Intro to fp-ts
Derp
Posted on August 29, 2021
This article serves as an introduction to fp-ts coming from someone knowledgeable in JS. I was inspired to write this up to help some team members familiarise themselves with an existing codebase using fp-ts.
Hindley-Milner type signatures
Hindely-Milner type signatures represent the shape of a function. This is important because it will be one of the first things you look at to understand what a function does. So for example:
const add1 = (num:number) => num + 1
This will have the type signature of number -> number
as it will take in a number and return a number.
Now what about functions that take multiple arguments, for example a function add
that adds two numbers together? Generally, functional programmers prefer to functions to have one argument. So the type signature of add
will look like number -> number -> number
and the implementation will look like this:
const add = (num1: number) => (num2: number) => num1 + num2
Breaking this down, we don't have a function that takes in two numbers and adds them, we have a function that takes in a number and returns another function that takes in an number that finally adds them both together.
In the fp-ts documentation, we read the typescript signature to tell us what a function does. So for example the trimLeft function in the string package has a signature of
export declare const trimLeft: (s: string) => string
which tell us that it is a function that takes in a string and returns a string.
Higher kinded types
Similar to how higher order functions like map
require a function to be passed in, a higher kinded type is a type that requires another type to be passed in. For example,
let list: Array<string>;
Array is a higher kinded type that requires another type string
to be passed in. If we left it out, typescript will complain at you and ask you "an array of what?". An example of this in fp-ts is the flatten function from array.
export declare const flatten: <A>(mma: A[][]) => A[]
What this says is that the function requires another type A
and it flattens arrays of arrays of A into arrays of A.
However, arrays are not the only higher kinded types around. I like to think of higher kinded types as containers that help abstract away some concept. For example, arrays abstract away the concept of iteration and Options abstract away the concept of null.
Option
Options are a higher kinded type that abstract away the concept of null. Although it requires some understanding to use and some plumbing to get it all set up, my promise to you is that if you start using Options, your code will be more reliable and readable.
Options containers for optional values.
type Option<A> = None | Some<A>
At any one time, an option is either a None
representing null or Some<A>
representing some value of type A
.
If you have a function that returns an Option for example head
export declare const head: <A>(as: A[]) => Option<A>
By seeing that the function returns an Option, you know that by calling head, you may not get a value. However, by wrapping this concept up in an Option, you only need to deal with the null case when you unwrap it.
So how do you write your own function that returns an Option? If you are instantiating your own Options, you will need to look under the constructors part of the documentation. For example,
import { some, none } from "fp-ts/lib/Option";
const some1 = (s:string):Option<number> => s === 'one'? some(1) : none;
However to extract out the value inside an Option, you will need to use one of the destructor methods. For example, the fold
function in Option is a destructor.
export declare const fold: <A, B>(onNone: Lazy<B>, onSome: (a: A) => B) => (ma: Option<A>) => B
This type signature is a little complicated so let's break it down.
-
fold: <A, B>...
:This function has two type parameters A & B -
...(onNone:Lazy<B>,...
: This take in an onNone function that returns a value of typeB
-
..., onSome: (a: A) => B)...
: This also takes in an onSome function that takes in a value of typeA
and returns a value of typeB
-
... => (ma: Option<A>)...
: This expects an Option of typeA
to be passed in -
... => B
: After all arguments are passed in, this will return a value of type B.
Putting all this together, if we wanted to use our some1
function from earlier and print "success 1" if the value was "one" otherwise print "failed", it would look like this:
import { some, none, fold } from "fp-ts/lib/Option";
const some1 = (s:string):Option<number> => s === 'one'? some(1) : none;
const print = (opt:Option<number>):string => {
const onNone = () => "failed";
const onSome = (a:number) => `success ${a}`;
return fold(onNone, onSome)(opt);
}
console.log(print(some1("one")));
console.log(print(some1("not one")));
Now we know how to create an Option as well as extract out a value from an Option, however we are missing what in my opinion is the exciting part of Options which is the ability to transform them. Options are Functors which is a fancy way of saying that you can map them. In the documentation, you can see that Option has a Functor instance and a corresponding map instance operation.
What this means is that you can transform Options using regular functions. For example, if you wanted to write a function that adds one to a an Option<number>
it would look like so:
import { map, Option } from "fp-ts/lib/Option";
const add1 = (num: number) => num + 1;
const add1Option = (optNum:Option<number>):Option<number> => map(add1)(optNum);
Now we know how to create options, transform them via map functions and use destructors to extract out the value from them whilst referring to the documentation each step of the way.
Posted on August 29, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.