Skip to content

match

match is Python’s pattern-matching statement, with one Typhon-specific rule: on a closed subject — a sealed union, an enum, Result[T, E], T?, bool or a string-literal union — every value must be covered (or case _: must be present).

Syntax

Same as Python 3.10+:

match SUBJECT:
case PATTERN [if GUARD]:
suite
case PATTERN [if GUARD]:
suite
...

Patterns

Literal pattern

match n:
case 0:
return "zero"
case 1 | 2 | 3:
return "small"

Capture pattern

match x:
case n: # captures any value as `n`
return n + 1

Class pattern (positional)

match s:
case Circle(radius):
return 3.14 * radius * radius
case Rectangle(width, height):
return width * height

Class pattern (keyword)

match u:
case User(id=0, name=n):
return f"admin {n}"
case User(name=n):
return n

Class patterns on built-in types (v0.9.0)

case TypeName() as binding: matches any value whose runtime type is TypeName and binds it under the narrowed shape. Useful as a typed fall-through over a wide-union subject:

def label(x: object) -> str:
match x:
case str() as s: return f"str:{s}"
case int() as n: return f"int:{n}"
case float() as f: return f"float:{f}"
case list() as xs: return f"list:{len(xs)}"
case dict() as d: return f"dict:{len(d)}"
case _: return "other"

The exhaustiveness pass also recognises case None: + a class pattern on the wrapped type as covering a T? subject:

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

case str() as s: works in both tyc check and tyc run since v0.9.0; before v0.9.0 the VM only matched class patterns on user-defined classes.

Since v0.10.0 the exhaustiveness pass also certifies three more shapes, so they no longer false-positive tyc::missing_return:

def flag(b: bool) -> str:
match b:
case True: return "on"
case False: return "off" # exhaustive — both bool inhabitants covered
type Dir = "n" | "s"
def step(d: Dir) -> int:
match d:
case "n": return 1
case "s": return -1 # exhaustive — every string-literal variant covered
def add(p: tuple[int, int]) -> int:
match p:
case (x, y): return x + y # exhaustive — fixed-arity tuple, length is static

Variadic tuple[int, ...] is intentionally excluded. A bool or string-literal match that misses a value reports tyc::non_exhaustive_match naming it (as well as tyc::missing_return when the function falls off the end); wrong-arity tuple patterns still fire.

Or pattern

match status:
case "active" | "pending":
return True
case _:
return False

Sequence pattern

match xs:
case []:
return "empty"
case [head, *rest]:
return f"head={head}, rest={rest}"
case [first, *middle, last]:
return f"{first}…{last}"

Sequence patterns with star-capture work in the VM since v0.8.0.

Mapping pattern

match cfg:
case {"host": h, "port": p}:
connect(h, p)
case {"host": h, **rest}:
connect(h, default_port=5432)
log_extras(rest)

Mapping patterns with rest-capture (**rest) work in the VM since v0.8.0.

Guard

match s:
case Circle(r) if r > 100:
return "huge circle"
case Circle(_):
return "circle"

A guarded case does not satisfy exhaustiveness for the matched variant — you still need an unguarded fallback.

Wildcard

case _:
return "anything"

Exhaustiveness on sealed unions

type Shape = Circle | Rectangle | Triangle
def area(s: Shape) -> float:
match s:
case Circle(r): return 3.14 * r * r
case Rectangle(w, h): return w * h
case Triangle(b, h): return 0.5 * b * h

No wildcard needed — the three cases cover the whole sealed union. Add a fourth variant to Shape and this match becomes tyc::non_exhaustive_match.

type Shape = Circle | Rectangle | Triangle | Square # added Square
# every match s: ... in the codebase now fails:
# error[tyc::non_exhaustive_match]: non-exhaustive `match` on sealed union `Shape`: missing variant(s) Square

Use case _: for a deliberate catch-all (opts out of exhaustiveness):

match s:
case Circle(_):
return "circle"
case _:
return "polygon" # catches every other variant — no exhaustiveness checking

Exhaustiveness on enums (v0.13.0)

An enum’s members form a closed set, so a match covering every member is exhaustive with no wildcard:

enum Dir: NORTH / SOUTH / EAST / WEST
def delta(d: Dir) -> tuple[int, int]:
match d:
case Dir.NORTH: return (0, 1)
case Dir.SOUTH: return (0, -1)
case Dir.EAST: return (1, 0)
case Dir.WEST: return (-1, 0) # exhaustive — all four members covered

Drop a member and the match fails, naming the one you missed:

# error[tyc::non_exhaustive_match]: non-exhaustive `match` on sealed union `Dir`: missing variant(s) WEST

Since v0.14.1 enum exhaustiveness also carries across module boundaries — an enum imported from another module is checked the same way.

Nested sealed unions

A sealed union whose variants include another sealed union flattens to its leaf classes. Covering every leaf is exhaustive:

class Circle frozen:
r: float
class Rect frozen:
w: float
h: float
class Tri frozen:
b: float
h: float
type Poly = Rect | Tri
type Shape = Circle | Poly
def area(s: Shape) -> float:
match s:
case Circle(r):
return 3.14 * r * r
case Rect(w, h):
return w * h
case Tri(b, h):
return 0.5 * b * h # exhaustive — every leaf covered

Drop the Tri arm and the match reports missing variant(s) Tri. The inner alias is not a class, so case Poly(): cannot stand in for its variants: it is rejected with tyc::alias_not_a_class (CPython raises TypeError on it). Write case Rect() | Tri(): instead.

Exhaustiveness on Result, T?, bool and literal unions

The same check covers every other closed subject, and reports the same tyc::non_exhaustive_match:

  • Result[T, E] must handle Ok and Err. When E (or T) is itself closed — a sealed union, an enum, bool or a literal union — the payload patterns must cover it too.
  • T? must handle None once the other arms cover T.
  • bool must handle True and False.
  • A string-literal union must handle every literal.
class NotFound frozen:
path: str
class Timeout frozen:
after_ms: int
type LoadError = NotFound | Timeout
def describe(r: Result[str, LoadError]) -> str:
match r:
case Ok(body):
return body
case Err(NotFound(path)):
return f"missing: {path}"
case Err(Timeout(after_ms)):
return f"timed out after {after_ms}ms"
def label(x: int?) -> str:
match x:
case None:
return "none"
case int() as n:
return str(n)

Add Denied to LoadError and describe reports missing variant(s) Err(Denied); drop case None: from label and it reports missing variant(s) None. The [strictness] exhaustive-match setting applies to all of these forms.

Matching over an expression scrutinee (v0.13.0)

The subject of a match doesn’t have to be a plain name — exhaustiveness runs the same analysis over any expression scrutinee:

match items[-1]: # last element of a sealed-union list
case Circle(r): return 3.14 * r * r
case Rectangle(w, h): return w * h
case Triangle(b, h): return 0.5 * b * h

Match-arm scrutinee narrowing (v0.13.0)

A class pattern narrows the subject variable to the matched variant inside that arm, so attribute and method access on it type-check against the narrowed shape:

match action:
case Action(_, _):
# `action` is narrowed to `Action` here
log(action.target)

Severity

[strictness] exhaustive-match in typhon.toml:

  • "error" — default. Missing variants fail the build.
  • "warn" — flagged as a warning.
  • "off" — no exhaustiveness checks.

Keep it at "error" outside of refactor windows.

Pattern matching on Result

match parse("42"):
case Ok(n):
print(f"got {n}")
case Err(msg):
print(f"error: {msg}")

Result[T, E] is a closed type, so the checker enforces both arms — and, when the error type is itself a sealed union, an Err(...) arm for each of its variants (see above).

How it desugars

Vanilla Python match:

match s:
case Circle(r):
return r * r

Typhon’s check is at compile time; runtime semantics are unchanged.

Where next