When is a slice of any not a slice of any?

When is []any not []any? When generics get involved!

Will the below compile? If not, why?

package main

func main() {
    one([]any{})
}

func one[S []E, E any](s S) {
    two(s)
}

func two(s []any) []any { return s }
See answer 👀

It doesn't compile, with this error:

generics/main.go:9:13: cannot use s (variable of type S constrained by []E) as type []any in argument to two:
 cannot assign []E (in S) to []any
 

It'd be quite reasonable to think it should compile, because:

  1. one has the type parameter [S []E, E any]
  2. by substitution, S = []E, E = any, so S = []any, surely?
  3. therefore we should be able to pass S into a function accepting []any?

Quick note: any is 'an alias for interface{} and is equivalent in all ways'. Though it landed alongside generics in Go 1.18, any is just interface{}.

But we know from the error that this reasoning is missing something. But what?

Why did Go add generics?

First up, let's remind ourselves why Go added generics. Without generics we couldn't write certain types of functions in a type-safe way, for example: "reverse a slice". Pre-generics, we'd have to have written something that sacrificed compile-time type-safety in some way. Either a function accepting any (AKA interface{}) and using reflection, or you might have tried writing a function accepting []any. I'll discuss just the second approach, reverseOld:

// technically it would have been []interface{} pre-Go 1.18
func reverseOld(s []any) bool) []any { /* ... */ }

Here's the generic version:

func reverseGeneric[S []E, E any](s S) bool) S { /* ... */ }

The reverseGeneric version is better for two reasons. First, the reverseOld approach loses type information: even when we pass in a slice with a non-any type like []io.Reader we get back []any:

readers := []io.Reader{}

// won't compile - we get back []any
var rev []io.Reader = reverseOld(readers)
// with generics  we keep the type
var revGen []io.Reader = reverseGeneric(readers)

Secondly, the reverseOld function can't work with all slices, but reverseGeneric can. reverseOld can only accept slices whose element type is an interface type:

files := []*os.File{&os.File{}}
readers := []io.ReadCloser{&os.File{}}

// works
_ = reverseOld(readers)
// fails: cannot use files (variable of type []*os.File) as type []any in argument to reverseOld
_ = reverseOld(files)

// works
_ = reverseGeneric(files)
_ = reverseGeneric(readers)

This second restriction might not surprise you at all - if so, skip ahead. But if you're thinking something like 'Huh?! But doesn't []any mean "any slice"?', you are not alone: read on.

[]any has never meant any slice

Even before generics, []interface{} (exactly equivalent to []any) did not mean "any slice".

Values with an interface type in Go are containers for concrete types, not the concrete types themselves. They're boxed types. Go does a lot to hide this from you for convenience, and that 'magic' also makes it really easy to write a lot of Go without being aware of this distinction. This is very different from how other languages handle this - e.g. TypeScript.

[]any in Go, before and after generics, means "a slice of interface boxes". Although every type can be place inside a 'box' of the any/interface{}, this doesn't imply every type is boxed. This means elements of slice of non-interface values like []int or []string are unboxed, so can't be assigned to []any. But []io.Reader or []interface{ foo() } have boxed elements, so can be assigned to []any.

This took me ages to get my head around. If this quick explanation hasn't clicked with you, I've written another post that explains why interfaces work like this in Go (even pre generics).

How are generics implemented?

We've learned type-parameters let us write a much broader set of generic functions than interfaces alone did. Now let's have a look into how Go compiles generic code. After that, we'll have enough to solve our puzzle.

Go's generics can be thought of as working by doing some copy-pasting - called 'instantiation' in the spec - for you. If you have a function that you want to work on a set of types, it'll 'copy-paste' specialised functions where the type parameters are replaced with specific types. Taking a function with the signature of our one function from the puzzle:

package main

func one[S []E, E any](s S) {}

func main() {
    one([]int{})
    one([]string{})
    one([]any{})
}

the compiler will generate us implementations of the one function for all the types assignable to S (the spec calls this the "type set"). In theory for any that's every type, a huge amount to compile! But in reality Go does this only for the subset of types that we actually pass into one in our program). The compiled functions will look a bit like this (use the compiler explorer to see real output), and you can see the type parameter has been replaced with the specific types we used:

// compiler generates the following implementations for us
func compiled_one(s []int) { }
func compiled_one(s []string) { }
func compiled_one(s []interface{}) { }

This should make it clear why S can't be considered []any. Two of the compiled function implementations accept slices of a specific concrete type.

So to correct our reasoning from the start of the post:

  1. one has the type parameter [S []E, E any]
  2. we know any here has a type-set that covers non-interface types (we saw that's the point of generics)
  3. we know that although if some type T can be assigned to any, if T is not an interface type []T isn't assignable to []any
  4. therefore we can't pass S into a non-generic function accepting []any. S is a superset of []any because []any only includes slices of interface types

This makes sense! As said, a major motivation for generics was exactly that, writing functions that can work with all slices, including slices of concrete types.

The two flavours of interface

Solving this mystery has taught us something important, and perhaps unfortunate: types like any, interface{} or io.Reader means something very different in generic and non-generic code:

Let's look at another implication of this difference: type-assertions.

Type-assertions and generics

In Go, we can use type-assertions to determine the concrete type of value of interface type at runtime. Or, at least, you can outside of generic code: with what you've learned, you should be able to figure out why. For instance:

package main

import "fmt"

type frobber interface{ frob() }

type mytype int

func (_ mytype) frob() {}

var _ frobber = mytype(0)

func normal(f frobber) {
    if iv, ok := f.(mytype); ok {
        fmt.Println("unwrapped you!", iv)
    }
}

func generic[F frobber](f F) {
    if iv, ok := f.(mytype); ok {
        fmt.Println("unwrapped you!", iv)
    }
}

If you run this you'll see normal compiles, but generic has a compile error:

generics/frob.go:15:15: invalid operation: cannot use type assertion on type parameter value f (variable of type F constrained by interface{frob()})

This shouldn't surprise us, because we now know that interface means something different in generic and non-generic code. In non-generic code, the value is always boxed in an interface value. In generic code it's a type-constraint, but the value may be concrete. Since we need the 'box' (interface value) to perform type-assertions, it makes sense that we can't use a type-assertion. This is captured in the spec, where the spec states type-assertions can be used only on an expression "of interface type, but not a type parameter". But what you've learned also means you understand why this rule is in the spec.

Far from basic

Let's add one more bit of confusion to the mix, and constrain an interface type with types, rather than just methods:

package main

type intString interface {
    // the ~ means newtypes with the underlying type of int
    ~int
    fmt.Stringer
}

type printableInt int

func (i printableInt) String() string {
    return fmt.Sprintf("%d", i)
}

func main() {
    var pi intString = 10
}

func withIntString(is intString) {}

We will get compile errors in two places here, because the only place you can use interfaces that have type constraints is in type-parameters. Huh - our intString interface uses the same interface keyword as other interfaces, but clearly works very differently!

Indeed, the spec tell us there are now two types of interface, 'basic' and 'general':

Interfaces that are not basic may only be used as type constraints, or as elements of other interfaces used as constraints. They cannot be the types of values or variables, or components of other, non-interface types.

After generics, Go now has 'basic' and 'general' interface types sharing the same syntax, and, as we've discussed above in this post, "basic" interfaces types behave completely differently in generic and non-generic code:

Non-generic code Generic code
Basic interface type Always boxed value, type-assertions Both boxed/unboxed values, no type-assertions
General interface type Not allowed Both boxed/unboxed values, no type-assertions

Tradeoffs

Sharing the interface keyword for generic and non-generic, and basic and general interfaces, is a tradeoff. It avoids adding new syntax, and sometimes it's intuitive: you don't need to understand the mechanics and it works pretty well how you'd expect. But I think I've demonstrated that this comes at some cost - the design has very literally confused things that behave very differently. This was pointed out by some maintainers during the design discussions:

I remain concerned that this proposal overloads words (and keywords!) that formerly had very clear meanings — specifically the words type and interface and their corresponding keywords — such that they each now refer to two mostly-distinct concepts that really ought to instead have their own names

I'm not stating this tradeoff was the wrong one, nor that there's an obviously better alternative. But it certainly has some curious implications.

Anyway, I hope this helps you understand the now overloaded meaning of 'interface', and you've deepened your understanding of Go.