17
Bounded types in io-ts
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
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
);
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
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>;
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
});
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"] */
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".
17