How-to › Run message-based services

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.

Audience
API producer
Level
beginner
Topic
Route messages by pattern
Languages
TypeScript
Verified

The worker’s switch (message.type) has forty cases and three people editing it in the same pull request. A producer renamed a type, the message arrived under the name nobody had a case for, and the lookup somebody wrote to replace the switch answered with handlers[message.type] is not a function. The message was dropped, and the log line named a function that never existed.

What you get

You will end up with a Map registry with no prototype chain in the way, a fallback for unknown types, and a test that every declared type has a handler. This is for you if adding a handler means editing a dispatcher someone else owns.

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 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 it relies on needs TypeScript 4.9 or later in your editor.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Map registryHandlers come from several modules and register themselves at startupNo compile-time exhaustiveness, so a declared type with no handler is a test failure rather than a build failureEvery handler lives in one file you own, and the compiler’s check matters more than open registration
object literal lookupA fixed set of types in one module, with satisfies checking the keysKeys such as constructor and __proto__ resolve through the prototype chain to code you never wroteThe type comes off the wire, where any string can arrive
switch statementA closed set of types, and you want the compiler to refuse a missing caseOne function that every added type edits, and a never default that throws at runtime on a wire type it never declaredHandlers 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.

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 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.

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 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.

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.

node demo.ts
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, 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.

node --test dispatch.test.ts
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) 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.

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.

Footnotes

  1. The satisfies operator arrived in TypeScript 4.9, 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. ↩︎ Back to text

  2. __proto__ is not in the main body of the language. ECMA-262 files it under Annex B, 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. ↩︎ Back to text

  3. The MDN page for 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.