Agent Rules / Dispatch and reflection
How do I write dispatch that type checkers can follow?
Rule AR-08
Dispatch over a closed set with a typed mapping keyed by a literal type, or an explicit match.
When the set of cases is known at write time, state it as a type. A Literal union (Python) or a string-literal union (TypeScript) names the cases, and either a mapping typed over that union or a match or switch that covers it ties each case to a function the checker can see. AR-07 says why to drop built-up names. This page shows what to write in their place.
Why it works
Python. typing.Literal tells a type checker that a value equals one of the listed literals. The docs example passes 'r' and gets a pass, and passes 'typo' and gets an error. A match statement tries each case in order; literal patterns compare with ==, and None, True and False compare with is. Pyright's reportMatchNotExhaustive flags a match whose cases do not cover the subject's type (off in basic and standard, error in strict). For the last case, typing.assert_never (added in Python 3.11) asks the checker to confirm the line is unreachable; if a new Literal member goes unhandled, the checker reports the argument's remaining type. At runtime assert_never raises, so a value that slips past the checker still fails loudly.
TypeScript. Record<Keys, Type> builds an object type with exactly those keys. With satisfies (TypeScript 4.9), the docs show that Record<Colors, unknown> rejects extra keys and keeps each property's own type, so handlers.refund stays the refund function rather than widening to a general handler type. typescript-eslint's switch-exhaustiveness-check requires a switch over a union or enum to have a case for every member or a default, and it needs type information.
Bad and good
A payment-event router in a billing service.
# bad: the case list lives in a string, and the dict hides arguments
HANDLERS = {"refund": handle_refund, "ship": handle_ship}
def route(kind: str, order):
return HANDLERS[kind](order)// bad: any string is a valid key
const handlers: { [k: string]: (o: Order) => void } = {
refund: handleRefund,
ship: handleShip,
};
function route(kind: string, order: Order) {
handlers[kind](order);
}# good: closed set as a type, exhaustive match
from typing import Literal, assert_never
Kind = Literal["refund", "ship", "cancel"]
def route(kind: Kind, order: Order) -> None:
match kind:
case "refund":
handle_refund(order)
case "ship":
handle_ship(order)
case "cancel":
handle_cancel(order)
case _ as unreachable:
assert_never(unreachable)// good: closed set as a type, table checked for exactly these keys
type Kind = "refund" | "ship" | "cancel";
const handlers = {
refund: handleRefund,
ship: handleShip,
cancel: handleCancel,
} satisfies Record<Kind, (o: Order) => void>;
function route(kind: Kind, order: Order): void {
handlers[kind](order);
}Add "chargeback" to Kind and both good versions point at the place that needs a new case: the assert_never line in Python, the satisfies clause in TypeScript. In the bad versions, the new kind reaches route and fails when it runs.
Trade-offs
- A match is the most direct form. Each call is a plain call and each argument list is visible. It costs a line or two per case and grows with the set. Past about a dozen cases, a table reads better.
- A table gives one place to look. It also gives a place to attach data per case (a label, a permission), which a
matchdoes not. A Pythondict[Kind, Callable[...]]annotation does not by itself force an entry for every member ofKind, so pair it with a test or build it from amatch(checked with pyright 1.1.414 in strict mode: a table typeddict[Literal["a", "b"], ...]holding only the"a"entry gives 0 errors, so the missing key is not reported). In TypeScript,Record<Kind, ...>does. - Strict mode is what turns the type into a check.
reportMatchNotExhaustiveis off below strict, so a project in basic mode still needs theassert_neverbranch. - Literal keys come from somewhere. If the kind arrives from JSON or a request, validate it into the
Literalat the boundary. After that every internal call is checked. The validation step is where an unknown value raises. - Open sets don't fit this rule. Plugins and third-party handlers are discovered at runtime. See AR-09.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| pyright | reportMatchNotExhaustive (none in basic and standard, error in strict) | a match that does not cover every member of the subject's type | 2026-10-30 |
| pyright | reportUnknownArgumentType, reportUnknownVariableType (error in strict) | handlers whose types the checker cannot resolve | 2026-10-30 |
| typing | assert_never(unreachable) in the final case | a missed member, reported by any compliant checker | 2026-10-30 |
| typescript-eslint | switch-exhaustiveness-check (needs type information) | a switch over a union or enum missing a case with no default | 2026-10-30 |
| tsc | satisfies Record<Kind, Handler> | missing or extra keys in a handler table | 2026-10-30 |
Rule block for AGENTS.md / CLAUDE.md
- Dispatch over a known set of cases uses a Literal/union type as the key:
an exhaustive match (end with assert_never), or `satisfies Record<Kind, Handler>` in TypeScript.
- Don't key a handler table by plain `str`.
- When you add a case to the type, fix every match and table the checker flags before moving on.Edge cases
- Enums. A Python
Enumor a TypeScriptenumworks as the key type; theswitch-exhaustiveness-checkpage names enums as well as unions. - Handlers with different signatures. A
Record<Kind, Handler>forces one shared type. If cases take different arguments, give each case its own call in amatch, or use a discriminated payload type. Callback typing is covered by AR-14. - Wildcard case. A
case _:that quietly returns hides the next missing member. End withassert_neveror a raise. - Framework routes built from a dict of names are the same problem at the router level. See CH-05.
- Guards. The Python docs say a guard runs only after its pattern matches, and a failed guard moves on to the next case. Pyright 1.1.414 in strict mode does not count a guarded case toward exhaustiveness: for
case "a" if n > 0:it reportsreportMatchNotExhaustiveon thematchline with "Unhandled type: Literal['a']". Keep the finalassert_nevercase.
What LLMGRAPH shows
Handlers called from an exhaustive match print certain; handlers reached through table[str_key]() print no calls to it found in this repo and plus 1 dynamic call nearby that could reach anything.
References
- Python typing:
Literal,assert_never,Never: https://docs.python.org/3/library/typing.html - Python compound statements, the
matchstatement: https://docs.python.org/3/reference/compound_stmts.html - Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
- TypeScript 4.9 release notes (
satisfies): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html - TypeScript utility types (
Record): https://www.typescriptlang.org/docs/handbook/utility-types.html - typescript-eslint
switch-exhaustiveness-check: https://typescript-eslint.io/rules/switch-exhaustiveness-check/
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2)
Related
- AR-07 Why does my coding agent miss calls made through getattr?
- AR-09 How should plugin registries be written for coding agents?
- AR-14 How should I type callbacks so agents can follow them?
Chimeras that use this rule
- CH-01 Why does my Django signal break after a refactor?
Last verified