A Lazy Sequence

Structured messages with TypeScript

I've been working on some architectural code for Manticore to move toward a more worker based structure. One aspect that I have been wanting to get cleaner is managing types for the messages that cross the worker boundaries.

One property of worker messages is that they use the ‘structured clone algorithm’ to share data between either side of the worker boundary. This allows data similar to JSON (although more types are supported) to be shared efficiently. Like JSON, you can pass objects but not classes. In TypeScript this means you necessarily lose type information.

Ideally I would like to be able to easily reconstitute type information for data received, and quickly make type-directed decisions. I also want to be able to work with messages generically when not at the boundaries of messaging, for instance, using a publish/subscribe hub. There are a few caveats here:

  • Without using some kind of schema or structural validation on top of what I present below, you need to trust both the sender and receiver to be generating structurally correct data. If you don't trust both sides (network requests, window message listeners, etc), you definitely want to include validation.
  • Without runtime types we have to perform all our dispatches using predicate functions.
  • Without generating code, theres little that can be done to avoid some of the boilerplate code needed.

You will want to be familiar with the advanced types section of the TypeScript handbook to grok the following; everything except intersection types and polymorphic this types are relevant.

All the code from this blog post is available in gist form.

The Message interface

To get started I am going to define an interface that all messages will use:

interface IMessage<TKey extends string, TPayload> {
  key: TKey;
  payload: TPayload;
}

A message has a key and some data. They key is used for routing and decision making with generic messages, and for determining the types with a typed message.

TKey extends string. The key is an instance of string so that we can use string literal types as part of the definition of typed message later.

Generic messages

This is pretty simple. GenericMessage is simply a type alias for messages where the key is any string, and the payload is any data:

type GenericMessage = IMessage<string, any>;

An example typed message

This is where the boilerplate starts. The definition of a typed message heavily leverages string literal types, union types, user-defined type guards, and type aliases. I place all the definitions for a particular group of message types within a single module:

module foo {
  type FooBarKeyT = "foo.bar";
  var FooBarKey: FooBarKeyT = "foo.bar";
  type FooQuuxKeyT = "foo.quux";
  var FooQuuxKey: FooQuuxKeyT = "foo.quux";
	
  export type FooBar = IMessage<FooBarKeyT, {v: number}>;
  export type FooQuux = IMessage<FooQuuxKeyT, {v: string}>;
  export type FooMessage = FooBar | FooQuux;


  // User-defined type guards:
  export function isFooMessage(m:GenericMessage): m is FooMessage {
    return m.key === FooBarKey 
        || m.key === FooQuuxKey;
  }
	
  export function isFooBarMessage(m:FooMessage): m is FooBar {
    return m.key === FooBarKey;
  }
	
  export function isFooQuuxMessage(m:FooMessage): m is FooQuux {
    return m.key === FooQuuxKey;
  }

	
  // Functions to create messages:
  export function fooBarMessage(v: number): FooMessage {
    return {key: FooBarKey, payload: {v: v}};
  }
	
  export function fooQuuxMessage(v: string): FooMessage {
    return {key: FooQuuxKey, payload: {v: v}};
  }
}

This is the guts of a typed message. This module, foo has two types of message – FooBar, and FooQuux – within the broader FooMessage type.

Defining the types

The first step is to define the message keys. This is done with the goofy pairs of lines at the top

type FooBarKeyT = "foo.bar";
const FooBarKey: FooBarKeyT = "foo.bar";
type FooQuuxKeyT = "foo.quux";
const FooQuuxKey: FooQuuxKeyT = "foo.quux";

The first pair of lines defines the type for the FooBar key by assigning a single string literal to a type alias. I've used a T suffix to separate it from the constant of the same name. The constant is then assigned the same string literal, and has the type explicitly provided. This ensures that the type and the constant cannot diverge, and that we don't need to repeat the literal any further. The process is repeated for FooQuux's key.

Note that there is no key defined for FooMessage. That type exists as a union of the above two keys.

export type FooBar = IMessage<FooBarKeyT, {v: number}>;
export type FooQuux = IMessage<FooQuuxKeyT, {v: string}>;
export type FooMessage = FooBar | FooQuux;

Next is defining the types for the messages themselves. Here I am just using type aliases of the IMessage interface to specify the key – using the key types just defined – and the payload. FooMessage is simply defined as the union of both IMessage types.

A very useful property of these message definitions is that the compiler will not let you specify the wrong keys, or, once the keys are specified, the wrong payload data. Try it out in a playground

Defining the guards

With the types defined, the next step is to define guard predicates for each type including the FooMessage union. This is total boilerplate but drastically improves the usability of the typed messages. You can see this in action in the consumer module of the gist.

export function isFooMessage(m:GenericMessage): m is FooMessage {
  return m.key === FooBarKey 
      || m.key === FooQuuxKey;
}
	
export function isFooBarMessage(m:FooMessage): m is FooBar {
  return m.key === FooBarKey;
}
	
export function isFooQuuxMessage(m:FooMessage): m is FooQuux {
  return m.key === FooQuuxKey;
}

Note that the FooMessage guard takes a GenericMessage argument. This means you can union FooMessages with all the other messages you expect to receive and easily dispatch based on type.

Message construction functions

These two functions – fooBarMessage, and fooQuuxMessage – are self explanatory. They return plain objects rather than using classes because when they are posted across a worker boundary this is what the other side will receive anyway.


That’s everything. I am still exploring this idea. So far it appears to have merit in spite of the boilerplate required. If you make use of this idiom in your projects I would be interested to hear how it goes.

In a future post I may write up some notes on how I am using workers, and a lightweight worker class I have written to structure my program.