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 anint
And astring
to construct aProduct<int, string>
. - How many possible
Product<int, string>
can exist? There exist M possibleint
s, and N possiblestring
s, then, for each Mint
we can choose up to N differentstring
s to construct the product. Therefore, up to M * N differentProduct<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 tonull
) 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 anint
Or astring
to construct anEither<int, string>
. - How many possible
Either<int, string>
can exist? With M possibleint
s, and N possiblestring
s, we can construct anEither<int, string>
either from anint
, or from astring
. Therefore there are up to M + N possible ways to construct anEither<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 astruct
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 struct
s 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
andOption3
. Feel free to use more descriptive names in your code. - In order to disambiguate which
Option
we have, all theOptions
have a common field:type
, but the possible values of thetype
field are different for each option. - Each
Option
may have other fields, with these fields being shared (or not) with otherOption
s. When we share a field name, the otherOption
s may have the same type in the field, or not. - Finally, we combine the different
Option
s by using the pipe operator, thus declaring thatMyADT
is either anOption1
, or anOption2
, or anOption3
.
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 theOption
s, Typescript will complain that you can't dovalue.type
if the fieldtype
is not guaranteed to exist in all yourOption
s. - 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 avalue.type
that doesn't exist, Typescript will complain that the case doesn't exist. - If two or more of your
Option
s happen to share the samevalue.type
by mistake, you will be able to read the fields that exist only for bothOption
s. 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.type
s) 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!