PRODUCTS

KEYWORDS

What's New With Golang Generics

I write Dolt, a version controlled database written 100% in Go. This wasn’t a choice we made so much as a choice that was made for us, since it’s based on noms and built on go-mysql-server, which were both written in Go.

As a result, I use Golang every day, but I’m neither a devoted advocate nor a hater. Go is a tool, and given that we used it to build a drop-in replacement for MySQL that’s faster than MySQL, it does a perfectly decent job.

Go’s design philosophy is that it’s simple by design, and the language is very conservative about adding new features unless those features can be demonstrated to be actually necessary and do more good than harm. It’s a high bar, but a bar than can and has been met in the past. Remember that Go used to not support generics, but support was finally added in 1.18, after it was clear that they were needed.

Since then, Go’s generic support has slowly improved. Features are added when they allow for cleaner and more expressive code, but avoided when it would only lead to messier, harder-to-maintain code. Last year, I ruminated about potential features that Golang might add to generics, debating for each one whether or not it met the bar. I essentially asked for each feature: “Generic code was already hard to read, does adding this allow for even more complicated code? Or do they allow for cleaner ways to express existing ideas?

Cut to two years later, and some of the features I ruminated about have since been more-or-less added to the language. I decided to see how these additions had impacted Dolt, whether we had adopted them and whether they had enabled us to write cleaner code.

Generic Type Aliases (Added in Go 1.24)#

Prior to Go 1.24, the following was invalid:

type Set[T comparable] = map[T]struct{}

While generic type aliases don’t add any expressiveness to the language, they can remove a lot of repetition and clutter. They’re mostly useful when you intend to repeatedly use a specific type for some of a generic’s type parameters, so you partially specialize it once.

I was an advocate for generic type aliases last time, and I’m excited to see that they’re supported now. But they’re also very situational: I searched Dolt’s codebase and found 0 uses. So their use cases are indeed pretty narrow.

That said, now that they’re available, it’s possible they might find a home in Dolt in the future.

Recursive Type Constraints (Added in Go 1.26)#

This was a feature added in Go 1.26, allowing type constraints like the following:

// A Lattice is a type of weakly ordered values with a least value named Bottom, and a unique
// least upper bound for any two value.
type Lattice[T Lattice[T]] interface {
  Less(other T) bool
  LeastUpperBound(other T) T
  Bottom() T
}

func WeakTopologicalOrder[T Lattice[T]](elems []T) seq.Iter { ... }

var _ Lattice[LatticeImpl] = LatticeImpl{}

This lets us use method chaining in functions that operate on this type:

func NAryLeastUpperBound[T Lattice[T]](elems T[]) T {
  var result T
  result = result.Bottom()
  for _, l := range elems {
    result = result.LeastUpperBound(l)
  }
  return result
}

In other languages, using a type parameter in its own type constraint is called a curiously recurring template pattern. It has some additional uses in languages that allow template metaprogramming, but the main value in Golang is to define an interface for types with methods that accept or return the same time being implemented.

This strongly resembles the “self-type constraint” feature from my previous article, with the caveat that it’s technically possible to provide a different type for the type parameter from the type that’s implementing the interface. For instance, You could have LatticeImpl implement Lattice[SomeUnrelatedLatticeImpl], although you probably shouldn’t.

This looks really cool… but it’s not actually accomplishing as much as you might think. Prior to Go 1.26, the above interface definition wouldn’t be allowed, but we could write one that looks like this instead:

type Lattice[T any] interface {
  Less(other T) bool
  LeastUpperBound(other T) T
  Bottom() T
}

And our generic function above would still be just as correct. The main benefit of the recursive type constraint is that it more clearly documents intent. It’s more obvious at a glance that the generic interface is meant to be specialized with the same type that’s implementing it, and it rejects attempts to specialize it with a type that’s completely unrelated.

There’s no code in Dolt that uses this feature, but there’s some code that could be updated to use it, such as the SequencedRelation generic interface. This in an interface type that represents database objects that produce an incrementing sequence of values, such as MySQL’s AUTO_INCREMENT columns. Implementations of this interface have a method that take a state for sequence’s state machine and return a new database object whose state is set to the provided value. Since this is a type that can return itself, it has a self-referential type parameter named Self:

// an abridged version of the SequencedRelation generic interface
type SequencedRelation[Self any, StateType any] interface {
	// GetSequenceState returns the current SequenceState of the object.
	GetSequenceState(ctx context.Context) (StateType, error)
	// HasSequenceState returns whether the relation wraps a sequence.
	// (This may be false, for instance, for tables that do not have an AUTO INCREMENT column)
	HasSequenceState(ctx context.Context) (bool, error)
	// SetSequenceState unconditionally sets the SequenceState for the object.
	SetSequenceState(ctx context.Context, val StateType) (Self, error)
}

Note how the type of this constraint is any. With Go 1.26, we could use a more specific type for this constraint:

// This version is more self-documenting:
// It's more clear what the type parameter is for and harder to misuse.
type SequencedRelation[Self SequencedRelation[Self, StateType], StateType any] interface {
	// GetSequenceState returns the current SequenceState of the object.
	GetSequenceState(ctx context.Context) (StateType, error)
	// HasSequenceState returns whether the relation wraps a sequence.
	// (This may be false, for instance, for tables that do not have an AUTO INCREMENT column)
	HasSequenceState(ctx context.Context) (bool, error)
	// SetSequenceState unconditionally sets the SequenceState for the object.
	SetSequenceState(ctx context.Context, val StateType) (Self, error)
}

Generic Methods (Added in Go 1.27)#

Prior to Go 1.27, while function definitions could take generic type parameters, methods could not. This was because Go is a structurally typed language: the set of methods implemented by a type determines what interfaces it implements. If a type defines generic methods, it becomes difficult for the compiler to reason about which interfaces it implements, and even more for the compiler to determine which of the infinite number of possible specializations of the method will be needed at runtime.

This meant that it wasn’t possible to write generic methods, even if the method wasn’t intended to be part of an interface. For instance, if you have a function that’s tightly coupled with a type, you might want to make that function a method to have it exist in the function’s namespace. But if that function was generic, you couldn’t do that.

type Tree[T any] struct {
  node T
  children []Tree[T]
}

// Prior to Go 1.27, this was not allowed
func (t Tree[T]) Map[U any](f func(T) U) {
  result := Tree[U] { node: f(t.node)}
  for _, child := range children {
    result.children = append(result.children, f(child))
  }
}

Instead, you would have needed to write the almost-identical function below:

func MapTree[T any, U any](input Tree[T]) Tree[U] {
  result := Tree[U] { node: f(t.node)}
  for _, child := range children {
    result.children = append(result.children, f(child))
  }
}

With Go 1.27, types will finally be able to implement generic methods, with the condition that these methods do not participate in satisfying interface constraints. These methods won’t change the set of interfaces that the type implements but can still be used to add functions to the namespace of the type.

We have functions like the above in Dolt. But none of them have been migrated to use generic methods yet, because Go 1.27 isn’t out yet. But when it releases later this month, I’ll expect we’ll find ourselves writing generic methods when it is natural to do so.

Overall Impact#

Currently, Dolt doesn’t use any of the above features, although that likely won’t be true for long. I’ve already identified places where we could incorporate recursive type constraints for a little bit of extra type strictness, and we’ll definitely be writing generic methods once we’re able to. It’s possible that there are places that would benefit from generic type aliases, and we just haven’t identified them or done the necessary refactors yet.

So while none of these features have made an impact on Dolt, although I’m still glad they all exist.

Next time we’ll dive more into how Dolt expresses relationships between generic types in a clean, readable way. In the meantime, I know you all have strong opinions about Golang. Feel free to join our Discord and tell me all the ways I’m wrong about the language.