What if I told you that every time I see string
, number
, bool
, []
, etc I consider it a red flag? You'd probably think I was crazy. After all, those are the building blocks by which we create our types! They are like the atoms that make up the real world.
This is true, but we can - and probably should - be more sparing in how we use these types. Let's look at an example:
type Customer = {
id: number;
firstName: string;
lastName: string;
loyaltyCard: string | null;
};
Right, so we have a simple Customer, and on the surface it looks like it has all the info we need, but by designing our types like this we break an important rule of software design: never let our application get into an invalid state.
Let's ask ourselves a few question:
- Can
firstName
orlastName
be empty strings? No - Does the loyalty card have a specific format? Let's say it is a 12-digit number
If you come from an object oriented background, like myself, you may be thinking, "This is easy! Make it a class and use a constructor!" Ok, let's do that!
class Customer {
firstName: string;
lastName: string;
loyaltyCard: number;
constructor(firstName: string, lastName: string, loyaltyCard: number) {
if (firstName.trim() === "") throw new Error("firstName cannot be empty!");
if (lastName.trim() === "") throw new Error("lastName cannot be empty!");
if (loyaltyCard.toString().length != 9)
throw new Error("loyaltyCard must be 9 digits!"); // Ignoring checking for non-numeric chars for the moment
this.firstName = firstName;
this.lastName = lastName;
this.loyaltyCard = loyaltyCard;
}
}
This is definitely a step in the right direction. By gathering our required parameters in a constructor we ensure the object will be properly hydrated, and we can do all of our validation before we allow our class to be instantiated. There are still a few issues with this approach, however. Firstly, because we are throwing exceptions we won't know if any code tries to instantiate a Customer with invalid values until runtime. This is because most popular OO languages don't typically give us a way to encode preconditions and their exceptions into the language, therefore we can't get compile-time checks around them1.
So, how do we do this?
Give your Types Self-Documenting Names
Let's go back to our original types, and encode some more specific types
type NonEmptyString = string;
type StringLength12 = string;
type Customer = {
id: number;
firstName: NonEmptyString;
lastName: NonEmptyString;
loyaltyCard: StringLength12 | null;
};
Ok, this approach looks good! Now our types have some information about their required values. But they are really just type aliases over the types we had before. So, compared to the purely OO approach we had before we've taken one step forward but two steps back. Is there a way to add a bit more safety around these new types?
Introducing Smart Constructors
Smart Constructors allow us to guarantee our types fulfill special requirements in order to construct them while also providing valuable compile-time checks. Let's introduce some smart constructors. To do this I need to use a popular type in functional programming called Either<L,R>
2. I am going to use the Either
from one of my favorite libraries fp-ts
3. Let's add some smart constructors:
export const isNonEmptyString = (s: string): s is NonEmptyString =>
s.trim() !== "";
export const NonEmptyString = E.fromPredicate(
isNonEmptyString,
(s) => "String cannot be empty"
);
export const isStringWithLength = (length: number) => (
s: string
): s is StringWithLength => s.length === length;
export const StringWithLength = (length: number) =>
E.fromPredicate(
isStringWithLength(length),
(s) => `'${s}' is not ${length} characters`
);
Now, when we try to create Customer
:
const customer: Customer = {
id: 12,
firstName: "",
lastName: "",
loyaltyCard: 2,
};
But now I have a pile of Eithers.
const firstName = NonEmptyString("John");
const lastName = NonEmptyString("Doe");
const loyaltyCard = StringWithLength(12)("0123456789123456");
How do I get the values to create a Customer
and do something with it? Well, we can use something called sequence
. Sequence
allows us to convert an array
, tuple
or struct
of Either
, or any other functor, to one Either
of all our values if they are all successful.
Let's start by putting all our values into an object:
const firstName = NonEmptyString("John");
const lastName = NonEmptyString("Doe");
const loyaltyCard = StringWithLength(12)("0123456789123456");
const pileOfEithers = { firstName, lastName, loyaltyCard };
Here you can see what the object looks like:
Let's use sequenceS
(the 'S' stands for struct. There is also sequenceT
for tuples and sequenceA
for arrays) to invert our type:
const seq = sequenceS(E.either);
const sequencedEithers = seq(pileOfEithers);
Now let's look at the whole thing and how we can chain our sequenced values into some dispatch functions.
import * as E from "fp-ts/Either";
import { sequenceS } from "fp-ts/lib/Apply";
import { pipe } from "fp-ts/lib/function";
// Simulate some dispatch functions to deal with our results
declare function dispatchError(error: string);
declare function dispatchCustomer(customer: Customer);
const firstName = NonEmptyString("John");
const lastName = NonEmptyString("Doe");
const loyaltyCard = StringWithLength(12)("0123456789123456");
const pileOfEithers = { firstName, lastName, loyaltyCard };
const seq = sequenceS(E.either);
const sequencedEithers = seq(pileOfEithers)
const Customer = (c) => ({
id: 12,
firstName,
lastName,
loyaltyCard,
});
pipe(
sequencedEithers
E.map((c) => ({ ...c, id: 12 })),
E.fold(dispatchError, dispatchCustomer)
);
The E.fold
on the last line is like reduce. It aggregates multiple values down to a single one. In our case we are converting the Either<string, Customer>
to the single type void
because our two dispatch function do not return a value. If the function did not return void they would still need to return the same type.
In a language like F# we would use 'Pattern Matching', like this:
match customerEither with
| Customer c -> dispatchCustomer(c)
| string error -> dispatchError(error)
That's it for now. In the next part of the series we will use our other types and look at how we can refine them even more!
Sources
- Domain Modeling Made Functional by Scott Wlaschin
- F# For Fun and Profit
- Getting started with fp-ts Series by fp-ts Creator Giulio Canti
Top comments (2)
Just started using Eithers in my typescript code, and they 've been great. This post opens my eyes to what you can really do with this pattern within typescript, though, I'm always amazed at how versatile and useful the type system is.
Thanks for the comment! Glad I could help. I am working on a few other posts at the moment that hopefully you will enjoy.
As for the TS type system, I can from traditional C-Style languages like C/C++/C# and Java, and I much prefer a system based on algebraic data types like Typescript. Also, if you haven't used the metaprogramming also available then I definitely would!