DEV Community

Cover image for Discriminating parts of a union type
Kajetan Świątek
Kajetan Świątek

Posted on

Discriminating parts of a union type

⚡️ Originally posted on my blog - kajetan.dev ⚡️


Within this mini-series, my goal is to convey to you my modest philosophy on how to improve both as a TypeScript developer and software engineer altogether. I decided to use a specific example for that: improving one's TypeScript code by trying out something else, not considered "mainstream", like Elm.

I started by sharing with you the idea of Elm decoders and how they can be used in TypeScript.

Now it's time to dive into other features of Elm and what can we learn from them.


Pattern matching - a million-dollar feature

If you've used TypeScript before, you most likely came across switch-case statements. It's a quite useful tool in a programmers toolkit that enables check value of an expression against a specific case.

Let's begin with a simple example and build our way up. Here's an example of a case expression in Elm:

parseRate : Int -> String
parseRate rate = 
  case rate of
    1 -> 
      "Horrible"
    2 -> 
      "Bad"
    3 -> 
      "Mediocre"
    4 -> 
      "Good"
    5 ->
      "Excellent"
    _ -> 
      "INVALID RATE"
Enter fullscreen mode Exit fullscreen mode

This is the most typical example I could think of but it lets you see for yourself how the syntax looks like. At the end, you can also see a default option that is handled by using a _ wildcard.

In most imperative languages that would be all we can squeeze from the case-statement. But Elm (and most of FP languages, in fact) can take it to another level. With it, you can match against even more complicated "patterns" like lists, tuples, records, etc.

We'll start building an example for this section now.

Let's assume, that our simple application loads a response from a server and renders it. The server can respond with either a successfully processed data (here, a simple integer value) or with validation errors. For that, we would like to define messages (actions that our application reacts to) and a model.

We can discriminate three different messages and we can do it with a union of types:

type Msg
  = Ok Int
  | Err (List String)
  | HttpError String
Enter fullscreen mode Exit fullscreen mode

Also, we can create a corresponding model:

type alias Model = 
  { data : Int
  , error : (List String)
  , httpError : String
  }
Enter fullscreen mode Exit fullscreen mode

The application can carry an Ok message, that indicates, that data has been fetched successfully. Another one is Err that can carry a payload as a List String (list of strings) - a list of validation errors that came as a result of our server-side computation. HttpError contains data about possible HTTP error.

Another thing is a Model that our view is based on. In an ideal world that's how our web applications should behave - it should be a function that takes a model and returns a view based on that model. That's what we shall assume here (for simplicity) - that the type Model represents both data that is carried by our application and also a view.

Next, we would like to have a function, that transforms our model based on a received message. For that we would like to react to all of three possible messages available. Surprisingly, we can use the good, old case expression for that:

updateModel : Msg -> Model -> Model
updateModel msg model =
  case msg of
    Ok data ->
      { model | data = data }
    Err es -> 
      { model | error = es }
    HttpError err -> 
      { model | httpError = err }
Enter fullscreen mode Exit fullscreen mode

It turns out, that case expressions in Elm can not only be used against primitive types but also against more complex ones. It is thanks to a mechanism known as the "pattern matching".

With that we discriminate elements of a union of types, that is our Msg, and return modified value of Model. On Ok we replace a data field with response payload, on Error we pass a list of error messages to the model. On HttpError we pass an error message.

Wait, that's not all! Would you like to see some cool trick? Let's just delete one case and see what happens:

-- MISSING PATTERNS -------------------------------------------- Jump To Problem

This `case` does not have branches for all possibilities:

27|>  case msg of
28|>    Ok data ->
29|>      { model | data = data }
30|>    Error es -> 
31|>      { model | error = es }

Missing possibilities include:

    HttpError _

I would have to crash if I saw one of those. Add branches for them!
Enter fullscreen mode Exit fullscreen mode

The project won't even compile! Compiler reminds us (very politely 😉) that there are some missing patterns that we didn't handle and we should change that. Elm is designed like that - with a bunch of little helpers built within a compiler that watch over us and our types. With those, we are able to avoid many troublesome bugs. More than that - it was always a crucial point for Elm to have as helpful and readable error messages as possible.

Unfortunately, JavaScript and TypeScript don't have a pattern matching feature. Even Elm's pattern matching is limited compared to Haskell, in favor of simplicity. But still - it is quite powerful.

Resources: Here you can read about pattern matching proposal for TS39 which is now at stage 1. Also, find out more about pattern matching in Haskell (this is a more advanced topic though).

Nevertheless, it cannot discourage us - let's get inspired and try to simulate those concepts in TypeScript! First idea you may think of (at least I did) is to use string literal types:

type Ok = 'Ok';
type Err = 'Err';
type HttpError = 'HttpError';

type Msg
  = Ok
  | Err
  | HttpError;

type Model = {
  data: int;
  errors: string[];
  httpError: string;
};

function updateModel(msg: Msg, model: Model): Model {
  switch (msg) {
    case 'Ok':
      // Well... What now?
    case 'Err':
      // Well... What now?
    case 'HttpError':
      // Well... What now?
  }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, we quickly realize, that it's impossible for a string literal type to carry some extra payload. One of the popular ways to discriminate types that hold some payload is to define them as JS objects with an additional field (commonly named type) that lets us discriminate object over the others:

type Ok = {
  type: 'Ok';
  data: number;
};

type Err = {
  type: 'Err';
  errors: string[];
};

type HttpError = {
  type: 'HttpError';
  message: string;
};

type Model = {
  data: number;
  errors: string[];
  httpError: string;
};

type Msg
  = Ok
  | Err
  | HttpError;
Enter fullscreen mode Exit fullscreen mode

With that we are able to successfully discriminate union of types with a case-statement:

function updateModel(msg: Msg, model: Model): Model {
  switch (msg.type) {
    case 'Ok':
      return { ...model, data: msg.data };
    case 'Err':
      return { ...model, errors: msg.errors };
    case 'HttpError':
      return { ...model, httpError: msg.message };
  }
}
Enter fullscreen mode Exit fullscreen mode

Done! TypeScript compiler even helps us by merging possible values of a type field from the union. When we hover over msg.type we get all possible values: (property) type: "Ok" | "Err" | "HttpError". This can help us when building the list of our cases.

But wait! What about a situation when we forget one of the possible cases like we did with the Elm example?

It turns out that when you omit some cases (and do not provide a default option) we get a compilation error of: Function lacks ending return statement and return type does not include 'undefined'. undefined? We don't want that in our function! This is a message for us that we missed a case and should add one.

Look out, though! There are some cases when that magic won't work and we will expose ourselves to unexpected bugs as a result. Above will work only if you have strictNullChecks flag turned on within your tsconfig.json (which I strongly encourage you to do!) and return type of your function is not undefined or void.

Fortunately, there is one interesting solution that is universal and works in all situations. It lets us leverage never type within our case expression:

function updateModel(msg: Msg, model: Model): Model {
  switch (msg.type) {
    case 'Ok':
      return { ...model, data: msg.data };
    case 'Err':
      return { ...model, errors: msg.errors };
    case 'HttpError':
      return { ...model, httpError: msg.message };
    default:
      assertUnreachable(msg);
  }
}

function assertUnreachable(x: never): never {
    throw new Error("Supposed to be unreachable...");
}
Enter fullscreen mode Exit fullscreen mode

never is a type that:

"(...) represents the type of values that never occur. For instance, never is the return type for a function expression or an arrow function expression that always throws an exception or one that never returns."
~ TypeScript docs

Here, when we remove one case, for example the HttpError one, we get a compilation error of Argument of type 'HttpError' is not assignable to parameter of type 'never'.. It's because it is impossible to assign a value to type never, which has no values at all.

There is only one more thing worth noting. Discrimination of a union of types in TypeScript works only within the top-level fields of an object!

Resources: This blog post describes interesting quirks of a TypeScript, that are all worth to know. Among them are constraints that come with discrimination of union type I mentioned above.


Conclusion

Pattern matching is a killer feature of such languages like Haskell and PureScript and most of the developers wouldn't imagine working without it. It exist also in Elm and it lets us, for example, discriminate actions that occur in our applications.

Resources: If you want to read about pattern matching in Elm, you can find more at this section of "Beginning Elm" tutorial.


This is all for this part! This series turns out to be longer than I anticipated when I started conceptualizing it. Nevertheless, I hope that it'll turn out useful to anyone that wants to learn more about TypeScript and/or consider trying out some other languages (with stronger type system).

Thanks for reading and see you next time!

Top comments (0)