Skip to content

Instantly share code, notes, and snippets.

@creachadair
Last active March 26, 2023 17:04
Show Gist options
  • Select an option

  • Save creachadair/00db0306534d80c0a882c9ba17dd94dc to your computer and use it in GitHub Desktop.

Select an option

Save creachadair/00db0306534d80c0a882c9ba17dd94dc to your computer and use it in GitHub Desktop.
Underlying types in Go generics

Use of Underlying Types in Go Generics

When specifying the type parameters for a generic structure in Go, you can use the notation ~T to mean "any type whose underlying structure is equivalent to T". For example, given:

func F[X ~string](x X) {}

the type parameter X can be instantiated with any type whose underlying representation is string. That includes string itself, of course, along with types like type Foo string which are based on it.

When the type constraint is based on a concrete type, as in this example, the value of the ~ notation is clear. However, you will also see examples like this, drawn from the slices package:

func Clip[S ~[]E, E any](s S) S

In plain language, this allows S to be any type whose underlying structure is a slice of E. A natural question that sometimes comes up in discussion is why this could not be more simply written as:

func ClipE[E any](es []E) []E

And indeed ClipE will accept any argument that could have been passed to the original Clip:

ClipE([]int{1, 2, 3})

type Q struct{ X bool }
ClipE([]Q{{true}, {true}, {false}})

type QS []Q
ClipE(QS{{false}, {false}, {true}})

The important difference between the two is not in their arguments, but in their results. By declaring a type constraint S ~[]E, the Clip function ensures that its return value has the same type as its argument.

By contrast, regardless what its argument type is, the result of ClipE has type []E (for some E).

To see the difference, consider this example:

func Clip[S ~[]E, E any](s S) S { return s }
func ClipE[E any](es []E) []E   { return es }

type QS []int

es := QS{1, 2, 3}
fmt.Printf("es=%T, Clip(es)=%T\n", es, Clip(es))
fmt.Printf("es=%T, ClipE(es)=%T\n", es, ClipE(es))
// Output: 
// es=main.QS, Clip(es)=main.QS
// es=main.QS, ClipE(es)=[]int

Here we see that Clip correctly propagates the argument type (main.QS) to the return value, while ClipE discards the named type and returns a plain []int.

So, in summary, underlying type constraints can be useful whenever you need to declare a function that needs to preserve the actual (not just underlying) type of its arguments in its results.

Although the example above uses a function, this applies to methods also, e.g.,

type T[X any, Y ~[]X] struct {
	Thing Y
}

func (t *T[X, Y]) Foo(p Y) Y {
	old := t.Thing
	t.Thing = p
	return old
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment