Skip to content

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 Triangle
tyc::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 arm

The 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 * h

Severity

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:

SubjectMust cover
Sealed union type X = A | Bevery variant class
Nested sealed union (type Shape = Circle | Poly, type Poly = Rect | Tri)every leaf class: Circle, Rect, Tri
enumevery 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
boolTrue and False
String-literal union type Dir = "n" | "s"every literal
class NotFound frozen:
path: str
class Timeout frozen:
after_ms: int
class 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.0

Before 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 * h

Match-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: float

Every 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: float
class Rect frozen:
w: float
class Tri frozen:
b: float
type Poly = Rect | Tri
type 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