Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Type Safe Message Dispatch in TypeScript

DZone's Guide to

Type Safe Message Dispatch in TypeScript

Follow along as one developer shows us how to create type safe code using the TypeScrip language.

· Web Dev Zone ·
Free Resource

Bugsnag monitors application stability, so you can make data-driven decisions on whether you should be building new features, or fixing bugs. Learn more.

TypeScript continues to be amazing. Anders and team are doing an incredible job making the language accessible and at the same time powerful enough to express interesting invariants that can be encoded with conditional and mapping types.

I'm currently working on a workflow toolkit and building it in TypeScript has allowed me to express the message dispatch logic in a type safe way. Putting the pieces together has been a lot of fun so I'm going to outline the pattern in case others find it useful.

The skeleton for a message dispatch loop consists of something that looks like the following:

function dispatch(message, handlers) {
  const messageType = message.type;
  if (handlers[messageType]) {
    return handlers[messageType](message);
  } else {
    throw new Error(`Unknown message type ${message.type}: ${message}`);
  }
}

Without types there isn't much we can say about this function. All we know is that there is some kind of mapping from types of message to functions that take the message and do something. We want to preserve as much of this structure as possible while at the same time adding as much type safety as possible. Fortunately, TypeScript has all the ingredients to make this possible.

First thing we need to do is specify the message types and give the compiler enough information to help us out:

type Eq<A, B> = [A] extends [B] ? ([B] extends [A] : true : never) : never;

type MessageType = 'work' | 'exit';
interface M<T extends MessageType, P> = {
  type: T
  payload: P
}
type WorkMessage = M<'work', any>;
type ExitMessage = M<'exit', any>;
type Message = WorkMessage | ExitMessage;
{ const _: Eq<MessageType, Message["type"]> = true; }

In a production environment you would spell out all the details and not leave the payload type unspecified but for demonstration purposes I'm going to leave the payload unspecified because it is easy to extend the pattern to typed payloads. The other thing I need to explain is Eq.

What's going on there is that we are verifying that every message type actually has a message assigned to it.  Eq will show up throughout so make sure you understand what is going on. If the types in Eq are not equal then we will not be able to assign true to it and the compiler will tell us the assignment in the block is not possible.

Now that the message type is specified we can use a switch statement and ask the compiler to help us out with the dispatch function:

function dispatch(message: Message, handlers) {
  switch (m.type) {
    case 'work': {
      return handlers[m.type](m);
    }
    case 'exit': {
      return handlers[m.type](m);
    }
    default: {
     const n: never = m;
     throw new Error(`This can never happen: ${n}`);
    }
  }
}

Thanks to TypeScript's exhaustive checking, the compiler will complain if we forget to check for all the message types that are in MessageType. The assignment in the default block will have red squiggly lines and it will tell us that never is not assignable to some type we forgot to check so when we try to compile we will get warnings about missing cases.

The messages are now handled properly but handlers is untyped. We can assign a type to it as well by using conditional and mapping types. First thing we need to do is specify the type of the handler function:

interface Mapping {
  work: WorkMessage
  exit: ExitMessage
}

type HandlerFunction<K> =
  K extends MessageType ? (m: Mapping[K]) => void : never;
{
  const _: Eq<keyof Mapping, MessageType> = true;
  const __: { [K in keyof Mapping]: Eq<K, Mapping[K]["type"]> } = {
    work: true,
    exit: true
  };
}
type HandlerMapping = { [K in keyof Mapping]: HandlerFunction<K> };

We are again using the same trick with Eq to verify certain invariants. We want to make sure that the key for the mapping and the actual type of the message match up. If they don't match up then the assignment will fail during compile time and we will get an error/warning that will tell us something is wrong. So if we tried to assign the key exit to the message WorkMessage the assignment would be incorrect and we'd get a warning/error at compile time.

Now with all those types we can make sure handlers variable in our dispatch function is also correct:

function dispatch(message: Message, handlers: HandlerMapping) {
  switch (m.type) {
    case 'work': {
      return handlers[m.type](m);
    }
    case 'exit': {
      return handlers[m.type](m);
    }
    default: {
     const n: never = m;
     throw new Error(`This can never happen: ${n}`);
    }
  }
}

This makes everything type safe. If you know of a better way to structure the dispatch logic and types then let me know. I'm pretty happy with this solution but open to a better design. I kind wish I could abstract the pattern further but I'm pretty sure that requires higher order types. There are a few simplification to be made but I'll leave that as an exercise for the reader.

Monitor application stability with Bugsnag to decide if your engineering team should be building new features on your roadmap or fixing bugs to stabilize your application.Try it free.

Topics:
typescript ,web dev ,type safety ,typescript tutorials

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}