Javier Casas

A random walk through computer science

Improving your React with Typescript ADTs

There are many ideas from functional programming, and specially Haskell, that can be easily adapted to other programming languages. Today, we will see how we can adapt Algebraic Data Types (ADTs) to Typescript, and use them to construct better Redux actions. But first, what is a Redux Action? What is an Algebraic Data Type?

Redux Actions

One of the patterns that is being applied to current frontends to handle state is having that state in a single place. Instead of mutating that state, modifying fields and all that stuff, we create Actions. An Action is a piece of data that describes an event in the system. Essentially, Redux works by constructing a single state machine for the whole frontend (called a store), and transitioning this state machine, step by step, as indicated by the Actions received. After receiving an Action and transitioning a step, React proceeds to re-render the UI derived from that new state.

Algebraic Data Types

Algebraic Data Types is a way to describe new Types of Data based on an Algebra. You should already know about Data Types (number, string, undefined, object, Array...), but what is an Algebra in this context? An Algebra is a way to combine elements so that the combinations are also valid elements (an Algebra is way more than this, but that's a story for other day). In this case, Algebraic Data Types is a way of combining existing Data Types so that we create new Data Types, and these new Data Types are valid.

Usually, when we talk about Algebraic Data Types, we refer to the Algebra of Sums of Products. Let's explore this a bit more:

Algebraic Products

The Algebraic Products refer to the idea of requiring both types in order to construct a product type. So you want to combine int and string using Product, and you end up with a type that requires both an int and a string in order to construct a Product<int,string>. This is equivalent to the mathematical notation of a tuple (int, string), which may have many possible values, such as (3, "John"), (-5, "asdf") or (1100, "chicken nuggets"). But, as you can see from each tuple, you require both an int and a string to construct it, and you can extract back the int and the string.

There are many extra details related to Category Theory that we are going to ignore, but for now we can link to other simple mathematical insights for why this is it:

  • Products relate to the Boolean And, in the sense that we need an int And a string to construct a Product<int, string>.
  • How many possible Product<int, string> can exist? There exist M possible ints, and N possible strings, then, for each M int we can choose up to N different strings to construct the product. Therefore, up to M * N different Product<int, string> can exist. See the multiplication (also called product) in M * N? That's another reason why it's called a Product.
  • Products already exist in most programming languages. You can find them when you need all the values of something to exist in order to construct that something. For example, in C, you need all the values in a struct to construct it (although the C guys will happily go with uninitialized memory, but that's a story for another day). In Java, all the attributes of an object have to be initialized (even to null) before the object can officially exist. And, in JavaScript, {myString: "John", myInt: 3} declares an ad-hoc object, which is effectively a Product with nice names for the fields.

Algebraic Sums (also called co-Products from Category Theory)

If a Product requires both items to construct a Product, a Sum requires only one of the two. So, if you want to construct a Sum<int, string> you can do it with either an int or a string. If you have both an int and a string, you actually have two different ways to construct the Sum<int, string>. Because Sum has many different connotations, we often call the Sum an Either, so Either<int, string> reads as "either an int, or a string".

And a few more mathematical insights:

  • Sums relate to the Boolean Or, in the sense that we need an int Or a string to construct an Either<int, string>.
  • How many possible Either<int, string> can exist? With M possible ints, and N possible strings, we can construct an Either<int, string> either from an int, or from a string. Therefore there are up to M + N possible ways to construct an Either<int, string>. As we expected, the Sum is right there between the M and the N.
  • Sums also exist in many programming languages, but they have been historically neglected, at least in terms of direct language support. In C, these can be found when using union, which often are accompanied with a struct with a tag in order to discriminate which alternative is actually represented. In Java and other Class-based programming languages, you see sums in action when you create an abstract class and derive several subclasses. Any given object of the abstract class will be actually an object of one (and only one) of the subclasses. In JavaScript, sums are often implemented (like Redux actions) by constructing an object with a field that states which other fields may exist, following some convention. For example, you could do {type: "STRING", stringValue: "asdf"} or {type: "INT", intValue: 3}.

Sums of Products

Now that we know what is a Sum and what is a Product, let's look into Sums of Products. Sums of Products is as simple as we expect: Either<Product<a, b>, Product<c, d>>. We just put the products inside the sum, and we can start thinking in terms of it.

Sums of products is what you use when you use abstract classes with different subclasses, and each subclass having many attributes. Sums of products is what C programmers use when they declare a union where each of the structs has many fields. Sums of products allow us to think in terms of having "either this struct with this many fields, or this other struct with this other many fields".

More than two

Just in case you missed it, all the examples we put before are organized by combining two of something. To make a Product, you combine two types. To make a Sum, you combine two types. Mathematically speaking it's easy to extend this to arbitrary quantities by nesting. For example, a Product of three entities a, b and c can be done by just doing Product<a, Product<b, c>>. A Sum of three entities a, b, c can be done as Either<a, Either<b, c>>.

Fortunately, most programming languages allow us to use these products and sums of more than two items without having to nest stuff. You can create objects with more than two fields, and you can derive more than two classes from each abstract class, and the programming language doesn't complain.

Algebraic Data Types in Typescript

Now that we know what is an Algebraic Data Type, let's see how can we implement them in Typescript. Typescript implements a Structural Type System. This means a value is of type T if it has all the fields with the right values that the type T demands, regardless of class hierarchies, ancestries or other inheritance shenanigans. So in:

interface Foo {
  name: string;
}

interface Bar {
  name: string;
}

Any Foo we create is also automatically a Bar, and any Bar we create is also automatically a Foo, because both types have the same fields with the same possible values. I can pass a Bar to a Foo-accepting function, and everything is perfectly fine, even though there isn't any type of direct relation between Foo and Bar.

This Structural Type System allows us to seek simpler implementations of ADTs that don't require abstract classes and inheritance. In this case, we are going to use the style of Redux, by having a common field that allows us to disambiguate between different Products. But, before that, let's look for Typescript constructs that allow us to implement Sums and Products.

Products in Typescript

We already showed some products, and the one in Typescript is exactly the same as in JavaScript: the object with many fields. Typescript allows us to use interface if this fancies the Class-based crowd:

interface Foo {
  field1: string;
  field2: int;
  ...
  fieldn: int[];
}

Although, in my case, I prefer the type alias declaration:

type Foo = {
  field1: string;
  field2: int;
  ...
  fieldn: int[];
}

Any value that pretends to be of the specified Product type has to provide all the specified fields with right types. Just as the Product definition demands.

Sums in Typescript

Typescript offers sums in a nice way: the "union" (pipe) | operator. This is often shown in the Typescript documentation:

type Foo = int | null;

Which means Foo will be either an int or null.

Constant values in Typescript

A very useful and nice feature of Typescript is the ability to indicate one possible value. For example:

type AsdfOnly = "asdf";

Now, a value of AsdfOnly must be the string "asdf", and that's the only possible value. When we combine this with the pipe operator, you reach a sort of enumeration schema that offers great flexibility:

type Dice = 1 | 2 | 3 | 4 | 5 | 6;
type Stooge = "Larry" | "Moe" | "Curly";

Now a value of type Dice can only be an integer from 1 to 6, and a value of type Stooge can only be the string "Larry", or the string "Moe", or the string "Curly".

ADTs in Typescript

Now that we have all the pieces necessary, we can construct our model for Algebraic Data Types, following the style of Redux actions:

type Option1 = {
  type: "Option1";
  fooField: string;
}

type Option2 = {
  type: "Option2";
  barField: number;
  bazField: string;
}

type Option3 = {
  type: "Option3";
  fooField: number;
  bazField: string;
}

type MyADT = Option1 | Option2 | Option3

Let's go through the details one by one.

  • All the possible alternatives of the Sum have been enumerated as Option1, Option2 and Option3. Feel free to use more descriptive names in your code.
  • In order to disambiguate which Option we have, all the Options have a common field: type, but the possible values of the type field are different for each option.
  • Each Option may have other fields, with these fields being shared (or not) with other Options. When we share a field name, the other Options may have the same type in the field, or not.
  • Finally, we combine the different Options by using the pipe operator, thus declaring that MyADT is either an Option1, or an Option2, or an Option3.

This allows us to create values of type MyADT, but we also need to eliminate (destructure) such values. To do this, we can use case statements, and Typescript is very helpful here:

function doSomething(value: MyADT) {
  switch(value.type) {
    case "Option1":
      // value is of type Option1 here
      break;
    case "Option2":
      // value is of type Option2 here
      break;
    case "Option3":
      // value is of type Option3 here
      break;
  } 
}

When we organize it this way, it looks exactly how you would write Redux reducers. But, on top of that, it uses many of Typescript mechanisms to help you write correct code:

  • If you forget to add the field type to one of the Options, Typescript will complain that you can't do value.type if the field type is not guaranteed to exist in all your Options.
  • If you try to extract a field in one of the cases that doesn't exist for that case, Typescript will complain that the field just doesn't exist.
  • If you try to run a case for a value.type that doesn't exist, Typescript will complain that the case doesn't exist.
  • If two or more of your Options happen to share the same value.type by mistake, you will be able to read the fields that exist only for both Options. Thus Typescript should tell you something's wrong as soon as you try to read a field that doesn't exist in both cases.
  • In Redux, often the action names (the value.types) are created as constants somewhere. This is so all the actions can be located on the same file, and manually checked to prevent duplicated entries. But the mechanism above can be used to reduce the probability of duplicated action types, so these constants are no longer needed.
  • In Redux, you often create custom constructors for each action. This is to help you construct the actions with the right fields and contents. But with Typescript helping here, you probably can remove these constructors, as Typescript will ensure you have all the right fields in your constructor.

Finally, the Typescript manual offers something very helpful that most mainstream programming languages can't just offer: exhaustiveness checks. If we take the function above and add to it a bit of code:

function assertNever(x: never): never {
  throw new Error("The impossible has happened:" + x);
}

function doSomething(value: MyADT) {
  switch(value.type) {
    case "Option1":
      // value is of type Option1 here
      break;
    case "Option2":
      // value is of type Option2 here
      break;
    case "Option3":
      // value is of type Option3 here
      break;
    default:
      assertNever(value);
  } 
}

We now have an exhaustiveness check. In the future, when we add Option4 to MyADT, doSomething will fail to typecheck, because when calling assertNever(value), value has to be of type never (a type that has no possible values, hence "never" will be satisfied), but instead it's being called with a value of type Option4. This way, the typechecker will helpfully tell us where we forgot to account for Option4. This is one of the holy grials the Typesystems offer: ways to tell you what things have you broken after doing a change.

Finally, having Option1, Option2, and Option3 as separated types allows you to create functions that accept or return them, thus even constricting more the values these functions can operate, and thus improving the type safety of other software constructs, such as state machines.

Conclusion

So here it is: a way that I find among best practices for doing Typescript by taking ADTs from Functional Programming and applying to your day-to-day Frontend work. Feel free to start applying this in your codebase to improve your work and make the compiler do more for you!

Back to index
Copyright © 2018-2023 Javier Casas - All rights reserved.