DEV Community

loading...

Domain Modeling with Tagged Unions in GraphQL, ReasonML, and TypeScript

Kevin Saldaña
・17 min read

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:

  1. Types that have a common, singleton type property — the discriminant.
  2. A type alias that takes the union of those types — the union.
  3. 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?

discriminant

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.

discriminat2

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.

typescript-error-1

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.

typescript-error-fix-1

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.

ts-error-2

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!

error-ts-3

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.

reason-error-1

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.

reason-error-4

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-error-5

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.

Discussion (5)

Collapse
yaldram profile image
Arsalan Yaldram

Awesome article Sir, I am a newbie to ReasonML. I completed learning ReasonML and want to take on ReasonReact Next. Thank you for this awesome article.

One question though under the TypeScript example we are returning the default assert if no User matches, then why do we need the "%other" type matching. If we are handling the unexpected case with the assert under default why we need to match "%other" what kind of type safety is the Realy compiler asking for.

Thanks.

Collapse
ksaldana1 profile image
Kevin Saldaña Author • Edited

Hey there! I'm glad you enjoyed the article and are diving into learning about Reason!

That part of the post is a bit shallow, and I definitely hand-waved over some of the finer details. I thought about diving into the nuances of your question in my article initially, but was hesitant to make the post longer than it already was!

It turns out I was making some assumptions about the relay-runtime and under-the-hood transformations that turned out to be incorrect. I'll walk through what I mean.

Let's say we created a new type on our MessageAuthor union called Suspended, to represent a user that has been temporarily suspended.

union MessageAuthor = User | Guest | Suspended

type Suspended {
  id: ID!
  username: String!
}

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!
}

If our GraphQL server updated before we got the chance to update the UI, our Messages query could have authors that the UI does not know how to deal with (eg. authors with the __typename of Suspended). In an ideal world, we have already handled the case for "unexpected" types.

That is where I thought the %other type was coming from. I thought that if Relay ran into any types that it was unaware of (from its own generation), that it would replace the __typename with the literal of %other.... It turns out that is not happening.

ts-error-1

This will happily go through to your functions, which in our example will blow up (because the assertNever function is called).

This is not what is happening in ReasonRelay—it is conforming to the behavior I described ("if I don't know what this __typename is, assign it some arbitrary value").

relay-error-1

This is a bit hard to parse, because of the way BuckleScript compiles down the ReasonML code, but you can mentally translate the number 809179453 to the string of %other or the variant UnmappedUnionMember. So in our code that we already wrote:

[@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>;
};

We've already handled that case and thought about what we want the UI to do as a result. Another note—that UnmappedUnionMember case is a great place to put some logging or error reporting! Then you can catch that schema change even faster!

When we update our query and schema, the Reason compiler now gives us a helpful error about our missing case. This is very useful if you have lots of utility functions that work against this set of values.

reason-error-4

So it seems that I made some faulty assumptions about what's happening on the TypeScript side—it seems the types are not actually helping you against this case. I want to learn more about what is going on with that before I make any edits to the post. Good catch though!

So that's the first part of your question, but there's a second part that's important as well, and that's why don't we just depend on the default case instead? You are absolutely correct in that it will help us avoid the really bad stuff, like runtime crashes. It's much better to just render nothing in React than crash entirely (though even this is contextual).

The main reason you don't want to depend on the default fallthrough is that you lose a little bit of the power of exhaustive pattern matching. Not all of it, but some.

As an example, let's say we update our premium example to include 1 more tier of role SUPER_WHALES

enum USER_ROLE {
    FREE
    PREMIUM
    WHALE
    SUPER_WHALE
}

If I wrote my premiumColor function to depend on the default case

function premiumColor(role: USER_ROLE) {
  switch (role) {
    case "PREMIUM": {
      return "red";
    }
    case "WHALE": {
      return "blue";
    }
    case "FREE": {
      return "black";
    }
    default: {
      return "black";
    }
  }
}

When I regenerated my schema (that now includes the SUPER_WHALE role), my compiler wouldn't give me any hints about changing requirements. Unknown to me, SUPER_WHALE users now have normal text-colored posts. However if I wrote it with the exhaustive check in the default case:

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);
    }
  }
}

I'd get a compiler error when my schema picked up the new role.

ts-error-2

This would help me pick up that requirement faster as a developer. Now, I'd still have the same issue of SUPER_WHALE users having normal posts, but I have now structured in my code in a way that let's the compiler help me recognize those gaps.

The ReasonML doc sections on pattern matching has a tidbit that goes into the reasoning a bit more.

Do not abuse the fall-through _ case too much. This prevents the compiler from telling you that you've forgotten to cover a case (exhaustiveness check), which would be especially helpful after a refactoring where you add a new case to a variant. Try only using _ against infinite possibilities, e.g. string, int, etc.

So ultimately it's about helping the compiler help you!

Hope that help answers your question! I will look more into what's going on with Relay and its %other type on the TS side, because from the way I understand it now, it seems to just be a footgun.

Collapse
yaldram profile image
Arsalan Yaldram

Thanks a lot for such a detailed explanation. It is people like you who help us understand things better. Love from India Sir.

Collapse
idkjs profile image
Alain

You are a great writer. Please keep writing tutorials, the in your off time, write that book you want to write. Peace/Love to you, sir.

Collapse
anilanar profile image
Anıl Anar

Your main argument about fixing "Typescript"'s compiler safety by switching to ReasonML is not valid anymore.

Typescript has exhaustive switch expressions. Your premiumColor example, after adding whales, would return "string"|undefined and callers of that function would get errors when they try to do something with the string.

Or you can use : string as return type if you want to ensure exhaustive checks.

Forem Open with the Forem app