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

Guillaume Wuip
iAdvize Engineering
4 min readFeb 20, 2020

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:

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

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:

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:

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.

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.

It is now cleaner to check for a TextMessage:

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:

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.

Now our renderMessage is way cleaner:

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

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:

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:

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.

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 !

--

--