Type-safe lookup tables
β’ 398 words β’ ~2 min read β²
In some scenarios I prefer to use a lookup table instead of a dynamic dispatch (or a switch statement / if-else-cascade). In Typescript I usually denote it like this:
type LookupKey = string | number | symbol;
type LookupTable<
LookupKey extends (string | number | symbol),
ReturnType
> = {
[key in LookupKey]: ReturnType
};
Let's suppose you need to handle commands that you receive via some message channel.
const COMMANDS = [
"a",
"b",
"c",
] as const;
type CommandName = typeof COMMANDS[number];
With the as const assertion the type of the array COMMANDS is narrowed to a readonly tuple of the assigned values instead of an array of string that would have been infered without the assertion. And by using an indexed access type, you can then create a discriminated union of the legal key names, that is derived from the values of the array. With this type then you can create a specific lookup table:
type CommandHandlerTable = LookupTable<
CommandName,
() => void
>;
const COMMAND_HANDLER: CommandHandlerTable = {
"a": () => {},
"b": () => {},
"c": () => {},
};
What is nice is that this list of legal keys serves two purposes at once: at compile-time to check for exhaustiveness, as well as at runtime to make validations on the actual objects. If you assert that clients might send malformed messages, you need to check all objects that are supposedly commands, before actually executing code that assumes that what ever is send has property which is a legal key in any given lookup table. You can do this with a type guard like the following:
type Command = {
type: CommandName
}
function isCommand(message: any): message is Command {
if (message && message.type) {
return COMMANDS.includes(message.type);
}
return false;
}
Now a the implementation of a handler might look like this:
function execute(message: any) {
if (isCommand(message)) {
COMMAND_HANDLER[message.type]();
}
}
So by simply adding a new value to COMMANDS the compiler will enforce that a handler is added to the COMMAND_HANDLER lookup table, as well as derive the discriminated union of legal keys to the table without having to touch the CommandName type. The execute function doesn't need to be changed, just because a command is added. And if you are willing to forgo the compile-time safety guarantees, this also makes it relatively straight forward to enable the dynamic extension of a system, although in the latter case you'd need some additional logic to prevent "undefined is not a function" exceptions.