A tip and a trick when working with generics

New language features, especially significant ones like generics, inevitably come with some caveats. Here are two.

Part 1. Does this code compile?

Have a look at the following code. Do you think it compiles? If you think it doesn't compile, what might be the reason?

type Sizer[T any] interface {
    Size() uintptr
}

type Producer[T any] struct {
    val T
}

// Producer implements the Sizer interface

func (p Producer[T]) Size() uintptr {
    return unsafe.Sizeof(p.val)
}

func (p Producer[T]) Produce() T {
    return p.val
}

func Execute[T any](s Sizer[T]) {
    switch p := s.(type) {
    case Producer[T]:
        fmt.Println(p.Produce())
    default:
        panic("This should not happen")
    }
}

func main() {
    Execute[string](Producer[string]{"23"})
    Execute[string](Producer[int64]{27})
}

Look at the signature of Execute() in particular and at the two Execute() calls inside main(). When Execute() is instantiated with a string type, then the type of parameter s should be a Sizer[string], right? Similar to this pseudo-code:

func Execute[string](s Sizer[string])

Sizer[T] is an interface with a method Size(), and type Producer[T] implements that interface. Passing a Producer[string] to Execute[string], as in the first Execute() call in main(), therefore should work as advertised.

In the second call to Execute[string], the parameter is of type Producer[int64]. This certainly doesn't compile—or does it?

Try it in the Go Playground. (The code was written for, and tested with, Go 1.18.)

Back from the playground? Ok. You should have observed the following.

  1. The code compiles without errors.
  2. The first call to Execute succeeds.
  3. The second call to Execute also succeeds, but inside Execute, the type switch executes the default case.

Why does this happen? Why does the wrong type parameter trigger no error at compile time?

The interface has a type parameter, but...

Maybe you noticed this already. The Sizer interface declares a type parameter T but does not use it anywhere. The type parameter may look quite natural in this context, because, after all, Producer has one, too, and Producer shall implement Sizer, so Sizer also seems to need one.

In fact, since Sizer's type parameter is unused inside the definition of Sizer, we can safely omit it—from the interface definition, as well as from Execute()'s function signature.

A cleaner version

Here is the cleaned-up code. It behaves in exactly the same way as the original code, but is easier to read.

type Sizer interface {
    Size() uintptr
}

func Execute[T any](s Sizer) {
    switch p := s.(type) {
    case Producer[T]:
        fmt.Println(p.Produce())
    default:
        fmt.Printf("s implements Sizer. That's all we know about s. Size is: %d bytes.\n", s.Size())
    }
}

(Playground link)

Note that Execute() still has a type parameter. The type switch needs it for matching the type of s against Producer[T]. But the type parameter for Sizer does not fool us anymore.

To be completely clear on this, these two variants of an interface are semantically identical. The type parameter has no effect at all.

type Doer interface {
    Do()
}

type Doer[T any] interface {
    Do()
}

Part 2. A type switch on a generic type fails... but why?

Closely related to the above scenario is the following one. The generalized form of interfaces allows for modeling union types using generics. Here is an example of a union of string and int.

func PrintStringOrInt[T string | int](v T) {
    switch v.(type) {
    case string:
        fmt.Printf("String: %s\n", v)
    case int:
        fmt.Printf("Int: %d\n", v)
    default:
        panic("Impossible")
    }
}

func main() {
    PrintStringOrInt("hello")
    PrintStringOrInt(42)
}

Run this code in the Playground. The result might be somewhat unexpected.

The code does not even compile. The switch statement triggers this error message:

./prog.go:10:9: cannot use type switch on type parameter value v (variable of type T constrained by StringOrInt)

Go build failed.

It looks like the type switch does not want to switch on a parametrized type.

Why does the type switch fail to compile?

This is in fact the result of an intentional decision of the Go team. It turned out that allowing type switches on parametrized types can cause confusion.

From the Type Parameters Proposal:

In an earlier version of this design, we permitted using type assertions and type switches on variables whose type was a type parameter, or whose type was based on a type parameter. We removed this facility because it is always possible to convert a value of any type to the empty interface type, and then use a type assertion or type switch on that. Also, it was sometimes confusing that in a constraint with a type set that uses approximation elements, a type assertion or type switch would use the actual type argument, not the underlying type of the type argument (the difference is explained in the section on identifying the matched predeclared type).

(emphasis mine)

Let me turn the emphasized statement into code. If the type constraint uses type approximation (note the tildes)...

func PrintStringOrInt[T ~string | ~int](v T)

...and if there also was a custom type with int as the underlying type...

type Seconds int

...and if PrintOrString() is called with a Seconds parameter...

PrintStringOrInt(Seconds(42))

...then the switch block would not enter the int case but go right into the default case, because Seconds is not an int. Developers might expect that case int: matches the type Seconds as well.

To allow a case statement to match both Seconds and int would require a new syntax, like, for example,

case ~int:

As of this writing, the discussion is still open, and maybe it will result in an entirely new option for switching on a type parameter (such as, switch type T).

See proposal: spec: generics: type switch on parametric types · Issue #45380 for details.

Trick: convert the type into 'any'

Luckily, we do not need to wait for this proposal to get implemented in a future release. There is a super simple workaround available right now.

Instead of switching on v.(type), switch on any(v).(type).

switch any(v).(type) {
    ...

This trick converts v into an empty interface{} (a.k.a. any), for which the switch happily does the type matching.

Thanks

This article is based on a discussion in the golang-nuts mailing list, and I want to thank T Benschar for bringing up the topic, Ian Lance Taylor for providing the answer, and Brian Candler for sharing more explanations and the trick of using any(v).

(Photo by Brett Jordan on Unsplash)


Categories: Generics, The Language