Match Errors
tyc::non_exhaustive_match
A match on a closed type doesn’t cover every value. Closed types are sealed
unions (including unions nested inside one), enums, Result[T, E], nullable
T?, bool and string-literal unions — see
What “exhaustive” means. The commonest case is a
sealed union missing a variant:
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 # ❌ missing Triangletyc::non_exhaustive_match
× non-exhaustive `match` on sealed union `Shape`: missing variant(s) │ Triangle ╭─[src/shapes.ty:4:11] 4 │ match s: · ┬ · ╰── match is not exhaustive ╰──── help: add a `case <Variant>():` arm for each missing variant, or add a `case _:` wildcard armThe same wording is used for every closed subject, including ones that are
not sealed unions (on sealed union `bool` , on sealed union `int | None` ).
Fix: add the missing variant, or use case _: for a deliberate catch-all (opts out of exhaustiveness for the rest of the union).
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 * hSeverity
Controlled by [strictness] exhaustive-match:
"error"— default. Fails CI."warn"— flagged as a warning."off"— no exhaustiveness check.
Keep it at "error" outside of refactor windows.
What “exhaustive” means
The checker counts variants listed on the type X = A | B | C alias. If the match covers every variant exactly once (either explicitly or via _), it’s exhaustive. The same rule applies to every closed subject:
| Subject | Must cover |
|---|---|
Sealed union type X = A | B | every variant class |
Nested sealed union (type Shape = Circle | Poly, type Poly = Rect | Tri) | every leaf class: Circle, Rect, Tri |
enum | every member |
Result[T, E] | Ok and Err; when T or E is itself closed, the payload patterns must cover it (Err(NotFound(..)) and Err(Timeout(..)) for E = NotFound | Timeout) |
T? | None as well as T |
bool | True and False |
String-literal union type Dir = "n" | "s" | every literal |
class NotFound frozen: path: strclass Timeout frozen: after_ms: intclass Denied frozen: user: str
type LoadError = NotFound | Timeout | Denied
def describe(r: Result[str, LoadError]) -> str: match r: # ❌ missing Err(Denied) 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" × non-exhaustive `match` on sealed union `Result[str, LoadError]`: missing │ variant(s) Err(Denied)A match over int? with only case int() as n: reports None missing; a
match over bool with only case True: reports False. A function whose
match is the last statement also reports tyc::missing_return for the
same gap, because the uncovered value falls through to an implicit None.
A case naming a nested alias (case Poly():) does not cover its variants;
it is rejected with tyc::alias_not_a_class.
Guards (case X(...) if cond:) don’t satisfy exhaustiveness for the variant — you still need an unguarded fallback. Since v0.8.0 a fully-guarded variant where every arm carries a guard is treated as exhaustive (pragmatic heuristic — pathologically-incomplete guard cascades will no longer be flagged).
Class patterns on built-in types cover T? (v0.9.0)
Exhaustiveness recognises case None: plus a class pattern on the inner type as covering a nullable subject:
def show(s: str?) -> str: match s: case None: return "<missing>" case str() as x: return x # no missing_return — these two arms cover str? since v0.9.0Before v0.9.0 this match required either a case _: fallback or a post-match return.
Enum exhaustiveness (v0.13.0)
An enum’s member set is a closed set, so it participates in exhaustiveness just like a sealed union. Covering every member with case Enum.MEMBER: arms (or | or-patterns) satisfies the return-path / exhaustiveness analysis:
from enum import Enum
class Direction(Enum): NORTH = 1 SOUTH = 2 EAST = 3 WEST = 4
def delta(d: Direction) -> tuple[int, int]: match d: case Direction.NORTH: return (0, 1) case Direction.SOUTH: return (0, -1) case Direction.EAST | Direction.WEST: return (0, 0) # ❌ tyc::non_exhaustive_match — but here all members are covered ✅A missing member now fires tyc::non_exhaustive_match naming the member that isn’t covered. Before v0.13.0 both the sealed-union and enum shapes collapsed into a generic tyc::missing_return instead of pointing at the specific uncovered member.
Fix: add the missing case Enum.MEMBER: arm (or fold it into a | or-pattern), or use case _: for a deliberate catch-all.
Expression scrutinees and match-arm narrowing (v0.13.0)
Exhaustiveness now works for expression scrutinees, not just plain-name subjects — match items[-1]: is analysed the same way as match x::
def last_area(shapes: list[Shape]) -> float: match shapes[-1]: # ✅ expression scrutinee, exhaustiveness checked case Circle(r): return 3.14 * r * r case Rectangle(w, h): return w * h case Triangle(b, h): return 0.5 * b * hMatch-arm narrowing also applies: case Action(_, _): narrows the subject to Action inside that arm, so member access on the narrowed type type-checks without a separate isinstance guard.
Adding a variant
The whole point of sealing. Adding Square to Shape:
type Shape = Circle | Rectangle | Triangle | Square # added Square
class Square: side: floatEvery match s: ... in the project now fails with tyc::non_exhaustive_match until updated. That’s the feature.
tyc::invalid_pattern
A pattern the Python grammar accepts but the CPython compiler
rejects. Without this check tyc build reported success and wrote a .py
that raised SyntaxError the moment anything imported it.
match xs: case [*a, *b]: # tyc::invalid_pattern — at most one `*rest` ... case [a, a]: # tyc::invalid_pattern — `a` captured twice ...A sequence pattern binds at most one *rest (two would have no unique
split point), and a pattern binds each name once — [a, a] does not mean
“these two are equal”. Rename the capture and compare in a guard:
match xs: case [first, *rest]: ... case [a, b] if a == b: ...Alternatives of a | pattern are checked independently, since each binds
on its own path: case [a] | (a,): is legal, and CPython requires both
sides to bind the same set of names.
tyc::alias_not_a_class
A type alias used where Python needs a class: as a match class pattern
(case Poly():) or as the second argument of isinstance.
class Circle frozen: r: floatclass Rect frozen: w: floatclass Tri frozen: b: float
type Poly = Rect | Tritype Shape = Circle | Poly
def describe(s: Shape) -> str: match s: case Circle(): return "round" case Poly(): # ❌ tyc::alias_not_a_class return "polygon"
def is_poly(s: Shape) -> bool: return isinstance(s, Poly) # ❌ tyc::alias_not_a_class × `Poly` is a type alias, not a class: `case Poly():` raises `TypeError` at │ runtime help: match its variants instead: `case Rect() | Tri():`type Poly = Rect | Tri emits a PEP 695 type statement, which creates a
typing.TypeAliasType object, not a class. CPython raises TypeError the
first time such a pattern or isinstance call runs, so tyc check rejects
it. The help text lists the alias’s variants.
Fix: match or test the variants.
def describe(s: Shape) -> str: match s: case Circle(): return "round" case Rect() | Tri(): return "polygon"
def is_poly(s: Shape) -> bool: return isinstance(s, (Rect, Tri))Full page: docs/diagnostics/alias_not_a_class.md (also tyc explain alias_not_a_class).
Where next
- Sealed Unions — the type-system reference.
matchreference — pattern shapes.- Sealed Unions and Match (tour) — teaching page.