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: floatShape 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 * heightThree things to notice:
- Each
casenames 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 thetype Shape = ...line and thismatchgoes 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 Triangletyc::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 armThis 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 + 1Before 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: floatclass Ident: name: strclass Plus: passclass Minus: passclass LParen: passclass RParen: passclass 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.0case 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 | Trianglefrom dataclasses import dataclass
@dataclass(slots=True)class Circle: radius: float
@dataclass(slots=True)class Rectangle: width: float height: float
@dataclass(slots=True)class Triangle: base: float height: float
Shape = Circle | Rectangle | Trianglematch 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: floatclass Add: lhs: Expr rhs: Exprclass Sub: lhs: Expr rhs: Exprclass Mul: lhs: Expr rhs: Exprclass Div: lhs: Expr rhs: Expr
type EvalError = DivByZero | Overflow
class DivByZero: passclass 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)from __future__ import annotationsfrom typhon_runtime import Ok, Err, Resultimport dataclassesfrom typhon_runtime import Err as __typhon_Err__
type Expr = Lit | Add | Sub | Mul | Div
@dataclasses.dataclass(slots=True)class Lit: value: float
@dataclasses.dataclass(slots=True)class Add: lhs: Expr rhs: Expr
@dataclasses.dataclass(slots=True)class Sub: lhs: Expr rhs: Expr
@dataclasses.dataclass(slots=True)class Mul: lhs: Expr rhs: Expr
@dataclasses.dataclass(slots=True)class Div: lhs: Expr rhs: Expr
type EvalError = DivByZero | Overflow
@dataclasses.dataclass(slots=True)class DivByZero: pass
@dataclasses.dataclass(slots=True)class Overflow: value: float
def eval(e: Expr) -> Result[float, EvalError]: match e: case Lit(v): return Ok(v) case Add(l, r): __typhon_q_0__ = eval(l) if isinstance(__typhon_q_0__, __typhon_Err__): return __typhon_q_0__ a1: float = __typhon_q_0__.value __typhon_q_1__ = eval(r) if isinstance(__typhon_q_1__, __typhon_Err__): return __typhon_q_1__ b1: float = __typhon_q_1__.value return Ok(a1 + b1) case Sub(l, r): # ...mirror of Add: two `?` expansions, then `Ok(a2 - b2)`... ... case Mul(l, r): # ...mirror of Add: two `?` expansions, then `Ok(a3 * b3)`... ... case Div(l, r): __typhon_q_6__ = eval(l) if isinstance(__typhon_q_6__, __typhon_Err__): return __typhon_q_6__ a4: float = __typhon_q_6__.value __typhon_q_7__ = eval(r) if isinstance(__typhon_q_7__, __typhon_Err__): return __typhon_q_7__ b4: float = __typhon_q_7__.value 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:
Expris sealed; every operator variant is listed. AddingModto the union breaks the match until handled.EvalErroris also sealed; thematchin a caller forces you to think aboutDivByZerovsOverflowdistinctly.?composes happily withmatch— both work because the function returnsResult[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 SquareThe 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.0This 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 | Cdeclares a sealed union.matchon a sealed union must cover every variant — exhaustiveness is checked at compile time.- Variants are normal classes; methods live in
implblocks or free functions. - Use sealed unions over inheritance whenever the variant set is fixed at design time.
Where next
- Generics and Interfaces — type parameters and structural typing.
match— the reference page.- State Machines with Sealed Unions — a worked design pattern.