Bounded types in fp-ts

A Bounded<a> is used to name the upper and lower limits within the a type for your domain. So for example, in this example, I am going to use a Bounded<number> from the fp-ts library to represent the age of a person within the bounds of 18 & 100.

import * as B from "fp-ts/lib/Bounded";
import * as O from "fp-ts/lib/Ord";

const AgeBound: B.Bounded<number> = {
  bottom: 18,
  top: 100,
  equals: O.ordNumber.equals,
  compare: O.ordNumber.compare
};

However, this does not actually do the checking of the bounds for you. So here I am creating a function that takes in a generic A type and a Bounded<A> to return back an Option<A>.

import * as Opt from "fp-ts/lib/Option";

// Bounded<A> -> A -> Option<A>
const mkBounded: <A>(bounded: B.Bounded<A>) => (a: A) => Opt.Option<A> = (
  bounded
) => (a) => {
  if (
    bounded.compare(a, bounded.bottom) !== -1 &&
    bounded.compare(a, bounded.top) !== 1
  ) {
    return Opt.some(a);
  }
  return Opt.none;
};

At this point, we realise that we haven't actually created an Age type. We also don't want to use just a number as this will allow values outside our domain to typecheck so we will also use an intersection type to create a new-type. We will also export the type so that other files can import that type.

// Age as a newtype
export type Age = number & { readonly __tag: unique symbol };

We now have all the pieces to create a smart constructor to return back an Option. Note that this is exported to allow other files to be able to create the Age type outside of typecasting.

import { flow } from "fp-ts/lib/function";

// smart constructor to return a Option<Age>
// number -> Option<Age>
export const mkAge = flow(
  mkBounded(AgeBound),
  Opt.map((age) => age as Age)
);

There is a bit to unpack here. First mkBounded(AgeBound) will return back a number -> Option<number> which is the wrong type as we need a number -> Option<Age>, hence we use a Opt.map(age => age as Age) which has the type Option<number> -> Option<Age>. We then use flow to perform left to right function composition and we end up with the right number -> Option<Age> shape for our function.

console.log(mkAge(9)); // None
console.log(mkAge(18)); // Some<18>
console.log(mkAge(20)); // Some<20>
console.log(mkAge(100)); // Some<100>
console.log(mkAge(101)); // None

So what's the point of doing all this? The reason is that we now have a narrower type to represent our Age and can rely more on our typechecker to "make illegal states unrepresentable".

const x: Age = 2.1; //typescript error

Another situation where the idea of narrowing a type arises when we receive JSON at runtime and wish to convert the data inside the JSON into our own stricter types within our domain. Check in next time for an article around the same concepts using io-ts.

References

8