Sealed Unions
A sealed union, declared with the type keyword, is a closed sum: every inhabitant is one of the variants listed in the alias. The checker uses this to enforce exhaustive match over the union — directly, inside the Ok / Err arms of a Result[T, E] whose error (or value) type is the union, and through unions nested inside another union, which flatten to their leaf classes. Adding a new variant fails every such match site that doesn’t cover it.
See Sealed Unions and Match (tour) for the full walk-through; this page is the reference.
Declaration
type Shape = Circle | Rectangle | Triangle
class Circle: radius: float
class Rectangle: width: float height: float
class Triangle: base: float height: floatThe type keyword declares the alias. The set on the right side is the complete list of inhabitants. Nothing outside this file can extend Shape.
What “sealed” means
The seal is enforced at the type level, not at runtime — Python has no concept of sealed classes. The checker:
- Treats the union as exactly the listed variants for purposes of exhaustiveness.
- Refuses to compile a
matchover a sealed union that misses a variant. - Does not prevent a Python subclass at runtime from being passed in; if such an instance reaches a
match, it’ll fall through unmatched (just like Python).
For closed sets known at design time, that’s fine. For genuinely open sums (third-party extensions), use a base class or an interface instead.
class variants
Variants are normal classes. They can have:
- Fields (with or without defaults).
implblocks (per-variant methods).frozenmodifiers.- Generic parameters.
type Tree[T] = Leaf[T] | Node[T]
class Leaf[T]: value: T
class Node[T]: left: Tree[T] right: Tree[T]pass-bodied variants
For variants that carry no data:
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: passpass-bodied classes still need parentheses on construction: Plus(), EOF(). The constructor is generated.
Pattern matching
Each variant has a positional structural pattern: case Variant(field1, field2, ...).
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 * heightDestructured names are new bindings, narrowed to their field types.
Wildcards
case _: is a deliberate catch-all. Using it opts out of exhaustiveness:
def describe(s: Shape) -> str: match s: case Circle(_): return "circle" case _: return "polygon" # catches Rectangle and TriangleThe compiler won’t warn when new variants are added. Use sparingly.
Guards
case X if cond: adds an additional predicate:
match s: case Circle(r) if r > 100: return "huge circle" case Circle(_): return "circle" case _: return "polygon"A guarded case does not satisfy exhaustiveness for that variant — you still need an unguarded case Circle(_) (or similar) for cases where the guard is false.
Generic sealed unions
type Result[T, E] = Ok[T] | Err[E]
class Ok[T]: value: T
class Err[E]: error: EThis is in fact how Result[T, E] itself is defined in typhon_runtime. The checker instantiates T and E at the use site.
Variant → parametric union assignability (v0.9.0)
A bare variant flows into the parametric union alias even when the variant itself is 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 # ✅ since v0.9.0 — Cons[T] → LL[T] count = count + 1Before v0.9.0 the variant Cons[T] was not recognised as assignable to LL[T], so cur = tail raised tyc::type_mismatch. The fix is purely additive — every existing program still type-checks.
The rule also applies for the non-generic-variant case (Nil declared as a plain class, no [T]): a Nil value flows into LL[T] for any T, because Nil has no T-dependent shape.
Distributing impl over a sealed alias
impl[T] LL[T]: distributes every method body across every variant — useful when every variant should expose the same operation:
impl[T] LL[T]: def is_empty(self) -> bool: match self: case Nil(): return True case Cons(_, _): return FalseLowering emits Cons.is_empty(self) and Nil.is_empty(self). Any diagnostic raised by the same line across multiple variants is deduplicated (v0.9.0) — a 10-variant union no longer reports 10 identical errors.
Cross-file variants
Variants must all be visible in the same scope as the type alias. A common layout:
class UserCreated: user_id: int
class UserDeleted: user_id: int
class UserRenamed: user_id: int new_name: str
type UserEvent = UserCreated | UserDeleted | UserRenamedImporting UserEvent from another file gives you the full sealed type. Adding a fourth variant requires editing this file — and the addition lights up every match in the codebase.
Adding a variant
The whole point of sealing. When you add Square to Shape:
type Shape = Circle | Rectangle | Triangle | Square
class Square: side: floatEvery match s: in the codebase that doesn’t have a case Square(...): becomes tyc::non_exhaustive_match. The diagnostic tells you exactly which variant is missing and where each match site is.
This is the single biggest static-safety win the language offers — refactors that would have been multi-week archaeology in plain Python become checklist work.
Common mistakes
Forgetting type
class Circle: ...class Rectangle: ...
def area(s: Circle | Rectangle) -> float: # ⚠️ ad-hoc union, not sealed ...Without type X = ..., the union is anonymous. Exhaustiveness still works for this call site, but there’s no named type that catches drift across files. Always declare a type alias for sealed unions.
Treating case _: as a default
case _: opts out of exhaustiveness. Use it when you genuinely want a catch-all; avoid it when you want the compiler to remind you about new variants.
Where next
- Sealed Unions and Match (tour) — worked examples.
match— the reference page.- State Machines with Sealed Unions — a worked pattern.