How to model your entities with opaque and sum types in Typescript - round 2.
This post was originally published on Medium.
In a previous article we shared how we use opaque and sum types to model a new domain in Typescript.
We built a simple way to write a robust and maintainable domain (in the sense of Domain Driven Design), providing a clean and readable basis to build from further down the line. But, as we will see, it came with boilerplate.
Modeling the domain with our basic opaque-types library
This where we stopped last time:
// message.ts
import { createOpaqueAPI } from '@iadvize-oss/opaque-type';
import { createFoldObject } from '@iadvize-oss/foldable-helpers';
type $Text = {
text: string;
}
type $Image = {
url: string;
description: string;
}
const TextOpaqueAPI = createOpaqueAPI<
'Text',
$Text,
>('Text');
const ImageOpaqueAPI = createOpaqueAPI<
'Image',
$Image,
>('Image');
// opaque types
export type Text = ReturnType<typeof TextOpaqueAPI.toOpaque>;
export type Image = ReturnType<typeof ImageOpaqueAPI.toOpaque>;
// union of opaque types
export type Message = Text | Image;
// constructors
export function createText(text: string): Text {
return TextOpaqueAPI.toOpaque({ text });
}
export function createImage({ url, description }): Image {
return ImageOpaqueAPI.toOpaque({ url, description });
}
// queries
export function text(message: Text): string {
const $message = TextOpaqueAPI.fromOpaque(message);
return $message.text;
}
export function url(message: Image): string {
const $message = ImageOpaqueAPI.fromOpaque(message);
return $message.url;
}
export function description(message: Image): string {
const $message = ImageOpaqueAPI.fromOpaque(message);
return $message.description;
}
export function isText(message: Message): message is Text {
return TextOpaqueAPIisOpaque(message);
}
export function isImage(message: Message): message is Image {
return ImageOpaqueAPIisOpaque(message);
}
// transformations
export function addSignature(signature: string) {
return (message: Text): Text => {
const $message = TextOpaqueAPI.fromOpaque(message);
const newText = `${$message.text} - ${signature}`;
return TextOpaqueAPI.toOpaque({ text: newText });
};
}
export function withBitlyUrl(message: Image): Image {
const $message = ImageOpaqueAPI.fromOpaque(message);
const newUrl = convertToBitly($message.url);
return ImageOpaqueAPI.toOpaque({ ...$message, url: newUrl });
}
// fold function from @iadvize-oss/foldable-helpers
const fold = createFoldObject({
Text: isText,
Image: isImage
});
This still works great. Our domain is safe: details of the types are kept private and the users of this message.ts
module are forced to use the exposed APIs in order to deal with messages. Opaque types hide implementation hence we cannot use them directly.
Although it produces a very solid domain, there is also a lot of boilerplate to write and it doesn’t accept entity addition as easily as we would like. Imagine adding a new $Audio
type here and having to deal with a new AudioOpaqueAPI
everywhere. Not fun.
Modeling the domain with @iadvize-oss/opaque-union
Enters @iadvize-oss/opaque-union. This is an experimental library to help with writing opaque sum-types without the boilerplate.
With this library we can rewrite our domain like so:
import * as Union from '@iadvize-oss/opaque-union';
type $Text = {
text: string;
}
type $Image = {
url: string;
description: string;
}
const MessageUnion = Union.of({
Text: Union.type<$Text>(),
Image: Union.type<$Image>(),
});
// opaque types
export type Text = ReturnType<typeof MessageUnion.of.Text>;
export type Image = ReturnType<typeof MessageUnion.of.Image>;
// union of opaque types
export type Message = Union.Type<typeof MessageUnion>;
// constructors
export const createText = MessageUnion.of.Text;
export const createImage = MessageUnion.of.Image;
// queries
export const text = MessageUnion.Text.lensFromProp('text').get;
export const url = MessageUnion.Image.lensFromProp('url').get;
export const description = MessageUnion.Image.lensFromProp('description').get;
export const isText = MessageUnion.is.Text;
export const isImage = MessageUnion.is.Image;
// transformations
export const addSignature = (signature: string) => MessageUnion.Text.iso.modify(
($text) => ({ ...$text, text: `${$text.text} - ${signature}` })
);
export const withBitlyUrl = MessageUnion.Image.lensFromProp('url')
.modify(convertToBitly);
// fold function from @iadvize-oss/foldable-helpers
const fold = MessageUnion.fold;
The exposed domain API is the same but the code is much more direct. We just had to pick what we wanted to expose out of our MessageUnion
.
Let’s look in more detail at what @iadvize-oss/opaque-union
enables us to do.
First, it enables us to list the private types we will work with and build an “opaque union api”.
type $Text = {
text: string;
};
type $Image = {
url: string;
description: string;
};
const MessageUnion = Union.of({
Text: Union.type<$Text>(),
Image: Union.type<$Image>(),
});
We will build our entire APi from this MessageUnion
. Because it has great power, it should not be shared outside the module: it would be counterproductive to expose opaque types but also share the key to “unopaque” them.
From MessageUnion
comes our constructors:
export const createText = MessageUnion.of.Text;
export const createImage = MessageUnion.of.Image;
export type Text = ReturnType<typeof MessageUnion.of.Text>;
export type Image = ReturnType<typeof MessageUnion.of.Image>;
These are functions that build opaque types from private types. We can extract the opaques types (the returned types) from them and we can use them directly like so:
export function createEmptyText() {
return MessageUnion.of.Text({ text: '' }); // you must pass a $Text here
}
MessageUnion
also gives us type guards and fold for free.
export const isText = MessageUnion.is.Text;
export const isImage = MessageUnion.is.Image;
export const fold = MessageUnion.fold;
With all these we have now built a convenient message.ts
module that can be used in our app like this:
import * as Message from '../path/to/message.ts';
const message: Message = Message.createText({ text: 'hello world' });
if (Message.isText(message)) {
return <p>This is a text</p>;
} else {
return <p>This is an image</p>;
}
// or
return fold({
Text: () => <p>This is a text</p>,
Image: () => <p>This is an image</p>,
})(message);
Constructors, type guards and fold help us a lot but the majority of the boilerplate of the previous example was in query and transformation functions, where we have to “unopaque” a variable to access or modify a property of the private type.
To cut down on this tedium, MessageUnion
exposes some optics from monocle-ts. You don’t have to be familiar with optics, lenses, prism or profunctor dark magic to use them.
MessageUnion
exposes two kinds of optics. The first one is Iso
. It helps us jump from a type A
to a type B
and back again (when there is a bidirectional link between them — an isomorphism, hence the name). Give them a type A
, it will give you a B
in return. Give them a type B
, it will give you an A
. You get the idea.
MessageUnion
uses Iso
to help us jump from opaque to private, and from private to opaque. It replace the fromOpaque
and toOpaque
functions from the previous article.
export function addSignature(signature: string) {
return (text: Text) => {
const $text = MessageUnion.Text.iso.unwrap(text); // get private type
const $newText = {
...$text,
text: `${$message.text} - ${signature}`,
};
return MessageUnion.Text.iso.wrap($newText); // get opaque type
};
}
// Iso even offers shortcuts, the same result can be achieved like so:
export const addSignature = (signature: string) => MessageUnion.Text.iso.modify(
($text) => ({ ...$text, text: `${$text.text} - ${signature}` })
);
The second kind of optics MessageUnion
exposes is Lens
. A Lens
allows you to look “inside” a type, either to get or to change something. MessageUnion
uses Lens
to access properties of the private types hidden in opaque types.
This enables us to easily expose queries and transformation functions.
// text :: (text: Text) => string
export const text = MessageUnion.Text.lensFromProp('text').get;
// updateText :: (content: string) => (text: Text) => Text
export const updateText = MessageUnion.Text.lensFromProp('text').set;
The function text
here will read the corresponding property inside the opaque type Text it receives. updateText
will update the property, given a new content.
Optics are hard to understand. There is a lot more to say on them (what are the other optics, how they compose well, etc.) but that’s not the goal of the library. For our need — getter, setter, complex transformations on opaque types — Iso and Lens are enough.
With this experimental library we hope to drastically reduce the time needed to write our domain files. We also hope its strong typing and helpful optic helpers will reduce the need to write tedious tests.
This library is already available, feel free to use and experiment with it. Start for example by reading the documentation of the repo. Then a simple npm add @iadvize-oss/opaque-union
will do!
A big thanks ❤️ to all my iAdvize colleagues that helped me write this post: Axel Cateland, Ben,oit Rajalu and Nicolas Baptiste !