# 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 tab
# 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)
```

```ts tab
// 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 tab
# 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)
```

```ts tab
// 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
| 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
```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)

## Related

- [AR-07: Why does my coding agent miss calls made through getattr?](https://beta.llmgraph.in/agent-readable/ar-07.md)
- [AR-09: How should plugin registries be written for coding agents?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-14: How should I type callbacks so agents can follow them?](https://beta.llmgraph.in/agent-readable/ar-14.md)

## Chimeras that use this rule

- [CH-01: Why does my Django signal break after a refactor?](https://beta.llmgraph.in/agent-readable/ch-01.md)

Last verified 2026-10-30
