Go FAQ: The Pipe Operator In Generics Is Not A Sum Type

Page content

An increasingly-frequently asked question in the Go subreddit is some variant of: “I have this code but it isn’t doing what I expect it to do.”

type MyStruct struct {
    // definition
}

type OtherStruct struct {
    // definition
}

type MySumType interface {
    MyStruct | OtherStruct
}

func main() {
    myStruct := MyStruct{...}
    printVal(myStruct)
}

func printVal[T MySumType](in T) {
    switch val := in.(type) {
    case MyStruct:
        fmt.Println("It's a MyStruct")
    case OtherStruct:
        fmt.Println("It's an OtherStruct")
    }
}

There’s a variety of manifestations of the question at this point, such as, “the switch won’t compile” or “I can’t take a MySumType as a type parameter on a function”, or a couple of other varients, but the specific variant doesn’t matter because there is no syntax tweak to fix this, because this is not what the | operator is.

The | Operator

I think it makes some sense that people think that’s what this, for three reasons.

First, the | symbol is generally associated with “or” operations, so it makes intuitive sense to mentally read MyStruct | OtherStruct as something like “MyStruct OR OtherStruct”.

Second, it resembles the operator used by some other languages to declare sum types:

type Color = Red | Green | Blue

And finally, if you remove the switch statement from the above code, it even compiles! This really makes it look like you’ve created a function that takes one of the types you’ve listed and not other types.

So I do not criticize people who start with this idea. There are a lot of roads to it and the fact that it seems to half compile is a rather attractive nuisance.

Alas, this is not what the | operator in Go is. Rather than a union, it is much better to think of it as an intersection; you get the intersection of all the types listed. And also, you specifically get them for the language operators, so this does not include methods or struct fields. (There are some proposals to address this in the future; if you’re reading this in 2026 or beyond, this may no longer be accurate, but it is accurate as I write it.)

So what MyStruct | OtherStruct really means is “I can accept either of these two types and use the operators they both have on them”. As “the operators they both have” in the case of a struct is limited to == and &, this means that just about the only functions you can write on this interface are:

func Equality[T MySumType](l T, r T) bool {
	return l == r
}

func PtrTo[T MySumType](in T) *T {
     return &in
}

both of which are generally useless; Equality is useless because if you have two values that you could pass to this function in hand, you could have simply used == on them directly, and PtrTo here is useless because even though it can be useful, this exact manifestation of it is over-specified; you might as well just write PtrTo[T any]. It has nothing to do with your “sum type”.

Note that in Equality that type signature does NOT mean that l could be a MyStruct and r could be an OtherStruct, because both values still have to be the same type. If you instead write:

func Equality[L MySumType, R MySumType](l L, r R) bool {
	return l == r
}

you’ll get a compile error because now not even the == operator is in scope anymore, because two structs of different types can not be compared with ==, so you can’t even do an equality test with a sum type.

Another way to think about it is, when writing the generic function, you are not writing code that has “either” a MyStruct or an OtherStruct, you have to think of your generically-typed values as having both of them, at the same time. Everything you do must apply to all the types at all points you use it. Thus, you can write:

func CentsToDollars[T float64 | float32](in T) T {
	return in * .01
}

and that code will compile and run and work on both float types, but you could not add an integer type to the type set because you can’t multiply an int by .01, and you will get the compile error

cannot convert .01 (untyped float constant 0.01) to type T

because the values of type T in your generic function must be able to operate on all the types at once, not one of them at a time.

As a consequence of the fact that the operator doesn’t work on method sets or fields, it is effectively useless to put struct types in a | operator. And as a consequence of that, at least as of the writing of this in 2024, the standard library already defines pretty much all the useful uses of |.

It is almost always a mistake to have a | in your user code at this point.

This is the sort of statement where if you’re reading this, and you intimately understand everything I’ve already said here, and you understand Go generics deeply, and you have some sort of exceptional case come up in your code and you can explain exactly why this is an exception for you, then by all means deploy your exception. But if the content of this post is news to you, then you should just stay away from direct use of the | operator.

What To Do Instead

I am a big believer of not Writing X In Y. That is, do not bring over paradigms from other languages and jam them where they don’t fit well, but instead learn how to cut “with the grain” of the language you are using and use what works in that.

This is not special pleading for Go. It is true for any combination of X and Y. It is as silly to jam the wrong paradigm into Go as it is to go to the Haskell subreddit and ask how to implement an interitance-based OO system in Haskell, and for the exact same reasons.

Consider Normal Interfaces

So the first thing to ask yourself is, can I just use a normal interface in Go? At first, start with the idea that it can just be an open interface.

A classic first example of sum types is to declare some colors, like I did in my Haskell snippet above. If your sum type represents the colors of spray paint that you are stocking, this may make sense, because “outside” things can’t just declare new colors for you. But if it’s the color of a paintbrush in an image editing program, all that the color represents is some value in some color space anyhow, and the correct spelling of the Haskell example above is probably something like:

type RGB struct {
    R byte
    G byte
    B byte
}

type Color interface {
    RGB() RGB
}

That is, a color is just anything that can yield an RGB value. There’s no need to use a sum type for this, and there isn’t even a need to close the interface off to prevent new values from being created.

Sum types are naturally closed, in that external packages can not add additional values to them, and while this can be useful in some cases, it is also quite often just an accidental consequence of using sum types in a language that naturally favors them. When going in to a language that does not favor them, it is important to examine your situation to see if you need the closure provided by sum types, or if it just came along for the ride. There are many cases where either it doesn’t matter, or by some simple favoring of polymorphism over data, code can be easily rewritten to move responsibility for answering questions about the data value into methods, rather than in functions.

That is, a function like

name :: Color -> String
name Red = "red"
name Blue = "blue"
name Green = "green"

in Haskell can be naturally modelled in Go not by taking in a restricted sum type, but by extending the Color interface:

type Color interface {
    RGB() RGB
    Name() string
}

and could continue to be extended with other color spaces or whatever else a “color” may constitute in your program. While the result is undeniably more verbose, in that the dapper little Haskell declarations above turn into

func (r Red) Name() string { return "red" }

it is still 100% compile-time safe; anywhere you call for a Color in your code you will be compile-time guaranteed (modulo nil) to have something that has a Name(), so you get the completeness guarantees that sum types offer.

Most of the time it is not necessary to close interfaces.

I would also #include my earlier post about Abuse of Sum Types in OO Languages as something worth considering.

So, all this said, I must just hate sum types and not have seen the Light of Functional Programming, yes? No. There is a time and a place for sum types, and even though Go doesn’t have them as slick as some other languages, the more I see people debate this matter the more I think the best answer you’re going to get is something that has been in Go since 1.0:

Closed Interfaces

A “closed interface” is not a term you’ll find in the Go language specification, but I use it to refer to an interface declaration that includes an unexported method in it:

type Color interface {
    isColor()
}

type Red struct {}
func (r Red) isColor() {}

type Green struct {}
func (g Green) isColor() {}

type Blue struct {}
func (b Blue) isColor() {}

With this, assuming no other type in this package implements isColor, you have created a type, Color, that has exactly three possible types that implement it, Red, Green, and Blue.

It is a common misconception that some other package could implement this interface by implementing an isColor method; before writing an email to me, I recommend you try it out first, as you will find it is not possible for another package to implement a Color. The compiler has a special error for this.

From here you can type switch on colors in a way that looks very like first-level (not recursive) pattern matching in other languages:

func PrintColor(c Color) {
    switch c.(type) {
    case Red:
        fmt.Println("Red")
    case Green:
        fmt.Println("Green")
    case Blue:
        fmt.Println("Blue")
    }
}

There is absolutlely a time and a place for closed interfaces. Abstract Syntax Trees are a classic case, and I have some Go code that uses this technique to close off the AST node types.

That said, I still think that in many cases you are better off putting as much into methods on the interface as possible:

type Color interface {
    Name() string
    RGB() RGB
    isColor()
}

because then you don’t have to be writing as many switch statements, you just call methods normally. Methods can also be arbitrarily rich and do things like take other parameters in a way that type switching into a sum type’s value can not.

For instance, even in the super-classic case I have of an AST for mathematical expressions, I still have a Precedence() Precedence method in my AST interface, so I don’t have to do a type switch to obtain precedence, even though I have type switches for some other aspects of the code.

In many cases you may find that the isColor isn’t doing anything for you and you may remove it. But there are times when it is worth keeping for the assurance.

If you want assurance that you are exhaustively testing all branches of a sum type, while the Go compiler doesn’t have it built in, there is a linter that will check it for you, and writing that in to your pre-commit hook or compilation process is not a hard thing for a programmer to do.

A nifty thing about this too is that you get == back on your sum type, because by the definition of == in Go, two interface values are equal if their types are the same and then within that type their values are identical. So unlike the Equality function with two types above that was a compile-time error, closed interfaces permit input == Red checks.

Last-Ditch Hack

I strongly advise any of the previous solutions over this one, because this one is even more of an attractive nuisance in its own way. However, you can in fact write:

func PrintMyType[T MySumType](in T) {
	switch any(in).(type) {
	case MyStruct:
		fmt.Println("it's a MyStruct")
	case OtherStruct:
		fmt.Println("it's an OtherStruct")
	}
}

Notice the wrapping of the value with any() in the switch statement.

Now, you may be going here “Aha! This is exactly what I wanted! Jerf must just not have led with this because he just Doesn’t Get It and just wants ‘idiomatic Go’ to be written above all else just for the sake of it, but I don’t care about his stupid views anyhow and I’m just going to do this”.

But, no, that is not the issue. If this was the “solution” I would happily hold it up as such. The problem is that while this works in isolation, if you try to pervasively use it you will find it does not compose well at all. You’ll find that the resulting generics proliferate like crazy, and you start ending up with functions with numerous generic specifications that you’re constantly typing and not infrequently needing to help the type inference with, and it isn’t even that far down the road where you end up wanting to write some other type of generic function but basically can’t because this puzzle piece just can’t be lined up with that puzzle piece anymore.

This is one of the things where it’s effectively impossible to give a nice bite-sized example that fits into a blog post, because any bite-sized example would need to be obviously flawed in some other way to work. It doesn’t really become an unsolvable problem until you have many pieces you are trying to fit together.

That said, I strongly encourage you to go ahead charging down this road if you don’t believe me. Or maybe you’ve got a small program where this will never quite manifest and you don’t care. I don’t believe myself in taking authority’s dictates as gospel when it comes to programming and you should absolutely test this if you like. You are guaranteed to learn something, whether it is that I’m wrong, or more about how it is indeed a problem, so that’s nice.

My warning would be, though, look to the size and complexity of your type signatures. I’ve written a lot of Go code, of a quite non-trivial nature, and they should not in general be exploding in complexity. When you’ve got four or five generic specifications and some of them are wrapped around other generics themselves, step back and consider whether maybe I had a point after all. Go type signatures should not generally look like

func MyFunction[
    T1 Color[any],
    T2 comparable,
    T3 Container[Color[T2]],
    T4 ~int | float64,
](
    in T1,
    val T2,
    check map[T3]SomeOtherGeneric[Color[int64]],
) {
    // whatever on earth this function would actually do here
}

because, again, you’ll fairly rapidly hit the point where you can’t even write the next level of complexity up as you continue to try to compose this into other functionality.

If it’s this dangerous, why do I mention it? Because while pervasive use of this will mess your code up something fierce, there is, every once in a while, an isolated case where this will solve your problems cleanly. You can go tens of thousands of lines of code without this ever coming up, but it can be really convenient as a local isolated hack every once in a long while. It’s arguably always at least a “code smell”, but it’s a valuable option to know about. It’s definitely not “the solution to sum types in Go”, though, and not something you will be happy with slathering all over the place in your code.

In conclusion, whatever it is you are trying to do with the | operator, there is some way to come close to it in Go, but it probably doesn’t involve the | operator in your own code.