Go Generics – Unions

Issue

I’m playing around with go generics by modifying a library I created for working with slices. I have a Difference function which accepts slices and returns a list of unique elements only found in one of the slices.

I modified the function to use generics and I’m trying to write unit tests with different types (e.g. strings and ints) but am having trouble with the union type. Here‘s what I have, now:

type testDifferenceInput[T comparable] [][]T
type testDifferenceOutput[T comparable] []T
type testDifference[T comparable] struct {
    input testDifferenceInput[T]
    output testDifferenceOutput[T]
}

func TestDifference(t *testing.T) {
        for i, tt := range []testDifference[int] {
            testDifference[int]{
                input: testDifferenceInput[int]{
                    []int{1, 2, 3, 3, 4},
                    []int{1, 2, 5},
                    []int{1, 3, 6},
                },
                output: []int{4, 5, 6},
            },
        } {
            t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
                actual := Difference(tt.input...)

                if !isEqual(actual, tt.output) {
                    t.Errorf("expected: %v %T, received: %v %T", tt.output, tt.output, actual, actual)
                }
        })
    }
}

I would like to be able to test both int’s or string’s in the same table test. Here’s what I’ve tried:

type intOrString interface {
    int | string
}
type testDifferenceInput[T comparable] [][]T
type testDifferenceOutput[T comparable] []T
type testDifference[T comparable] struct {
    input testDifferenceInput[T]
    output testDifferenceOutput[T]
}

func TestDifference(t *testing.T) {
        for i, tt := range []testDifference[intOrString] {
            testDifference[int]{
                input: testDifferenceInput[int]{
                    []int{1, 2, 3, 3, 4},
                    []int{1, 2, 5},
                    []int{1, 3, 6},
                },
                output: []int{4, 5, 6},
            },
            testDifference[string]{
                input: testDifferenceInput[string]{
                    []string{"1", "2", "3", "3", "4"},
                    []string{"1", "2", "5"},
                    []string{"1", "3", "6"},
                },
                output: []string{"4", "5", "6"},
            },
        } {
            t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
                actual := Difference(tt.input...)

                if !isEqual(actual, tt.output) {
                    t.Errorf("expected: %v %T, received: %v %T", tt.output, tt.output, actual, actual)
                }
        })
    }
}

However, when running this, I get the following error:

$ go version
go version dev.go2go-55626ee50b linux/amd64

$ go tool go2go test
arrayOperations_unit_test.go2:142:6: expected ';', found '|' (and 5 more errors)

Why is it complaining about my intOrString interface?

EDIT #1 – I can confirm, with @Nulo’s help, that gotip does work, and I now understand why I can’t use intOrString as a type – it’s supposed to be a constraint.

However, it would still be nice to find some way to mix ints and strings in my table test…

$ gotip version
go version devel go1.18-c812b97 Fri Oct 29 22:29:31 2021 +0000 linux/amd64

$ gotip test
# github.com/adam-hanna/arrayOperations/go2 [github.com/adam-hanna/arrayOperations/go2.test]
./arrayOperations_unit_test.go:152:39: interface contains type constraints
./arrayOperations_unit_test.go:152:39: intOrString does not satisfy intOrString
./arrayOperations_unit_test.go:155:6: incompatible type: cannot use []int{…} (value of type []int) as []intOrString value
./arrayOperations_unit_test.go:156:6: incompatible type: cannot use []int{…} (value of type []int) as []intOrString value
./arrayOperations_unit_test.go:157:6: incompatible type: cannot use []int{…} (value of type []int) as []intOrString value
./arrayOperations_unit_test.go:159:13: incompatible type: cannot use []int{…} (value of type []int) as testDifferenceOutput[intOrString] value
./arrayOperations_unit_test.go:163:6: incompatible type: cannot use []string{…} (value of type []string) as []intOrString value
./arrayOperations_unit_test.go:164:6: incompatible type: cannot use []string{…} (value of type []string) as []intOrString value
./arrayOperations_unit_test.go:165:6: incompatible type: cannot use []string{…} (value of type []string) as []intOrString value
./arrayOperations_unit_test.go:167:13: incompatible type: cannot use []string{…} (value of type []string) as testDifferenceOutput[intOrString] value
./arrayOperations_unit_test.go:152:39: too many errors
FAIL    github.com/adam-hanna/arrayOperations/go2 [build failed]

Solution

If you come across this Q&A because of its generic title (pun not intended), here’s a quick primer about unions:

  1. Can be used to specify the type set of an interface constraint. A generic type parameter T will be restricted to the types in the union
  2. Can be used only in interface constraints. And if an interface contains a union (with one or more terms) then it is an interface constraint.
  3. Can include approximate elements with ~

For example:

type intOrString interface {
    int | string
}

func Foo[T intOrString](x T) {
    // x can be int or string
}

Now onto the OP’s question, with some more details:

You can’t use an interface constraint as a type

By including a type set, intOrString becomes an interface constraint, and using it as a type is explicitly not supported. Permitting constraints as ordinary interface types:

This is a feature we are not suggesting now, but could consider for later versions of the language.

So the first thing to do is to use intOrString as an actual constraint, hence use it in a type parameter list. Below I replace comparable with intOrString:

type testDifferenceInput[T intOrString] [][]T
type testDifferenceOutput[T intOrString] []T
type testDifference[T intOrString] struct {
    input testDifferenceInput[T]
    output testDifferenceOutput[T]
}

This also means you can’t use the constraint to instantiate a concrete type as your test slice:

// bad: using intOrString to instantiate a parametrized type
[]testDifference[intOrString]

A generic container can’t hold items of different types

The second problem you have is that the test slice contains two structs of unrelated types. One is testDifference[int] and one is testDifference[string]. Even though the type testDifference itself is parametrized with a union constraint, its concrete instantiations are not the same type. See also this for further details.

If you need a slice holding different types, your only option is []interface{} (or []any) …or, you just separate the slices:

ttInts := []testDifference[int]{ testDifference[int]{...}, /* etc. */ }
ttStrs := []testDifference[string]{ testDifference[string]{...}, /* etc. */ }

Allowed operations on union constraints

Only the operations supported by all types in the type set. Operations based on type sets:

The rule is that a generic function may use a value whose type is a type parameter in any way that is permitted by every member of the type set of the parameter‘s constraint.

In case of a constraint like int | string, what are the operations permitted on either int or string? In short:

  • var declaration (var foo T)
  • conversions and assertions T(x) and x.(T), when appropriate
  • comparison (==, !=)
  • ordering (<, <=, > and >=)
  • the + operator

So you can have an intOrString constraint, but the functions that make use of it, including your func Difference, are limited to those operations. For example:

type intOrString interface {
    int | string
}

func beforeIntOrString[T intOrString](a, b T) bool {
    return a < b
}

func sumIntOrString[T intOrString](a, b T) T {
    return a + b
}

func main() {
    fmt.Println(beforeIntOrString("foo", "bar")) // false
    fmt.Println(beforeIntOrString(4, 5)) // true

    fmt.Println(sumIntOrString("foo", "bar")) // foobar
    fmt.Println(sumIntOrString(10, 5)) // 15
}

Answered By – blackgreen

Answer Checked By – Pedro (GoLangFix Volunteer)

Leave a Reply

Your email address will not be published.