A sum type (also known as enum / tagged union / one of / disjoint union) allows a type to be exactly one of several fixed alternatives. Functional languages treat this as a first-class concept with pattern matching and exhaustive checking. Go offers almost everything, but does not. My little linting tool sumlint aims to bridge part of that gap for Go.

Introduction

This concept exists in many functional languages. Take Elm, for example

import Html

type Shape
    = Circle Float
    | Point

area : Shape -> Float
area shape =
    case shape of
        Circle r ->
            pi * r * r
        Point ->
            0

main =
  let shape = Point
  in
  area shape
    |> Debug.toString
    |> Html.text

In this case, an idiomatic Go implementation would be straightforward.

type Areaer interface {
  Area() float64
}

type Point struct {}
func (p Point) Area() float64 {
  return 0
}

Consider creating a new case in Elm.

type Shape
    = Circle Float
    | Point
    | Rectangle Float Float

This results in a compile error that leads the developer to all places where this case must be handled. Additionally, using interfaces can be impractical when the underlying structs have different layouts.

This case does not account for all possibilities.

10|>    case shape of
11|>        Circle r ->
12|>            pi * r * r
13|>        Point ->
14|>            0


Missing possibilities include:

What have Go ever done for us?

Interfaces? Generics? Type switches?

All right, but apart of interfaces, generics and type switches?

Iterators?

Iterators? Oh shut up!

Go itself seems to be very close to having sum types. The following code is valid Go code that looks almost exactly like Elm examples.

type Shape interface {
  Circle | Point
}

Excellent proposal, sir! There are only two problems. The first is this is a generic type constraint only. And the second is this is only a generic type constraint.

Technically this is a single problem, but I considered it to be so important, that I mentioned it twice!

In other words, if you declare a variable of type Shape, the Go compiler will complain.

cannot use type Shape outside a type constraint: interface contains type constraints

However, since Go 1, there has been a nearly perfect construct in Go: a type switch. However, as this is Go, there is no way to perform advanced pattern matching; a switch can be based on types or values, but not in a single statement.

var shape Shape
var area float64
switch x.(Shape) {
case Point:
  area = 0.0
case Circle:
  area = math.Pi * x.R * x.R
}

Except for exhaustiveness. The Go compiler is unaware of which structs implement which interfaces, so adding a new case has no impact. The code will compile and crash at runtime. While possible, type switches are not idiomatic Go.

BurntSushi/go-sumtype

I am not the first person to try to solve this problem.Take https://github.com/BurntSushi/go-sumtype as an example. BurntSushi is a Rust developer and the author of ripgrep, among other projects. So it’s not surprising that he’s interested in having at least one type of sum type available in Go.

//go-sumtype:decl MySumType
type MySumType interface {
        sealed()
}
$ go-sumtype mysumtype.go
mysumtype.go:18:2: exhaustiveness check failed for sum type 'MySumType': missing cases f

Can we do it better?

The Rust code looks like this, and this approach is considered normal and idiomatic.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

Not in Go. The magic comments commonly seen in Go source code are directives of the compiler itself. Such as go:embed, go:build, go:tool. The only exception is nolint, which is used to turn off false positives of a linter.

In Go world, there is actually a great precedent. The Test, Bench, and Fuzz prefixes are merely conventions that have an actual meaning for testing code. So let’s have a linter that implements the following simple convention

// SumFoo declares a sum type, which is recognized by sumlint
type SumFoo interface {
	sumFoo()
}

The rules are simple.

The Sum interface must be public and have Sum as a prefix.

It must implement a private method with the same name and the sum prefix.

Each definition of this type is considered a sum type. Sumlint detects all structs that implement the interface and enforces exhaustiveness in type switches. No magic comments are needed, as the special status is visible from the type name itself.

An example

// SumFoo declares a sum type, which is recognized by sumlint
type SumFoo interface {
	sumFoo()
}

// A is an implementation of a SumFoo
type A struct{}
func (A) sumFoo() {}

// B is an implementation of a SumFoo
type B struct{}
func (B) sumFoo() {}

There are three cases. The first one is ideal because all cases are covered. Unlike in other languages, Go requires a default case to handle the nil interface case. This particular property may clash with a nilness or nilaway analyzers.

func good(x SumFoo) {
	switch x.(type) {
	case A, B:
	default:
	}
}

Removing B case leads to an error.

// missing B
func noB(x SumFoo) {
	switch x.(type) {
	case A:
	default:
	}
}
go vet -vettool=${HOME}/go/bin/sumlint .
./src.go:29:2: non-exhaustive type switch on SumFoo: missing cases for: github.com/gomoni/sumlint/tests.B

As well as a missing default. This case is intended to handle the nil interface value, which can’t be easily work-arounded in a Go.

// missing default, is reported
func noDefault(x SumFoo) {
	switch x.(type) {
	case A, B:
	}
}
go vet -vettool=${HOME}/go/bin/sumlint .
./src.go:23:2: missing default case on SumFoo: code cannot handle nil interface

Edited on 2021-10-18: fix the grammar, improved wording.