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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Map registry | 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 | 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 | 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.
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.
Related how-tos
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
-
The
satisfiesoperator 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 -
__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, besideescape,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 -
The MDN page for
JSON.parserecords 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