# How to dispatch messages to handlers with a Map keyed by type

> Replace a switch on message.type with a Map of handlers, so that adding one is a registration call, unknown types reach a fallback, and prototype keys stay out.

Source: https://voxgig.com/howto/dispatch-messages-with-a-handler-map

- Audience: api-producer
- Level: beginner
- Languages: typescript
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

Keep a `Map<string, Handler>`, register each handler by type, and dispatch through an explicit `has` check that sends anything unregistered to one fallback. A switch gives you compile-time exhaustiveness and a file that grows forever. An object literal is shorter, but `constructor`, `__proto__`, and `toString` resolve through the prototype chain and reach code you never wrote. Test that every declared type has a handler.

---
## You will need

Basic TypeScript, and Node 22.18 or later, which
[runs a `.ts` file directly](https://nodejs.org/api/typescript.html) by stripping the types.
Verified 2026-09-24 against Node 22.22.2. The sample has no dependencies and no build step, and
the [`satisfies` operator](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html)
it relies on needs TypeScript 4.9 or later in your editor.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Map registry](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) | Handlers come from several modules and register themselves at startup | No compile-time exhaustiveness, so a declared type with no handler is a test failure rather than a build failure | Every handler lives in one file you own, and the compiler's check matters more than open registration |
| [object literal lookup](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer) | A fixed set of types in one module, with `satisfies` checking the keys | Keys such as `constructor` and `__proto__` resolve through the prototype chain to code you never wrote | The type comes off the wire, where any string can arrive |
| [switch statement](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/switch) | A closed set of types, and you want the compiler to refuse a missing case | One function that every added type edits, and a `never` default that throws at runtime on a wire type it never declared | Handlers come from more than one module |

The split is between what the compiler can check and what the code can be extended with. A
switch with a `never` default is checked at build time and closed at runtime: every type is a
case in one file. A Map is open at runtime and unchecked at build time, so the test below stands
in for the compiler. The object literal sits between them and inherits `Object.prototype`, which
is the reason it is not the answer.

## Register handlers in a Map and check before you call

The registry is fifteen lines, and two of them carry the design.

```ts title="map-dispatch.ts"
export function createDispatcher(fallback: Handler = (m) => `no handler for ${m.type}`) {
  const registry = new Map<string, Handler>()

  return {
    register(type: string, handler: Handler) {
      if (registry.has(type)) throw new Error(`a handler for ${type} is already registered`)
      registry.set(type, handler)
      return this
    },

    dispatch(message: Message): string {
      if (!registry.has(message.type)) return fallback(message)
      return registry.get(message.type)!(message)
    },

    types: () => [...registry.keys()],
  }
}
```

[`has`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/has)
answers for the keys that were set and for nothing else. There is no chain behind a Map, so a
type named `constructor` is unknown until somebody registers it. The fallback is the one place an
unknown type can go, and it is a parameter so the worker can count and log there rather than
throw.

`register` refuses a second handler for the same type. Without that check, two modules that
claim one type resolve by load order, and the one that loses finds out in production.

## Keep the switch's exhaustiveness in a test

A Map cannot tell the compiler which keys it will hold. The declared types can, and the object
the handlers are written in can be held to them.

```ts title="messages.ts"
export const handlers = {
  'order:create': (m) => `created order ${m.id}`,
  'order:cancel': (m) => `cancelled order ${m.id}`,
  'payment:capture': (m) => `captured ${m.amount} for order ${m.id}`,
} satisfies Record<MessageType, Handler>
```

`satisfies` fails the build on a missing key or a misspelled one, without widening the object to
`Record<string, Handler>`. That is the same guarantee a switch gets from a `never` default,
which the [TypeScript handbook](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
describes under exhaustiveness checking. The Map is then built from that object at startup, and
the first test asserts that nothing was lost on the way.

```ts title="dispatch.test.ts"
test('every declared message type has a registered handler', () => {
  const registered = dispatcher().types()
  for (const type of MESSAGE_TYPES) assert.ok(registered.includes(type), `no handler for ${type}`)
  assert.equal(registered.length, MESSAGE_TYPES.length, 'and nothing undeclared is registered')
})
```

A handler registered from another module is checked by the same test, because it registers into
the same Map. The compiler never sees it, and the test does.

## Watch three dispatchers meet the prototype chain

The demo parses six messages from JSON and sends each through all three dispatchers. One type is
declared, one is unknown, and four are property names every object already has.

```bash
node demo.ts
```

```text output
order:refund
  switch          Error: unhandled message type "order:refund"
  object literal  no handler for order:refund
  Map             no handler for order:refund
constructor
  switch          Error: unhandled message type "constructor"
  object literal  [object Object]
  Map             no handler for constructor
__proto__
  switch          Error: unhandled message type "__proto__"
  object literal  TypeError: handler is not a function
  Map             no handler for __proto__
toString
  switch          Error: unhandled message type "toString"
  object literal  [object Undefined]
  Map             no handler for toString
hasOwnProperty
  switch          Error: unhandled message type "hasOwnProperty"
  object literal  TypeError: Cannot convert undefined or null to object
  Map             no handler for hasOwnProperty
```

The switch throws on everything it did not declare, with the type in the message, which is the
correct runtime behavior of a `never` default and not a bug. The Map sends all five to the
fallback. The object literal mistakes what it finds under each of the four property names for a
handler, with a different result each time.

`handlers['constructor']` is `Object`, which is a function, so the lookup calls it and gets an
object back. `handlers['__proto__']` is
[`Object.prototype`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto),
which is not a function, so the call throws.[^2] `toString` and `hasOwnProperty` are functions
called with no receiver, so one returns `[object Undefined]` and the other throws. Any of the
four looks like a handler to an `if (!handler)` check, because none of them is `undefined`.

The message that carries `"type":"__proto__"` is not malicious and not malformed. It is a valid
JSON document,[^3] and the string is harmless until it becomes a property lookup.

## Check it worked

Six tests. The first is the exhaustiveness check from the previous section, and the fourth pins
the object literal's behavior so the page's claim stays true.

```bash
node --test dispatch.test.ts
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 145.919166
```

The second test registers a fallback that records what it saw, sends `order:refund`, and asserts
that the fallback ran once and nothing threw. Add a type to `MESSAGE_TYPES` without a handler and
the first test fails with `no handler for` and the type. Add a handler for a type that is not
declared and the same test fails on the count.

## When it goes wrong

Dispatch throws `is not a function` on a message nobody expected. The lookup reads the key and
calls the result without a `has` check, or the registry is an object literal and the key was a
property of `Object.prototype`. Use a Map, and check before you call.

Two modules register the same type and the second one wins in silence. The registry uses `set`
without checking `has`. Throw on a duplicate at registration, which is at startup, rather than
finding out from a customer.

Every test passes, but a real message still reaches the fallback. The producer renamed the type
and `MESSAGE_TYPES` was not told. Generate the declared list from the schema the producer
publishes, or import it from the package the producer ships, so a rename is a build failure on
your side.

## When not to do this

Do not build a registry for three types that live in one file. A switch with a `never` default
is shorter, and the compiler checks it on every save, which no test can match. The Map earns its
place when handlers arrive from modules the dispatcher does not import.

Do not key on one string once two properties decide the handler. The first composite key,
`order:create:v2`, is the point where the Map stops being a registry and becomes a naming
convention. When the version, the tenant, or the source decide the handler alongside the type,
you need a matcher that reads properties, and a string key cannot be made to do that.

Do not keep the object literal and reach for
[`Object.create(null)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create)
as the fix. A null-prototype object removes the chain, and it still needs the `has` discipline,
and TypeScript has no type for it that `satisfies` can check. The Map has the check built in.

Do not let the fallback swallow. A fallback that returns a string and moves on has hidden the
renamed type for good. Count what it sees, log the type, and alert on the count.

## Related how-tos

- [Run service startup steps in a fixed order](/howto/run-service-startup-steps-in-order)

- [Limit concurrency in a message worker](/howto/limit-concurrency-in-a-message-worker)

## Last verified

Verified 2026-09-24 against Node 22.22.2. Both output blocks are what the preceding command
printed. The sample was also type-checked with TypeScript 6.0.3 under `strict` and
`erasableSyntaxOnly`; the page's commands do not run the compiler.

[^1]: The `satisfies` operator arrived in
[TypeScript 4.9](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html),
whose release notes introduce it with a palette of colors that must each be a string or a tuple
of numbers. The problem it solves is older than the keyword. An annotation checks a value and
then replaces its type with the annotation, so the check costs you what you knew. The keyword
checks and keeps quiet, which is the rarer of the two behaviors.

[^2]: `__proto__` is not in the main body of the language. ECMA-262 files it under
[Annex B](https://tc39.es/ecma262/multipage/additional-ecmascript-features-for-web-browsers.html),
whose title is Additional ECMAScript Features for Web Browsers, beside `escape`, `unescape`, and
the HTML string methods. The standard describes them because the web already depended on them,
and Annex B is normative for browsers only. Node is not a browser and ships it anyway, which is
how a lookup table in a queue worker came to hold a property from the 1990s.

[^3]: The MDN page for
[`JSON.parse`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse)
records the one place a JSON text and the same characters read as a JavaScript expression give
different values: a key named `__proto__`. In JSON it is an ordinary property. In an object
literal it sets the prototype. The message in the demo is therefore built the safe way, and the
danger arrives later, when the safe string is used as a key.