lo icon indicating copy to clipboard operation
lo copied to clipboard

Proposal: Add a generic-based compile-time interface assertion helper

Open nickxudotme opened this issue 1 month ago • 5 comments

TL;DR

Introduce a modern, generic-friendly alternative to Go's traditional interface implementation check.

Current idiom:

var _ SomeInterface = (*SomeStruct)(nil)

Proposed alternative:

var _ = lo.Implement[SomeInterface](&SomeStruct{})

Both provide a compile-time assertion, but the new form aims to be more explicit and idiomatic in the era of generics.


Motivation

Many long-standing Go idioms have gradually been replaced by more modern, expressive forms as the language evolves.

For example, deleting a slice range used to require manual slicing:

a = append(a[:i], a[j:]...)

Now Go encourages a clearer and safer built-in helper:

a = slices.Delete(a, i, j)

However, one particular idiom still carries the “old Go” flavor—the pattern for asserting that a struct implements an interface:

var _ SomeInterface = (*SomeStruct)(nil)

Although widely used, this pattern has some drawbacks:

  • It is not immediately intuitive for newcomers.
  • It reads more like a trick than an explicit assertion.
  • It feels out of place in modern, generic-heavy Go codebases.

Since lo embraces functional-style utilities and code clarity, this proposal explores whether a small generic helper could provide a more expressive alternative.


Proposal

Introduce a generic helper that performs a compile-time interface implementation check:

func Implement[T any](_ T) struct{} {
    return struct{}{}
}

Usage:

var _ = Implement[SomeInterface](&SomeStruct{})

Variants

Support asserting multiple implementations at once:

func Implement[T any](_ ...T) struct{} {
    return struct{}{}
}

var _ = Implement[SomeInterface](&SomeStruct{}, &AnotherStruct{})

Or a void-return version:

func Implement[T any](_ T) {}

func init() {
    Implement[SomeInterface](&SomeStruct{})
}

Why this might fit lo

  • It is tiny and almost zero-cost.
  • It improves clarity and self-documentation.
  • It complements lo’s philosophy of composable helpers.

Why it might not fit lo

  • lo traditionally focuses on functional utilities, not language-level contracts.
  • The classic idiom is already short and widely understood.
  • The community has not yet standardized a generic-based interface assertion pattern.

Alternative: publish under a smaller module

I understand this proposal is somewhat outside lo’s core domain, but if there’s interest, it could live in a small side package, such as:

  • lo/asserts
  • lo/constraints

nickxudotme avatar Nov 20 '25 03:11 nickxudotme

Hey @nickxudotme

Thanks for the proposal.

I would suggest something like lo.Cast[MyInterface](&myStruct{}):

func Cast[T any](t T) T {
	return t
}

samber avatar Nov 22 '25 10:11 samber

Hey @nickxudotme

Thanks for the proposal.

I would suggest something like lo.Cast[MyInterface](&myStruct{}):

func Cast[T any](t T) T { return t }

Thanks for your reply! Yeah, your version feels cleaner and fits the runtime vibe of lo much better.

If you’re okay with it, I’d love to open a PR adding Cast to type_manipulation.go, plus some comments and a Playground link to match the style of the other functions.

Just let me know! 😄


By the way, I ran a benchmark to confirm that this function incurs no memory allocations after compiler optimizations, making it a suitable replacement for the old idiom.

package main

import (
	"testing"
)

type SomeInterface interface {
	Foo()
}

type SomeStruct struct{}

func (s *SomeStruct) Foo() {}

type AnotherStruct struct{}

func (s AnotherStruct) Foo() {}

func Cast[T any](t T) T {
	return t
}

func BenchmarkCastPointer(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = Cast[SomeInterface](&SomeStruct{})
	}
}

func BenchmarkCastValue(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = Cast[SomeInterface](AnotherStruct{})
	}
}

Result

goos: darwin
goarch: arm64
pkg: hello
cpu: Apple M3 Max
BenchmarkCastPointer
BenchmarkCastPointer-14    	1000000000	         0.2761 ns/op	       0 B/op	       0 allocs/op
BenchmarkCastValue
BenchmarkCastValue-14      	1000000000	         0.2680 ns/op	       0 B/op	       0 allocs/op
PASS

nickxudotme avatar Nov 22 '25 17:11 nickxudotme

Let's discuss implementation before opening the PR.

In #624 I was suggesting different variants:

  • Cast[T any](v T) -> returns casted value (with compile-time validation)
  • CastOr[T any](v any, fallback T) -> returns the casted value or fallback value
  • CastOrEmpty[T any](v any, fallback T) -> returns the casted value or empty value

WDYT ?

samber avatar Nov 22 '25 21:11 samber

Hi @nickxudotme, I apologize for intervening in the discussion so abruptly.

It seems that Implement handles two tasks:

  1. type conversion and
  2. type assignable assertion.

However, neither the root word nor the part of speech of this verb expresses these meanings. Therefore, I believe a different name is appropriate.

As for how to implement it, I agree with what was mentioned above, but perhaps some stricter distinctions could be made regarding naming semantics.

  1. StaticCast[T any](v T) T or StaticCast[T](... T) with compile-time assertion (naming like C++ https://en.cppreference.com/w/cpp/language/static_cast.html). It also guarantees the assignability of embedded types.
  2. Cast[Src, Dst any](src Src) (Dst, bool) or MustCast[Src, Dst any](src Src) Dst with panic if cast failed
  3. CastOr[Src, Dst any](src Src, def Dst) Dst or CastOrSupply[Src, Dst any](src Src, f func() Dst) Dst which will be called if casting failed
  4. CastOrZero[Src, Dst any](src Src) Dst with zero value of type Dst just like var dst Dst declared

jizhuozhi avatar Nov 23 '25 11:11 jizhuozhi

OK, I am trying to make this perfect.

Thanks @jizhuozhi for the detailed proposal! I really appreciate the thoughtful design.

Let me analyze each function from a practical perspective:


1. StaticCast[T any](v T) T

High usage frequency - this is my original requirement.

// Practical scenario: ensuring a type implements an interface
var _ = StaticCast[io. Writer](&bytes.Buffer{})
var _ = StaticCast[http.Handler](MyHandler{})

However, I think the name Cast is already sufficient.


2. Cast[Src, Dst any](src Src) (Dst, bool)

Very common requirement.

func HandleRequest(data any) error {
    user, ok := Cast[any, *User](data)
    if !ok {
        return errors.New("invalid user data")
    }
    // use user... 
}

But Go's assertion syntax is already concise enough:

user, ok := data.(*User)
user, ok := Cast[any, *User](data)

3. MustCast[Src, Dst any](src Src) Dst

Same as above, we can directly use the native assertion syntax.


4. CastOr[Src, Dst any](src Src, def Dst) Dst

High usage frequency, and indeed more concise than the native approach.

age, ok := data.(int)
if !ok {
    age = 0
}

age := CastOr[any, int](data, 0) // Or: CastOr(data, 0)

Or we can use CastOr[Dst any](src any, fallback Dst) Dst , this actually looks better


5. CastOrSupply[Src, Dst any](src Src, f func() Dst) Dst

A lazy-loading variant, but do we really need this in practice?

Unless the default value is very expensive to compute.

I find it hard to imagine scenarios where this is necessary.


6. CastOrZero[Src, Dst any](src Src) Dst

(Also known as CastOrEmpty, but I think CastOrZero is more consistent in style)

Returns zero value variant:

// Practical scenario
count := CastOrZero[any, int](response["count"])
name := CastOrZero[any, string](userMap["name"])

// Equivalent to:
count := CastOr(response["count"], 0)
name := CastOr(userMap["name"], "")

And it has an advantage in a small scenario - for example, 0 is inferred as int by default, but what if you want int64?

count := CastOrZero[any, int64](response["count"])  // Clear
count := CastOr(response["count"], int64(0))        // Requires type conversion
count := CastOr[any, int64](response["count"], 0)   // Or this way

Or we can use CastOrZero[Dst any](src any) Dst


Conclusion

So I think we need to provide three functions:

// 1. Compile-time type assertion
func Cast[T any](v T) T {
    return v
}

// 2. Runtime type conversion with custom fallback
func CastOr[Src, Dst any](src Src, fallback Dst) Dst {
    if v, ok := any(src).(Dst); ok {
        return v
    }
    return fallback
}

// Or 
func CastOr[Dst any](src any, fallback Dst) Dst {
	if v, ok := src.(Dst); ok {
		return v
	}
	return fallback
}

// 3.  Runtime type conversion with zero value fallback
func CastOrZero[Src, Dst any](src Src) Dst {
    if v, ok := any(src).(Dst); ok {
        return v
    }
    var zero Dst
    return zero
}

// Or
func CastOrZero[Dst any](src any) Dst {
	if v, ok := src.(Dst); ok {
		return v
	}
	var zero Dst
	return zero
}

Usage examples:

// Scenario 1: Compile-time check - use Cast
var _ = lo.Cast[http.Handler](MyHandler{})

// Scenario 2: Need zero value - use CastOrZero
age := lo.CastOrZero[any, int](userData["age"])
age := lo.CastOrZero[int](userData["age"]) // looks better

// Scenario 3: Need custom default value - use CastOr
age := lo.CastOr[any, int](userData["age"], 18)
age := lo.CastOr[int](userData["age"], 18) // looks better

This is already concise and practical, and can cover most scenarios.

nickxudotme avatar Dec 02 '25 15:12 nickxudotme