Pattern Matching in Go: How GALA Brings Sealed Types and Exhaustive Matching to the Go Ecosystem

Go is a language built on simplicity. But simplicity has costs, and one cost Go developers feel acutely is the lack of pattern matching and algebraic data types. If you have ever modeled a closed set of variants -- shapes, AST nodes, API responses, state machine states -- you know the pain: interfaces, type switches, zero exhaustiveness checking, and boilerplate that grows with every new variant.
GALA is a programming language that transpiles to Go, bringing sealed types, exhaustive pattern matching, and algebraic data types to the Go ecosystem with zero runtime overhead. The output is plain Go code. Every Go library works. Every Go tool works. You just get better type safety where it matters.
This post walks through the problem, the solution, and real code you can try today.
The Problem: Modeling Variants in Go
Suppose you are building a graphics library and need to represent shapes. In Go, the standard approach uses interfaces and type switches:
type Shape interface {
isShape()
}
type Circle struct {
Radius float64
}
func (c Circle) isShape() {}
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) isShape() {}
type Triangle struct {
Base float64
Height float64
}
func (t Triangle) isShape() {}
To compute the area, you write a type switch:
func area(s Shape) string {
switch v := s.(type) {
case Circle:
return fmt.Sprintf("circle area: %.2f", math.Pi*v.Radius*v.Radius)
case Rectangle:
return fmt.Sprintf("rect area: %.2f", v.Width*v.Height)
case Triangle:
return fmt.Sprintf("triangle area: %.2f", 0.5*v.Base*v.Height)
default:
return "unknown shape"
}
}
This works. But it has three problems that get worse as your codebase grows:
No exhaustiveness checking. Add a new
Pentagonvariant. The compiler says nothing. Thedefaultbranch silently absorbs it, or worse, you forget thedefaultand get a zero-value return. You find the bug in production.Boilerplate. Every variant needs a struct, a marker method, and the type switch needs a
defaultcase. The interface itself (isShape()) carries no information -- it exists only to close the type set, and it does not even do that reliably since any package can implement it.No destructuring. You cannot extract fields inline. You match the type, then access fields on the bound variable. This is verbose and error-prone when variants have many fields.
These issues have been discussed in Go proposals for years (golang/go#19412, golang/go#41716). Sum types and pattern matching remain among the most requested features. The Go team has chosen not to add them, and that is a reasonable decision for the language's goals. But the need does not go away.
Enter GALA
GALA (Go Alternative Language) transpiles to Go. You write GALA source, the transpiler produces standard Go code, and you build the result with go build or Bazel. There is no runtime library, no reflection magic, no code generation at runtime. The generated Go is the kind of code you would write by hand -- just more of it, and correct.
GALA gives you sealed types (algebraic data types), exhaustive pattern matching with destructuring and guards, Option[T]/Either[A,B]/Try[T] monads, immutable collections, and full Go interop. You can import any Go package, call any Go function, and use any Go library.
The trade-off is honest: you add a transpilation step. Your source files are .gala instead of .go. IDE support is available for IntelliJ but not yet universal. Whether that trade-off is worth it depends on your project. For codebases heavy on variant types, state machines, or functional data processing, it often is.
Sealed Types: Closed Type Hierarchies Done Right
Here is the same Shape example in GALA:
sealed type Shape {
case Circle(Radius float64)
case Rectangle(Width float64, Height float64)
case Triangle(Base float64, Height float64)
}
That is the entire definition. The transpiler generates:
A parent struct
Shapewith a_variantdiscriminator fieldCompanion objects
Circle{},Rectangle{},Triangle{}withApplymethods for constructionUnapplymethods for pattern matching (destructuring)IsCircle(),IsRectangle(),IsTriangle()discriminator methodsCopyandEqualmethods
Construction is concise:
val c = Circle(3.14)
val r = Rectangle(10.0, 20.0)
Sealed types also support generics:
sealed type Result[T any] {
case Ok(Value T)
case Err(Error error)
}
val success = Ok(42)
val failure = Err[int](fmt.Errorf("not found"))
Compare this to the Go version. The GALA definition is 4 lines. The Go version is 18 lines of structs and marker methods, and it still does not give you exhaustiveness checking or destructuring.
Pattern Matching: Beyond the Type Switch
GALA's match expression supports literal matching, variable binding, destructuring, guards, nested patterns, and type-based matching. Here is the area function:
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 Triangle(b, h) => f"triangle area: ${0.5 * b * h}%.2f"
}
Notice what is different from Go:
Destructuring.
case Circle(r)binds theRadiusfield torin one step. Nov := s.(Circle); r := v.Radius.Expression syntax. The entire match is an expression that returns a value. No
var result stringdeclaration before the switch.No default case. All three variants are covered. The compiler knows this. If you remove the
Trianglebranch, you get a compile error.
Guards
Guards add conditions to patterns:
struct Person(Name string, Age int)
val status = person match {
case Person(name, age) if age < 18 => s"$name is a minor"
case Person(name, age) if age > 65 => s"$name is a senior"
case Person(name, _) => s"$name is an adult"
}
The equivalent Go code needs a type switch (or if-else chain), manual field access, and a mutable variable to hold the result:
var status string
switch {
case person.Age < 18:
status = fmt.Sprintf("%s is a minor", person.Name)
case person.Age > 65:
status = fmt.Sprintf("%s is a senior", person.Name)
default:
status = fmt.Sprintf("%s is an adult", person.Name)
}
Nested Patterns
Patterns compose. You can match extractors inside extractors:
type Even struct {}
func (e Even) Unapply(i int) Option[int] = if (i % 2 == 0) Some(i) else None[int]()
val opt = Some(10)
opt match {
case Some(Even(n)) => Println(s"even number: $n")
case Some(n) => Println(s"odd number: $n")
case None() => Println("nothing")
}
Try writing that with Go type switches. You would need nested switches, temporary variables, and manual extractor logic. In GALA, the intent is clear in a single expression.
Type-Based Matching
When working with any or interface types, GALA supports type patterns directly:
val x any = "hello"
val res = x match {
case s: string => "string: " + s
case i: int => s"int: $i"
case _ => "unknown"
}
This is similar to Go's type switch, but integrated into the same match syntax as everything else.
Exhaustive Matching: The Compiler Catches Missing Cases
This is where golang sealed types and exhaustive matching really pay off. When you match on a sealed type and miss a variant, the GALA transpiler rejects the code at compile time.
Given this sealed type:
sealed type TrafficLight {
case Red()
case Yellow()
case Green()
}
This code compiles:
val action = light match {
case Red() => "stop"
case Yellow() => "caution"
case Green() => "go"
}
But remove the Yellow case and the transpiler reports an error: the match is not exhaustive. You cannot forget to handle a variant.
In Go, you would use iota constants or separate types with a type switch, and nothing stops you from forgetting a case. Linters like exhaustive exist but they are optional, require configuration, and only work with iota-based enums. GALA's exhaustiveness checking is built into the language and works with full algebraic data types, not just integer constants.
If you only care about some variants, use a wildcard catch-all:
val msg = light match {
case Red() => "must stop"
case _ => "can proceed"
}
This is explicit -- you are telling the compiler you have intentionally handled the remaining cases together.
Real-World Example: Expression Evaluator
Let us build a simple arithmetic expression evaluator using sealed types and pattern matching. This is a classic use case for go algebraic data types.
package main
sealed type Expr {
case Num(Value float64)
case Add(Left Expr, Right Expr)
case Mul(Left Expr, Right Expr)
}
func eval(e Expr) float64 = e match {
case Num(v) => v
case Add(l, r) => eval(l) + eval(r)
case Mul(l, r) => eval(l) * eval(r)
}
func show(e Expr) string = e match {
case Num(v) => f"$v%.0f"
case Add(l, r) => s"(\({show(l)} + \){show(r)})"
case Mul(l, r) => s"(\({show(l)} * \){show(r)})"
}
func main() {
// (2 + 3) * 4
val expr = Mul(Add(Num(2), Num(3)), Num(4))
Println(s"\({show(expr)} = \){eval(expr)}")
}
Output: ((2 + 3) * 4) = 20
Now here is the equivalent Go code. It is what the transpiler generates (simplified for clarity):
package main
import "fmt"
const (
Expr_Num uint8 = iota
Expr_Add
Expr_Mul
)
type Expr struct {
_variant uint8
Value float64
Left Expr
Right Expr
}
// Companion objects + Apply methods omitted for brevity
func eval(e Expr) float64 {
switch e._variant {
case Expr_Num:
return e.Value
case Expr_Add:
return eval(e.Left) + eval(e.Right)
case Expr_Mul:
return eval(e.Left) * eval(e.Right)
default:
panic("non-exhaustive match")
}
}
func show(e Expr) string {
switch e._variant {
case Expr_Num:
return fmt.Sprintf("%.0f", e.Value)
case Expr_Add:
return fmt.Sprintf("(%s + %s)", show(e.Left), show(e.Right))
case Expr_Mul:
return fmt.Sprintf("(%s * %s)", show(e.Left), show(e.Right))
default:
panic("non-exhaustive match")
}
}
func main() {
expr := Expr{_variant: Expr_Mul,
Left: Expr{_variant: Expr_Add,
Left: Expr{_variant: Expr_Num, Value: 2},
Right: Expr{_variant: Expr_Num, Value: 3},
},
Right: Expr{_variant: Expr_Num, Value: 4},
}
fmt.Printf("%s = %.0f\n", show(expr), eval(expr))
}
The Go version is roughly three times longer. More importantly, the Go version has no compile-time guarantee that the switch is exhaustive. The default: panic is a runtime safety net, not a compile-time one. Add a Div variant to Expr and the Go compiler says nothing. The GALA compiler rejects the code until you handle Div in every match.
Option, Either, and Try: Standard Library Patterns
GALA's standard library uses sealed types throughout. These are not special compiler features -- they are regular sealed types that you could define yourself:
sealed type Option[T any] {
case Some(Value T)
case None()
}
sealed type Either[A any, B any] {
case Left(LeftValue A)
case Right(RightValue B)
}
sealed type Try[T any] {
case Success(Value T)
case Failure(Err error)
}
They all support pattern matching:
val result = fetchUser(id) match {
case Success(user) => s"Hello, ${user.Name}"
case Failure(err) => s"Error: $err"
}
val name = user.Email match {
case Some(email) => email
case None() => "no email on file"
}
And they support monadic chaining with Map, FlatMap, Filter, GetOrElse, and Recover:
val displayName = user.Name
.Map((n) => strings.ToUpper(n))
.GetOrElse("ANONYMOUS")
val result = divide(10, 2)
.Map((x) => x * 2)
.FlatMap((x) => divide(x, 3))
.Recover((e) => 0)
This eliminates entire categories of nil-pointer bugs and forgotten error checks. The types make missing cases visible, and pattern matching makes handling them concise.
Try It Yourself
In your browser: The GALA Playground lets you write and run GALA code with no installation.
On your machine: Download a binary from GitHub Releases for Linux, macOS, or Windows. Then:
# Create a file
cat > main.gala << 'EOF'
package main
sealed type Shape {
case Circle(Radius float64)
case Rectangle(Width float64, Height float64)
}
func describe(s Shape) string = s match {
case Circle(r) => f"circle with radius $r%.2f"
case Rectangle(w, h) => f"\({w}%.0f x \){h}%.0f rectangle"
}
func main() {
Println(describe(Circle(3.14)))
Println(describe(Rectangle(10, 5)))
}
EOF
# Run it
gala run main.gala
For projects, GALA integrates with Bazel:
load("//:gala.bzl", "gala_binary")
gala_binary(
name = "myapp",
src = "main.gala",
)
Conclusion
GALA does not replace Go. It transpiles to Go. The output is standard Go code that uses Go's type system, Go's runtime, and Go's toolchain. There is no runtime dependency, no reflection overhead, and no magic.
What GALA adds is a layer of compile-time safety for a specific class of problems: modeling closed variant types and ensuring every case is handled. If your codebase has state machines, AST nodes, protocol messages, API response types, or any domain with a fixed set of variants, sealed types and exhaustive pattern matching eliminate an entire category of bugs that Go's type system cannot catch.
The trade-off is a transpilation step and a different source language. For many projects, that trade-off is not worth it -- Go's simplicity is its strength. But for projects where type safety at the variant level matters more than syntactic familiarity, GALA gives you go pattern matching that the language itself has not yet provided.
Check out the GitHub repository for the full language specification, or try the playground to experiment with sealed types and pattern matching right now.

