DEV Community

Derp
Derp

Posted on • Edited on

Bounded types in fp-ts

TLDR; show me the code!

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

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

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

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

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

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

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

Top comments (1)

Collapse
 
iquardt profile image
Iven Marquardt • Edited

I guess you've already recognized the natural number issue with TS. You can only enforce natural numbers or integers at runtime. I work on a type validator where you can write Nat(18) or Int(-18) instead of the number literal. Nat/Int are just subclasses of Number. In order to avoid the performance penalty I intend to erase the Nat(...)/Int(...) pattern during build process. However, frequently writing the constructor calls remains a bit tedious.