For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

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.

python# 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)
typescript// 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); }
python# 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)
typescript// 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 match does not. A Python dict[Kind, Callable[...]] annotation does not by itself force an entry for every member of Kind, so pair it with a test or build it from a match (checked with pyright 1.1.414 in strict mode: a table typed dict[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. reportMatchNotExhaustive is off below strict, so a project in basic mode still needs the assert_never branch.
  • Literal keys come from somewhere. If the kind arrives from JSON or a request, validate it into the Literal at 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

ToolSettingWhat it catchesChecked
pyrightreportMatchNotExhaustive (none in basic and standard, error in strict)a match that does not cover every member of the subject's type2026-10-30
pyrightreportUnknownArgumentType, reportUnknownVariableType (error in strict)handlers whose types the checker cannot resolve2026-10-30
typingassert_never(unreachable) in the final casea missed member, reported by any compliant checker2026-10-30
typescript-eslintswitch-exhaustiveness-check (needs type information)a switch over a union or enum missing a case with no default2026-10-30
tscsatisfies Record<Kind, Handler>missing or extra keys in a handler table2026-10-30

Rule block for AGENTS.md / CLAUDE.md

markdown- 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 Enum or a TypeScript enum works as the key type; the switch-exhaustiveness-check page 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 a match, 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 with assert_never or 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 reports reportMatchNotExhaustive on the match line with "Unhandled type: Literal['a']". Keep the final assert_never case.

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 match statement: 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)
  • 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

For agentsthis page as Markdown/agent-readable/ar-08.mdllms.txt