DEV Community

loading...

Bounded types in io-ts

Derp
Javascript developer who wants to get deeper into functional programming. New articles here, old articles at https://dogedev.blogspot.com/
・3 min read

In the last article, we went through how we created an Age type to represent an age between 18 and 100. We also touched upon how in using a stricter type, we are closer to the goal to "make illegal states unrepresentable". However, what happens when we still want to use our stricter types but the data comes over untyped over an API? Furthermore, our Age type might only be one small component of larger User type so how do we compose our Age type into our larger User type?

One solution is to use io-ts, a runtime type validation library, also by Giulio Canti. At the core of io-ts there is the idea of a Codec of type Type<A,O,I> where A is our static type, O is our output type and I is our input type. It also provides codecs for a lot of the primitives such as string, boolean, number and more.

Codecs have a decode method which has the type signature of I -> Either E A which loosely translated means that given the input type I, you either get an Error type E or our static type A. For example, if we use the number codec and pass in a string, we will get a Left back representing an error. If we pass in a number however, we will get back a Right back representing a successful decoding.

console.log(t.number.decode('3')); // Left error
console.log(t.number.decode(3)); // Right 3
Enter fullscreen mode Exit fullscreen mode

However our current problem is that we want a stricter codec than just a number as our Age type is only valid for a bounded range of the js number type. Here we rely on io-ts once again through t.brand to create a codec for our branded type. Branded types are the io-ts version of new-types also using unique symbol underneath the hood.

interface AgeBrand {
  readonly Age: unique symbol;
}

const AgeBrandC = t.brand(
  t.number, // codec
  (n: number): n is t.Branded<number, AgeBrand> => n >= 18 && n <= 100, 
// refinement of the number type
  "AgeC" // name of this codec
);
Enter fullscreen mode Exit fullscreen mode

Let's unpack this. We first create our branded type AgeBrand similar to how we created Age as a new-type in the fp-ts example.

Next t.brand expects a base Codec to build on top of. Since Age is a bounded type within numbers, we use the t.number codec. As the second argument to t.brand, we supply the predicate or refinement function that asks given a number, tell me how to figure out if it is an Age type or not. Finally we optionally provide the codec for our branded type a name.

After all this, we now have a codec for our Age type and we can use it to decode things to either get a Left or Right result.

console.log(AgeBrandC.decode(13)); //Left error
console.log(AgeBrandC.decode(18)); //Right result
Enter fullscreen mode Exit fullscreen mode

But wait, where is our static Age type in this new io-ts world? io-ts provides a handy Typeof operator to extract out the static type from a codec.

type Age = t.TypeOf<typeof AgeBrandC>;
Enter fullscreen mode Exit fullscreen mode

At this point, we have now successfully recovered all of the functionality provided in our fp-ts where instead of mkAge we use decode to determine whether a number is a valid Age or not. However we are yet to tackle the second part of our problem. Namely, what if the Age component is only a larger part of a User component? Here we use the t.type function to compose our ageC codec into a larger codec.

const UserC = t.type({
  firstName: t.string,
  lastName: t.string,
  age: AgeBrandC
});
Enter fullscreen mode Exit fullscreen mode

Here we define our user codec to have three fields, a firstName, a lastName and an age. For the firstName and lastName, we use the primative t.string codec to check that they are strings. However for the age field, we use our AgeBrandC codec to ensure that it is a number between 18 and 100.

However, when trying to decode, it is not very readable to decipher Left or Right results. We can instead use the PathReporter error reporter to get a human readable error.

console.log(
  PathReporter.report(
    UserC.decode({
      firstName: "foo",
      lastName: "foo",
      age: 13
    })
  )
);
/*["Invalid value undefined supplied to : 
{ firstName: string, lastName: string, age: Age }/lastName: string",
"Invalid value 13 supplied to :
{ firstName: string, lastName: string, age: Age }/age: Age"] */
Enter fullscreen mode Exit fullscreen mode

In conclusion, we can use io-ts to strictly type our untyped data at the edge of our application so that within the core business logic, we can use our stricter types and hopefully proceed a little further along our journey to "make illegal states unrepresentable".

Discussion (0)