Sum types are the #1 most-requested feature in the Go Developer Survey, yet Go still doesn’t have them. GALA delivers sum types today through sealed types — algebraic data types (ADTs) that define a fixed set of variants, with the compiler enforcing that every match expression handles all of them. If you’ve been looking for golang sum types, discriminated unions, or closed type hierarchies, this is the answer.
An algebraic data type (ADT) is a composite type formed by combining other types. The most common form is the sum type — a type that can be one of several named variants, each carrying different data. Languages like Rust (enum), Scala (sealed trait), and Haskell (data) have had these for years. Go has no native equivalent. GALA fills that gap.
With sealed types you get:
Apply methodsUnapply methods for pattern matchingA sealed type declaration lists every variant inside a single block. Each case defines a variant with its own fields:
sealed type Shape {
case Circle(Radius float64)
case Rectangle(Width float64, Height float64)
case Point()
}
Variants can carry zero or more fields. Point() above has no fields — it represents a singleton-like value.
When you write a sealed type, the transpiler produces several things automatically.
Parent struct — A single Shape struct that merges all variant fields plus a _variant discriminator. This keeps memory layout flat and allocation-free.
Companion objects — Empty structs Circle{}, Rectangle{}, Point{} that act as factory namespaces.
Apply methods — Each companion gets an Apply method so you can construct variants with function-call syntax:
val c = Circle(3.14)
val r = Rectangle(10.0, 20.0)
val p = Point()
Unapply methods — Each companion gets an Unapply method for use in pattern matching. You never need to call these directly; the match expression uses them behind the scenes. To test which variant a value is, match on it (see Pattern Matching) — that’s the idiomatic approach, and it stays exhaustive.
Copy and Equal — Like all GALA structs, sealed types get Copy() and Equal() for free.
Variants are constructed by calling the companion name as a function. Positional and named arguments both work:
// Positional arguments
val c = Circle(3.14)
val r = Rectangle(10.0, 20.0)
// Named arguments
val r2 = Rectangle(Width = 15.0, Height = 25.0)
Sealed types support type parameters, enabling you to define reusable algebraic structures:
sealed type Result[T any] {
case Ok(Value T)
case Err(Error error)
}
val success = Ok(42)
val failure = Err[int](errors.New("oops"))
Generic sealed types work exactly like non-generic ones — you get companion objects, Apply/Unapply, and exhaustive matching, all parameterized by the type argument.
The compiler knows every variant of a sealed type. When you match on all of them, no case _ default is needed:
func area(s Shape) string = s match {
case Circle(r) => f"circle area: ${3.14159 * r * r}%.2f"
case Rectangle(w, h) => f"rect area: ${w * h}%.2f"
case Point() => "point"
}
If you forget a variant, the compiler tells you. This eliminates an entire class of bugs where new variants are added but forgotten in downstream logic.
You can add if conditions to sealed type match branches:
val desc = shape match {
case Circle(r) if r > 100.0 => "large circle"
case Circle(r) => s"circle with radius $r"
case Rectangle(w, h) => f"$w%.0f x $h%.0f"
case Point() => "point"
}
A case’s fields can declare defaults, written as a function’s parameter defaults are. A constructor call leaves a defaulted field out, by position or by name:
sealed type Shape {
case Circle(Radius int, Label string = "circle")
case Square(Side int)
}
func main() {
Println(Circle(3), Circle(Radius = 4), Circle(5, Label = "big"))
// Circle(3, circle) Circle(4, circle) Circle(5, big)
}
Fields with defaults come after the fields without them
(GALA-E0013). A default case’s defaults also make
the type’s default value: what an
uninitialized var or an omitted field holds.
A sealed type can mark one case default. The default case is the type’s
zero value: what a value holds when nothing constructed it, such as an
uninitialized var, a field a struct literal leaves out, a slot of a fresh Go
slice, or a missed Go map lookup.
sealed type Mode {
case On(Level int)
default case Off()
}
struct Cfg(Name string, M Mode)
func main() {
var m Mode
val c = Cfg{Name: "a"} // M left out
Println(m, c.M) // Off() Off()
}
Option’s default case is None(), so an uninitialized Option is None.
A sealed type without a default case has no default value: GALA code that
would leave a value of it unset is an error (GALA-E0075).
A Go zero value of one can still come from Go; it is none of its cases, and an
exhaustive match without a wildcard panics on it, naming the type (a case _
or binding arm matches it like any value). Leave the default case out when every value must be built
from a case, as for Try and Either. A sealed type marks at most one case
default (GALA-E0074).
Go can build a value without calling any GALA code, as a composite literal or a zero value, and so skip the field defaults GALA applies. An exported sealed type therefore gets constructors for Go callers, and its doc comment says to use them:
New<T>() returns the type’s default value, when it has a
default case;New<T><Case>(...) builds a case: each field without a default is a
parameter, and each field with one is an option, With<T><Case><Field>(v),
applied over its default.For
package lights
sealed type Light {
case Off()
default case On(Power int = 30, Room string = "Living")
}
a Go caller writes:
l := lights.NewLight() // On(30, Living)
dim := lights.NewLightOn(lights.WithLightOnPower(5)) // On(5, Living)
off := lights.NewLightOff()
A sealed type without a default case gets no New<T>(), and its doc comment
lists its case constructors instead. A constructor is not generated under a
name the package’s non-test files already declare (in GALA or hand-written
Go), or that two of its types would both generate; a case constructor is skipped when any name it needs (its
options included) is taken.
Each exported case also gets a predicate for Go callers, Is<Case>(), true
when the value is that case: d.IsPercent(). It is not generated when the
type declares a method of that name or has a field of it. The
Go interop guide shows
the generated Godoc.
When you only care about a subset of variants, use case _ as a catch-all. The compiler accepts this as a valid exhaustive match:
sealed type Animal {
case Dog(Name string)
case Cat(Name string)
case Bird(Name string)
}
val msg = animal match {
case Dog(name) => s"Woof! I'm $name"
case _ => "I'm not a dog"
}
GALA’s standard library defines three core sealed types that you will use constantly:
| Type | Variants | Purpose |
|---|---|---|
Option[T] |
Some(Value T), None() |
Optional values — eliminates nil |
Either[A, B] |
Left(LeftValue A), Right(RightValue B) |
Disjoint union — typed errors |
Try[T] |
Success(Value T), Failure(Err error) |
Failable computations |
All three support Map, FlatMap, GetOrElse, pattern matching, and more. See the Error Handling page for details.
val opt = Some(42)
val msg = opt match {
case Some(v) => s"got $v"
case None() => "empty"
}
| Use sealed types when… | Use interfaces when… |
|---|---|
| You know all variants at compile time | Third parties need to add implementations |
| You want exhaustive matching | You want open extension |
| Variants carry different data shapes | Implementations share a common method set |
| You are modeling state machines, ASTs, or messages | You are defining contracts between packages |
Sealed types and interfaces can coexist. You can define methods on a sealed type that satisfy an interface, giving you both closed variants and polymorphism.