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.
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 if
s 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 if
s 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!