How to model your entities with opaque and sum types in Typescript.

This post was originally published on Medium.

Working on a new feature implies working on modeling the data that comes with it. This step provides a clean and readable basis to build from further down the line. Because of this we are putting a larger emphasis on making sure our model is as robust and maintainable as it could be. Here is how.

Rails

Photo by Anton Darius on Unsplash

The scenario

Imagine we are working on a fancy chat application in which we send many kinds of messages. We could model them this way:

// Message.ts
export type TextMessage = {
  text: string;
}

export type ImageMessage = {
  mimeType: string;
  url: string;
  description: string;
}

export type AudioMessage = {
  mimeType: string;
  url: string;
  description: string;
}

And we would define a union type Message that could be any of them:

// Message.ts
export type Message = TextMessage | ImageMessage | AudioMessage;

Doing so helps a lot (we can for example define a list const messages: Message[] = … without having to deal with variations at that level) but it is not perfect.

How should we deal with this union in a function that receives a Message and that should act differently depending on the type of the message? We would have to set up a series of ifs and quickly code ourselves into a corner, like this:

import { Message } from './Message';

function renderMessage(message: Message) {
  if (message.text) {
    // ok we know it’s a TextMessage
    return <TextComponent text={message.text} />
  }

  if (message.url) {
    // Problem!
    // is it ImageMessage or AudioMessage?
  }
}

We went through the trouble of strongly typing our messages yet we have to rely on fragile internal structure inspection to differentiate them. We want to differentiate the types of messages effortlessly.

Tagged Union

Enter tagged unions. In Typescript, a typed object is still only a classic Javascript object at runtime. We need to find a trick to differentiate two types with the same properties, like ImageMessage and AudioMessage.

We can add a tag to our types manually:

export type TextMessage = {
  messageType: 'TEXT';
  ...
}

export type ImageMessage = {
  messageType: 'IMAGE';
  ...
}

export type AudioMessage = {
  messageType: 'AUDIO';
  ...
}

export type Message = TextMessage | ImageMessage | AudioMessage;

Our Message type can now be called a “tagged union”, which means a union of sub-types that all have a tag value (here, messageType). Now when we write our function, Typescript will refine the type when we check the tag value.

function renderMessage(message: Message) {
  if (message.messageType === 'TEXT') {
    // message is typed as TextMessage here
    return <TextComponent text={message.text} />
  }

  if (message.messageType === 'IMAGE') {
    // message is typed as ImageMessage here
    return <ImageComponent url={message.url} description={message.description} />
  }

  if (message.messageType === 'AUDIO') {
    // message is typed as AudioMessage here
    return <AudioComponent url={message.url} description={message.description} />
  }
}

While this works great, it doesn’t scale so well. We have to test message.messageType everywhere in our codebase. This will necessarily create a lot of boilerplate and is not D.R.Y.

User type guard

We want to check the type of each message more easily. Typescript can help with that through user-defined type guards.

// If this function returns true, TS will know the message variable is a TextMessage
function isText(message: Message): message is TextMessage {
  return message.messageType === 'TEXT';
}

// Repeat for isImage and isAudio

It is now cleaner to check for a TextMessage:

if (isText(message)) {
  return <TextComponent text={message.text} />
}

But we still have to do all our ifs each time we want to process messages differently based on type. In the end, we did not remove that much boilerplate so far — it is just moving it somewhere else — but at least it is D.R.Y: if the messageType values change, we simply update the type guard function.

Fold on things

Inspired by functional programming and particularly the library fp-ts, we decided to give the fold/match pattern a try for our entities sum types.

If you are not familiar with the concept, here is what it looks like. Using fp-ts Option, we can can use the fold function:

import { Option, fold } from 'fp-ts/lib/Option';

const user: Option<User> = ... // ie. None | Some<User>

return fold(
    () => <p>oops, no user!</p>,
    (user: User)  => <UserView user={user} />,
)(user);

How does this help with our Message sum-type? We need a function that takes as many parameters as there are sub-types. Each one of these parameters should be a function as well, one that takes a Message and returns something. Finally, the parent function, given a Message, should return the correct sub-function result depending on its sub-type.

// Message.ts
function fold<R>(
  onText: (message: TextMessage) => R,
  onImage: (message: ImageMessage) => R,
  onAudio: (message: AudioMessage) => R,
) => {
  return (message: Message): R => {
    switch (message.messageType) {
      case 'TEXT':
        return onText(message);

      case 'IMAGE':
        return onImage(message);

      case 'AUDIO':
        return onAudio(message);
    }
  }
}

Now our renderMessage is way cleaner:

import { TextMessage, ImageMessage, AudioMessage, fold } from './Message';

const renderMessage = fold(
  (textMessage: TextMessage) => (
    <TextComponent text={message.text} />
  ),
  (imageMessage: ImageMessage) => (
    <ImageComponent
      url={message.url}
      description={message.description}
    />
  ),
  (audioMessage: AudioMessage) => (
    <AudioComponent
      url={message.url}
      description={message.description}
    />
  ),
)

// Somewhere in a component far, far away... 
return renderMessage(message);

Creating a fold for every sum-type and for every union type is fastidious. We created @iadvize-oss/foldable-helpers to help with that:

import { createFold } from '@iadvize-oss/foldable-helpers';

// fold :: (TextMessage -> R) -> (ImageMessage -> R) -> (AudioMessage -> R) -> Message -> R
// “feeding” the fold-creator with our previously created type guards
const fold = createFold(isText, isImage, isAudio);

Or, passing function with an object to give them names:

import { createFoldObject } from '@iadvize-oss/foldable-helpers';

// fold :: ({
//  isText: TextMessage -> R,
//  isImage: ImageMessage -> R,
//  isAudio: AudioMessage -> R
// }) -> Message -> R
const fold = createFoldObject({ isText, isImage, isAudio });

We now have a Message sum-type with its fold function to match on members.

It is good enough to be used internally, but if we want to share the Message sum-type with the world, for example in a public library, we would want to consider subtype properties as private or opaque.

Expose opaque message entities

Let’s say we want to share the Message, TextMessage, ImageMessage and AudioMessage entities in a library as well as functional function like below:

createText(text: string): TextMessage
text(message: TextMessage): string

createImage(url: string, description: string): ImageMessage
createAudio(url: string, description: string): AudioMessage
url(message: ImageMessage | AudioMessage): string,

toText(message: ImageMessage): TextMessage

...

We don’t want to share externally how we have modeled our entities and which properties we have on each type: forcing users to use our functions allows us to update the internal implementation without breaking someone else’s code.

We use @iadvize-oss/opaque-type to hide our type in an opaque one. It will wrap our internal types in an opaque shell. It will also provide a “runtime” type representation like below:

export type $TextMessage = {
  text: string;
}

const { toOpaque, fromOpaque, isOpaque } = createOpaqueAPI<
  'TextMessage',
  $TextMessage,
>('TextMessage');

export type TextMessage = ReturnType<typeof toOpaque>;

export function createText(text: string): TextMessage {
  return toOpaque({ text });
}

export function text(message: TextMessage): string {
  const $message = fromOpaque(message);
  return $message.text;
}

// We can use the isOpaque type guard without needing the `messageType` tag,
// free of charge!
export function isText(message: Message): message is TextMessage {
  return isOpaque(message);
}

The user of our fabulous Message library can use it without having to rely on internal details. Breaking changes will only be real, functional breaking changes and never petty implementation details.

import {
  createText,
  createImage,
  createAudio,
  AudioMessage,
  description,
  url,
  fold,
} from 'fabulous-Message-library';

// data fetching
async function fetchMessages() {
  const { rawMessages } = await fetch.get('http://messages.io');

  const messages = rawMessages
    .map(message => {
      if (...) {
         return createAudio(...)
      } 

      if (...) {
         return createImage(...)
      } 

      return createText(...);
    })

  return messages;
}

// Views

function AudioMessage({ message } : { message: AudioMessage }) {
  return (
    <div>
      <p>{description(message)}</p>
      <Player source={url(message)} />
    </div>
  );
}

// ...

function MessageComponent({ message }: { message: Message }) {
  return fold({
    isText: message => <TextMessage message={message} />,
    isImage: message => <ImageMessage  message={message} />,
    isAudio: message => <AudioMessage message={message} />,
  })(message);
}

Having modeled our functional entities with a tagged-union, opaque types and a foldable interface we can now share them freely, assured we are still in control of the internal details.

Doing this certainly prevents technical “breakings” down the line but, regardless of the publicity of our codebase, it also greatly facilitates maintenance and refactoring while enforcing a consistent, strongly typed data structure.


A big thanks ❤️ to all my iAdvize colleagues that helped me write this post: Wandrille Verlut, Victor Graffard, Axel Cateland and Ben oit Rajalu!