Domain Modeling with Tagged Unions in GraphQL, ReasonML, and TypeScript
Kevin Saldaña
Posted on January 12, 2020
GraphQL has exploded in popularity since its open-source announcement in 2015. For developers who had spent a lot of time managing data transformations from their back-end infrastructure to match front-end product needs, GraphQL felt like a tremendous step forwards. Gone were the days of hand-writing BFFs to manage problems of over-fetching.
A lot of value proposition arguments around GraphQL have been about over/under fetching, getting the data shape you ask for, etc. But I think GraphQL provides us more than that—it gives us an opportunity to raise the level of abstraction of our domain, and by doing so allow us to write more robust applications that accurately model the problems we face in the real world (changing requirements, one-off issues).
An underappreciated feature of GraphQL is its type system, and in particular features like union types and interfaces. Union types in GraphQL are more generally called tagged unions in computer science.
In computer science, a tagged union, also called a variant, variant record, choice type, discriminated union, disjoint union, sum type or coproduct, is a data structure used to hold a value that could take on several different, but fixed, types. Only one of the types can be in use at any one time, and a tag field explicitly indicates which one is in use. It can be thought of as a type that has several "cases", each of which should be handled correctly when that type is manipulated. Like ordinary unions, tagged unions can save storage by overlapping storage areas for each type, since only one is in use at a time.
That's a lot of words, but is any of that important? Let's look at a simple example first.
The Shape of Shapes
The TypeScript compiler has support for analyzing discriminated unions. For the rest of this article, I will be using tagged union and discriminated union as interchangeable terminology. According to the documentation, there are three requirements to form a discriminated/tagged union:
- Types that have a common, singleton type property — the discriminant.
- A type alias that takes the union of those types — the union.
- Type guards on the common property.
Let's take a look the example code to make sure we really understand what we mean.
// 1) Types that have a common, singleton type property — the discriminant.
// In this example the "kind" property is the discriminant.
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
// 2) A type alias that takes the union of those types — the union.
type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
// 3) Type guards on the common property.
// A switch statement acts as a "type guard" on
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
First, we need a discriminant. In this example, the kind
property acts as the discriminant (as string literals like "square"
are singleton types). Second, we need a type alias that takes a union of those types, which we do on line 20 with the type alias Shape
.
Now that we have a union type with a discriminant, we can use type guards on that property to leverage some cool features of the TypeScript compiler. So what did we just gain?
It seems that TypeScript has the ability to infer the correct type for each case statement in our switch! This is very useful, as it gives us great guarantees for each of our data types, making sure we don't misspell or use properties that don't exist on that specific type.
Going back to the Wikipedia definition of tagged unions
It can be thought of as a type that has several "cases", each of which should be handled correctly when that type is manipulated.
In our example the area
function is handling each case of the Shape
union. Besides type narrowing, how else is the use of discriminated unions useful?
One of the hardest parts of software development is changing requirements. How do we handle new edge cases and feature requests? For example, what if we were now in the business of calculating the area of triangle? How would our code need to change to account for that?
Well first, we'd need to add the new type to our discriminated union.
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number
}
type Shape = Square | Rectangle | Circle | Triangle;
// This is now giving us an error
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
That was easy enough. But, if we look at our area function, we see we are now getting an error from TypeScript.
So what's happening here? This is a feature called exhaustiveness checking, and it's one of the killer features of using discriminated unions in your code. TypeScript is making sure you have handled all cases of Shape
in your area function.
Once we update our area function to handle the Triangle
type, our error goes away! This works the other way too—if we no longer want to support the Triangle
type, we can remove it from the union and follow the compiler errors to remove any code no longer needed. So discriminated unions help us both with extensibility and dead code elimination.
The original error wasn't very detailed as far as what code path we missed, which is why the TypeScript documentation outlines another way to support exhaustiveness checking.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
default: return assertNever(s); // error here if there are missing cases
}
}
By structuring your switch statements with a never
type default fallthrough, you get a better error explaining the problem.
Now, it's much easier to tell that we missed the Triangle
type in our area
function.
Although the above example is a bit contrived (like most programming examples), discriminated unions can be found commonly out in the JavaScript wild. Redux actions can be considered discriminated unions with the type
property serving as the discriminant.
It turns out that union types in GraphQL are also discriminated unions!
Our Schema Evolution
We have just received a new seed round from thirsty venture capitalists who see an opportunity to re-hash and re-market the concept of a message board, a technology perfected in the mid-1970s. As a seemingly competent software developer in the height of the software bubble, you jump at the opportunity to build your resume.
Enter GraphQL.
You're all about lean schemas, so you start with something pretty basic.
type Query {
messages: [Message!]!
}
type Message {
id: ID!
text: String!
author: MessageAuthor!
}
union MessageAuthor = User | Guest
type User {
id: ID!
name: String!
dateCreated: String!
messages: [Message!]!
}
type Guest {
# Placeholder name to query
name: String!
}
Your UI will display an unbounded list of messages. Your product team has not learned from the mistakes of the past, and think it would be cool for people to be able to post messages anonymously. Being the savvy developer you are, you make sure to encode that requirement into your GraphQL schema.
Looking closer at our schema, it seems like the MessageAuthor
type union looks an awful lot like our discriminated union examples from before. The only thing that seems to be missing is a shared discriminant property. If GraphQL let us use the type name as the discriminant, we could use the same patterns of type narrowing and exhaustiveness checking we explored earlier.
It turns out GraphQL does have this in the form of a special __typename
property, which can be queried on any field in GraphQL. So, how can we use this to our advantage?
You sit down to bust out the first iteration of the UI. You boot up create-react-app and add Relay as your GraphQL framework. Relay provides a compiler that provides static query optimizations, as well as producing TypeScript (and other language) types based off your client queries.
You use your new-found knowledge of discriminated unions—the first iteration of the UI turns out to not take too long.
import React from "react";
import { useLazyLoadQuery } from "react-relay/hooks";
import { AppQuery as TAppQuery } from "./__generated__/AppQuery.graphql";
import { graphql } from "babel-plugin-relay/macro";
const query = graphql`
query AppQuery {
messages {
id
text
author {
__typename
... on User {
id
name
}
... on Guest {
placeholder
}
}
}
}
`;
const App: React.FC = () => {
const data = useLazyLoadQuery<TAppQuery>(query, {});
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh"
}}
>
{data.messages.map(message => (
<Message message={message} />
))}
</div>
);
};
type MessageProps = {
// a message is an element from the messages array from the response of AppQuery
message: TAppQuery["response"]["messages"][number];
};
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
const Message: React.FC<MessageProps> = ({ message }) => {
switch (message.author.__typename) {
case "User": {
return <div>{`${message.author.name}: ${message.text}`}</div>;
}
case "Guest": {
return <div>{`${message.author.placeholder}: ${message.text}`}</div>;
}
default: {
assertNever(message.author);
}
}
};
export default App;
Everything looks good to go to you. The Relay compiler confirms that your query is valid with your back-end GraphQL spec. TypeScript, in strict mode of course, tells you there's an error though!
What is %other
? Drilling down into the code generated by the Relay compiler, where that is coming from is pretty obvious.
readonly author: {
readonly __typename: "User";
readonly id: string;
readonly name: string;
} | {
readonly __typename: "Guest";
readonly placeholder: string;
} | {
/*This will never be '%other', but we need some
value in case none of the concrete values match.*/
readonly __typename: "%other";
};
Interesting... our exhaustive pattern matching is failing because the Relay compiler generates an additional member for each discriminated union, which represents an "unexpected" case. This is great! This is providing us guard rails and forcing us to deal with the schema evolving out from under us. It gives us the freedom as the consumer to decide what we want to do in that unexpected case. In the context of our message board, we could either hide the message entirely, or display a placeholder username for an unresolvable entity. For now we won't render those posts.
const Message: React.FC<MessageProps> = ({ message }) => {
switch (message.author.__typename) {
case "User": {
return <div>{`${message.author.name}: ${message.text}`}</div>;
}
case "Guest": {
return <div>{`${message.author.placeholder}: ${message.text}`}</div>;
}
case "%other": {
return null;
}
default: {
assertNever(message.author);
}
}
};
Great—we've accounted for any new author types that get created before we can make changes on our UI. This will prevent us from getting runtime errors!
Your new message board site is a hit. Your growth rate is off the charts; in no time the message board extends beyond your immediate friends and family. The board of directors comes rushing in asking what the next innovation is.
Realizing they need to monetize now, management wants to create the concept of premium users. There will be multiple classes of premium user depending on the amount of money they give us, and their reward will be a different color on messages.
type Query {
messages: [Message!]!
}
type Message {
id: ID!
text: String!
author: MessageAuthor!
}
union MessageAuthor = User | Guest
type User {
id: ID!
name: String!
dateCreated: String!
messages: [Message!]!
role: USER_ROLE!
}
enum USER_ROLE {
FREE
PREMIUM
WHALE
}
type Guest {
# Placeholder name to query
placeholder: String!
}
The backend changes are made. Time to go update the UI query!
query AppQuery {
messages {
id
text
author {
__typename
... on User {
id
name
role
}
... on Guest {
placeholder
}
}
}
}
Time to go implement the color-coded message functionality you promised to your paid users.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
const Message: React.FC<MessageProps> = ({ message }) => {
switch (message.author.__typename) {
case "User": {
return <div style={{color: premiumColor(message.author.role)}}>{`${message.author.name}: ${message.text}`}</div>;
}
case "Guest": {
return <div>{`${message.author.placeholder}: ${message.text}`}</div>;
}
case "%other": {
return null;
}
default: {
assertNever(message.author);
}
}
};
function premiumColor(role: USER_ROLE) {
switch (role) {
case "PREMIUM": {
return "red";
}
case "FREE": {
return "black";
}
case "%future added value": {
return "black";
}
}
}
Easy enough. You go to the work fridge to go celebrate your genius monetization strategy. Before you even get a chance to open up that ironically bitter double IPA, your boss runs frantically.
"You forgot about the whales."
Sweat runs down your forehead as you realize the gravity of your mistake. Your highest paying customers—the ones who paid extra money to assert their digital dominance over others in the form of an exclusive message color—had been robbed of their promised value.
You rush back to your computer. I had GraphQL! I had discriminated unions!
Then you realize the error of your ways. You realize that you didn't add exhaustive pattern matching to your premiumColor
function. The whales had been forgotten. You clean up the code and add the exhaustive check to fix the bug.
function premiumColor(role: USER_ROLE) {
switch (role) {
case "PREMIUM": {
return "red";
}
case "WHALE": {
return "blue";
}
case "FREE": {
return "black";
}
case "%future added value": {
return "black";
}
default: {
assertNever(role);
}
}
}
Your bug is fixed. You promise to yourself that you will be more vigilant as a developer in the future. Maybe you add a test. The compiler did all it could, but you hadn't structured your code to take full advantage of exhaustiveness checking. What if the compiler could have done more for us though? What if the pattern we were doing here—matching against specific values and types and returning different values—had better support from the type system (like more powerful exhaustiveness checking)?
A Reasonable Alternative
My goal up to this point has to been to show the value of discriminated unions, and union types generically, and how they help us incrementally build up requirements and account for divergence in product needs depending on that divergence.
As we've illustrated, TypeScript has good support for discriminated unions, but we have to go through a lot of effort and write extra boilerplate code (eg assertNever
) to get good compile-time guarantees.
Going back to the TypeScript documentation about discriminated unions:
You can combine singleton types, union types, type guards, and type aliases to build an advanced pattern called discriminated unions, also known as tagged unions or algebraic data types. Discriminated unions are useful in functional programming. Some languages automatically discriminate unions for you; TypeScript instead builds on JavaScript patterns as they exist today.
One sentence stuck out to me here.
Some languages automatically discriminate unions for you.
What would this look like? What does a language that "automatically" discriminates unions mean?
Enter ReasonML.
ReasonML is a new(ish) syntax for the OCaml language. The ML family of languages is known for its great support for algebraic data types (such as discriminated unions) and wonderful type inference (meaning you don't have to write type annotations yourself).
In ReasonML, discriminated unions are supported first-class by the compiler through variants. Instead of having to write an interface with a property such as __typename
or kind
, variants allow you to express that at a higher level of declaration. Think of it as being able to add keywords that the compiler knows how to attach meaning to.
Instead of a switch statement that can match off a singular discriminant property as in TypeScript, ReasonML supports pattern matching, which gives us the ability to match types at a deeper level. More importantly, we can maintain exhaustiveness checking while leveraging these more advanced matching features.
What does that mean practically? How could that have helped us avoid the bug we had above?
Let's take a look at the comparable example in ReasonML with ReasonReact and ReasonRelay (before we add the premium user color feature).
module Query = [%relay.query
{|
query AppQuery {
messages {
id
text
author {
__typename
...on User {
id
name
role
}
...on Guest {
placeholder
}
}
}
}
|}
];
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div> {React.string(user.name ++ ": " ++ message.text)} </div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
Let's break down this code step-by-step:
module Query = [%relay.query
{|
query AppQuery {
messages {
id
text
author {
__typename
...on User {
id
name
role
}
...on Guest {
placeholder
}
}
}
}
|}
];
ReasonML has a very powerful module system. They provide a nice seam for code re-use and modularity, as well as additional features that are outside the scope of the blog post.
This %relay.query
syntax is called a PPX. You can think of it as a super-charged tagged template that has first-class support at the compiler level. This allows us to hook in additional functionality and type guarantees at compile time through these custom syntaxes. Pretty neat!
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
};
This a module for our CSS-in-JS styles. This is using the library bs-css to provide a typesafe-shim over Emotion.
Notice the flex
syntax? These are called polymorphic variants. Don't worry if that's a lot of gibberish. Conceptually for our purposes you can think of them as supercharged string literals (notice a theme here). Since Reason/OCaml does not have the concept of "string literals", polymorphic variants serve a similar use case. That is quite a simplification, but for the purposes of this article should be enough.
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div> {React.string(user.name ++ ": " ++ message.text)} </div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
Just like normal variants, we can also pattern match on polymorphic variants! In ReasonRelay, our union types are decoded as polymorphic variants that we can pattern match off of. Just like the TypeScript examples, the type is narrowed in each case, and the compiler will yell at us if we happen to miss any patterns.
One thing to notice is the lack of type annotations in the ReasonML example—there is not any reference to an external generated types file, or generic types being passed into our hooks! Because of the power of the PPX and ReasonML's use of the Hindley-Milner inference, the compiler can infer what all our types our from their usage. Don't worry though, it is still very type-safe!
Let's re-write our premium feature functionality in ReasonML.
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
let message = role =>
switch (role) {
| `PREMIUM => style([color(red)])
| `FREE
| `FUTURE_ADDED_VALUE__ => style([color(black)])
};
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div className={Styles.message(user.role)}>
{React.string(user.name ++ ": " ++ message.text)}
</div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
ReasonRelay adds FUTURE_ADDED_VALUE__
and UnmappedUnionMember
to the respective enum and variant types to help prevent runtime errors for unknown types (just like in TypeScript).
This time we write our premiumColor
function as a helper function inside the Styles
module (which feels appropriate as far as code concerns).
You feel good about your code... but wait! We still have the same bug in our above code! We hadn't learned the error of our ways! But looking at our editor, we can see that we have an error in our component.
The compiler found a bug! But what is it saying? It seems that our Styles.message
function had not handled the case for Whale
, so the compiler is giving us an error. Because of the usage of our functions, the type system could infer there was a mismatch in our understanding! Let's update our code to fix the error.
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
let message = role =>
switch (role) {
| `PREMIUM => style([color(red)])
| `WHALE => style([color(blue)])
| `FREE
| `FUTURE_ADDED_VALUE__ => style([color(black)])
};
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div className={Styles.message(user.role)}>
{React.string(user.name ++ ": " ++ message.text)}
</div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
Pattern Matching Extra Goodies
Above we've illustrated some of the power of pattern matching—but we haven't really scratched the surface of what is really possible. Unlike TypeScript, which is limited in matching against complex patterns (more than one discriminant, etc), especially while retaining exhaustiveness checking.
ReasonML is not bound to those same limitations. Here's another way we could have written our "premium" user functionality.
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
let premiumMessage = style([color(red)]);
let whaleMessage = style([color(blue)]);
let freeMessage = style([color(black)]);
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User({name, role: `PREMIUM}) =>
<div className=Styles.premiumMessage>
{React.string(name ++ ": " ++ message.text)}
</div>
| `User({name, role: `WHALE}) =>
<div className=Styles.whaleMessage>
{React.string(name ++ ": " ++ message.text)}
</div>
| `User({name, role: `FREE | `FUTURE_ADDED_VALUE__}) =>
<div className=Styles.freeMessage>
{React.string(name ++ ": " ++ message.text)}
</div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
There's a bit going on in this syntax, so let's break it down. You can think of this syntax similarly to destructuring in JavaScript. However there's two things going on here—first, we are binding the name
property of the user to the variable binding name
(just like in JavaScript). The second part is the interesting part—we are telling the compiler to match against the role
value of each author (so Styles.whaleMessage
will only be applied for users with the Whale
role).
The best part is, we can still leverage all the power of exhaustiveness checking for these properties. We aren't limited to only a singular discriminant! So if we comment out the Whales
part of our component:
Reason is telling us we forgot to handle our whales! We can crutch on the compiler to help us remember all of the edge cases of our domain.
Conclusion
The goal of this article was to introduce you to the concept of discriminated/tagged unions and show how you can leverage them to write more extensible applications. We went through some simple examples in TypeScript to get a basic idea of what tagged unions are, and what type of guarantees the compiler can generate around them. We then looked at GraphQL unions and how they are represented as tagged unions at runtime.
We walked through a contrived requirements story and showed how we can leverage the lessons we learned earlier, along with type generation tools such as Relay, to write applications that are robust to changing requirements. We ran up against the limitations of TypeScript's exhaustiveness checking, and the code-scaling limitations of nested tagged unions.
We then took a brief look at ReasonML, and what a language that has "automatic" support for tagged unions through variants looked like. Using very similar technology to the TypeScript examples, we demonstrated the power of variants and pattern matching in Reason, and how the power of the compiler can handle cases that require lots of hoops in TypeScript.
Lastly, we explored the power of Hindley-Milner type inference and pattern matching, and how in combination they allow us to write highly type-safe applications without needing to provide lots of type annotations.
Whether or not you use GraphQL, TypeScript, or ReasonML, algebraic data types are an incredibly powerful tool to keep in your arsenal. This article only begins to scratch the surface of what type of things they make possible.
If you are interested in learning more about ReasonML, come check us out in the Discord! Everyone is incredibly friendly and willing to answer any questions you may have.
Posted on January 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.