DEV Community

TheWix
TheWix

Posted on

Basic Types are a Red Flag, Part 1

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;
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. Can firstName or lastName be empty strings? No
  2. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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-ts3. 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`
  );
Enter fullscreen mode Exit fullscreen mode

Now, when we try to create Customer:

const customer: Customer = {
  id: 12,
  firstName: "",
  lastName: "",
  loyaltyCard: 2,
};
Enter fullscreen mode Exit fullscreen mode

We get errors: Incorrect Customer Creation

But now I have a pile of Eithers.

const firstName = NonEmptyString("John");
const lastName = NonEmptyString("Doe");
const loyaltyCard = StringWithLength(12)("0123456789123456");
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

Here you can see what the object looks like:
Alt Text

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);
Enter fullscreen mode Exit fullscreen mode

After we have used sequenceS
Alt Text

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)
);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

  1. Domain Modeling Made Functional by Scott Wlaschin
  2. F# For Fun and Profit
  3. Getting started with fp-ts Series by fp-ts Creator Giulio Canti

  1. C# tried this with Code Contracts and Java with force exception handling, but both were a bit clunky to use, because often times we don't want to deal with errors until we are in the correct context. 

  2. Here is some information on Either 

  3. fp-ts documentation can be found here 

Top comments (2)

Collapse
 
oliverradini profile image
OliverRadini

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.

Collapse
 
thewix profile image
TheWix

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!