Skip to content

Sealed Unions and Match

A sealed union is a closed set of variants — exactly the cases listed, no more. When you match on one, the checker forces you to handle every case. Adding a new variant lights up every match site that doesn’t cover it. This is, in plain code-correctness terms, the single biggest static-safety win Typhon offers over typed Python.

Declaring a sealed union

Use type to alias a union of classes:

type Shape = Circle | Rectangle | Triangle
class Circle:
radius: float
class Rectangle:
width: float
height: float
class Triangle:
base: float
height: float

Shape is now a closed type with exactly three inhabitants. Nothing outside this file can extend the union — that’s what “sealed” means.

Variants are normal classes. They can have fields, defaults, and impl blocks, like anything else.

Pattern-matching with match

def area(s: Shape) -> float:
match s:
case Circle(radius):
return 3.14159 * radius * radius
case Rectangle(width, height):
return width * height
case Triangle(base, height):
return 0.5 * base * height

Three things to notice:

  • Each case names one variant and destructures its fields. The names (radius, width, etc.) are new bindings, narrowed to the variant’s field types.
  • There’s no wildcard — and we didn’t need one. The checker verified that the three cases cover the whole union.
  • Add a fourth variant (Square?) to the type Shape = ... line and this match goes red. The diagnostic tells you exactly which variant is missing.

What missing cases look like

def area(s: Shape) -> float:
match s:
case Circle(radius):
return 3.14159 * radius * radius
case Rectangle(width, height):
return width * height
# forgot Triangle
tyc::non_exhaustive_match
× non-exhaustive `match` on sealed union `Shape`: missing variant(s)
│ Triangle
╭─[src/shapes.ty:2:11]
2 │ match s:
· ┬
· ╰── match is not exhaustive
╰────
help: add a `case <Variant>():` arm for each missing variant, or add a `case
_:` wildcard arm

This is what the design doc calls “the single biggest static-safety win over current Python” — and it pays off every time the union grows.

Exhaustiveness is configurable

By default, [strictness] exhaustive-match = "error" in typhon.toml. You can lower it to "warn" or "off" during refactors, but the default — and the recommendation — is to keep it as an error.

Wildcards and guards

When a wildcard is the intent (you genuinely want a catch-all), use case _::

def describe(s: Shape) -> str:
match s:
case Circle(_):
return "circle"
case _:
return "polygon"

case _: opts out of exhaustiveness for the rest of the union. The checker won’t complain when a new variant lands, so use it sparingly — the safety story relies on most matches being exhaustive.

Patterns can include guards (if clauses):

def classify(s: Shape) -> str:
match s:
case Circle(radius) if radius > 100:
return "huge circle"
case Circle(_):
return "circle"
case Rectangle(w, h) if w == h:
return "square"
case Rectangle(_, _):
return "rectangle"
case Triangle(_, _):
return "triangle"

Guards do not relax exhaustiveness — you still need a case for every variant that isn’t covered by a guard.

Variants with shared methods

impl blocks attach to individual variants:

type Shape = Circle | Rectangle | Triangle
class Circle:
radius: float
impl Circle:
def area(self) -> float:
return 3.14159 * self.radius * self.radius
class Rectangle:
width: float
height: float
impl Rectangle:
def area(self) -> float:
return self.width * self.height
class Triangle:
base: float
height: float
impl Triangle:
def area(self) -> float:
return 0.5 * self.base * self.height

…or you can keep behaviour on the union by writing a free function that pattern-matches (often clearer for behaviour that depends on multiple variants at once).

Distributing impl over the union

For methods that have the same body shape on every variant, write the impl once on the union alias and let the compiler distribute it:

impl Shape:
def name(self) -> str:
match self:
case Circle(_): return "circle"
case Rectangle(_, _): return "rectangle"
case Triangle(_, _): return "triangle"

The lowering emits the body once per variant — Circle.name(self), Rectangle.name(self), Triangle.name(self). Since v0.9.0 any diagnostic raised by the same line across multiple variants is deduplicated by (code, rendered message), so a 10-variant union no longer reports 10 identical errors when something goes wrong inside the shared body.

Variant → parametric union flow (v0.9.0)

A bare variant flows into the parametric union alias even when the variant is itself generic over the same type parameter. This is the assignability rule that makes recursive ADT walks compile:

class Cons[T] frozen:
head: T
tail: LL[T]
class Nil[T] frozen:
pass
type LL[T] = Cons[T] | Nil[T]
def length[T](xs: LL[T]) -> int:
mut cur: LL[T] = xs
mut count: int = 0
while True:
match cur:
case Nil():
return count
case Cons(_, tail):
cur = tail # ✅ Cons[T] flows into LL[T]
count = count + 1

Before v0.9.0 the cur = tail assignment fired tyc::type_mismatch because Cons[T] wasn’t recognised as assignable to LL[T]. Recursive ADT walks (linked lists, ASTs, sealed-union streams) now compile without per-variant casts.

Real-world example: a parser

Sealed unions shine when you have a small fixed alphabet of inputs or outputs:

type Token = Number | Ident | Plus | Minus | LParen | RParen | EOF
class Number:
value: float
class Ident:
name: str
class Plus: pass
class Minus: pass
class LParen: pass
class RParen: pass
class EOF: pass
def display(t: Token) -> str:
match t:
case Number(v):
return f"<num {v}>"
case Ident(n):
return f"<id {n}>"
case Plus():
return "+"
case Minus():
return "-"
case LParen():
return "("
case RParen():
return ")"
case EOF():
return "<eof>"

Add a Star variant for multiplication and every match on Token fails to compile until you handle it. The compiler becomes your TODO list.

match on T? covers None + a class pattern (v0.9.0)

For nullable subjects, the exhaustiveness pass recognises case None: plus a class pattern on the inner type as covering the full T?:

def show(name: str?) -> str:
match name:
case None: return "<anonymous>"
case str() as s: return s
# no missing_return — these two arms cover str? since v0.9.0

case TypeName() as binding: is the class-pattern shorthand for any built-in type (str, int, float, list, dict, bool, bytes). The capture binds the subject under the narrowed type.

Sealed unions vs inheritance

You can model “shape” with inheritance — class Shape: plus class Circle(Shape): .... Two reasons to prefer a sealed union:

  • Exhaustive matching. Subclassing has no mechanism for “every subtype”. A sealed union does.
  • No hidden behaviour. A subclass can override a method silently. A sealed union forces every behaviour into the match, where you can see what each variant does in one place.

When in doubt: if the set of variants is fixed and known at design time, reach for a sealed union; if you genuinely expect external extension, use a base class or an interface (see Generics and Interfaces).

How it desugars

Each variant is emitted as a dataclass; the union itself emits as a Python | alias:

type Shape = Circle | Rectangle | Triangle

match lowers to a Python 3.10+ match statement essentially unchanged — Typhon’s check is at compile time; runtime semantics are vanilla Python match.

Putting it together

A small expression evaluator using sealed unions throughout — both for the AST and for evaluation errors:

type Expr = Lit | Add | Sub | Mul | Div
class Lit:
value: float
class Add:
lhs: Expr
rhs: Expr
class Sub:
lhs: Expr
rhs: Expr
class Mul:
lhs: Expr
rhs: Expr
class Div:
lhs: Expr
rhs: Expr
type EvalError = DivByZero | Overflow
class DivByZero: pass
class Overflow:
value: float
def eval(e: Expr) -> Result[float, EvalError]:
match e:
case Lit(v):
return Ok(v)
case Add(l, r):
let a1: float = eval(l)?
let b1: float = eval(r)?
return Ok(a1 + b1)
case Sub(l, r):
let a2: float = eval(l)?
let b2: float = eval(r)?
return Ok(a2 - b2)
case Mul(l, r):
let a3: float = eval(l)?
let b3: float = eval(r)?
return Ok(a3 * b3)
case Div(l, r):
let a4: float = eval(l)?
let b4: float = eval(r)?
if b4 == 0.0:
return Err(DivByZero())
return Ok(a4 / b4)

(The Sub and Mul arms are elided in the emitted view — each expands to the same ? ladder shown for Add and Div. The bindings are renamed per arm (a1/b1 … a4/b4) because Typhon is function-scoped: a second let a inside another case would shadow the outer a, which tyc::no_block_shadow rejects.)

What this shows:

  • Expr is sealed; every operator variant is listed. Adding Mod to the union breaks the match until handled.
  • EvalError is also sealed; the match in a caller forces you to think about DivByZero vs Overflow distinctly.
  • ? composes happily with match — both work because the function returns Result[float, EvalError].

Common mistakes

Forgetting type and just listing classes

class Circle: ...
class Rectangle: ...
def area(s: Circle | Rectangle) -> float: # ⚠️ works but doesn't seal
...

This makes Circle | Rectangle an ad-hoc union, not a sealed one. Exhaustiveness on match will still work for this union, but there’s no named type that catches drift in other files.

Fix: type Shape = Circle | Rectangle, then use Shape everywhere.

Adding a variant and not updating matches

type Shape = Circle | Rectangle | Triangle | Square # added Square

The checker now flags every match s: that didn’t have case Square(...):. That’s the feature — fix each one and move on.

Mixing case _: and exhaustiveness

def area(s: Shape) -> float:
match s:
case Circle(r):
return 3.14159 * r * r
case _: # catches every other variant
return 0.0

This compiles, but you’ve opted out of exhaustiveness. New variants will silently fall into the wildcard. Only use _: when that’s genuinely what you want.

What you’ve learned

  • type X = A | B | C declares a sealed union.
  • match on a sealed union must cover every variant — exhaustiveness is checked at compile time.
  • Variants are normal classes; methods live in impl blocks or free functions.
  • Use sealed unions over inheritance whenever the variant set is fixed at design time.

Where next