Versol Labs

Table of contents

Grasping function composition with TypeScript and fp-ts

I had the opportunity to work with fp-ts on a couple of production projects at Robin that involved transformations and validations on data. fp-ts is a TypeScript library for providing popular patterns from typed functional languages like Scala and Haskell. Functional composition helped streamline operations on data and resulted in code that was surprisingly easy to reason about.

The project culminated in a workshop I lead for the engineering department to share some of the team’s learnings and open a discussion around other projects at the company that might benefit from some of these patterns.

I wanted to share out some of those learnings here for the wider community and the result is this compact primer.


Composition

With functional composition, multiple functions are strung together in a sequence to create a larger function that has their combined behavior. Data can then be piped through that sequence; each function outputting the input to the next function.

import { pipe } from 'fp-ts/lib/function'

const result = pipe(
  'apple',
  getLength,
  double
) // => 10

fp-ts’s pipe method allows us to compose functions together. The first argument is the initial input and subsequent arguments are the functions that make up the composition.

The string 'apple' is fed to the getLength function. The return value of getLength is fed to the double function. And the return value of double is the return value of the pipe invocation.


We could parameterize this composition.

import { pipe } from 'fp-ts/lib/function'

const getDoubleLength = (myString: string): number => {
  return pipe(
    myString,
    getLength,
    double
  )
}

getDoubleLength('apple') // => 10

fp-ts’s flow function is another tool for function composition. flow returns a function that can be invoked later.

import { flow } from 'fp-ts/lib/function'

const getDoubleLength: (str: string) => number = flow(
  getLength,
  double
)

getDoubleLength('apple') // => 10

So far, our model of composition can be visualized by a set of railroad track pieces in a line.

function composition


Either

When composing functions there are times when a function may return an error, or may return one of two value types.

Using fp-ts’s Either we can specify a left return value and a right return value for a function.

Take a function that checks if a provided user has pets.

const checkHasPets = (user: User): Either<Error, User> => {
  return user.hasPets
    ? E.right(user)
    : E.left(generateError('user has no pets'))
} 

The function returns a Left holding an error or a Right holding the original user.

The Either type is the union of types Left and Right.

type Either<E, A> = Left<E> | Right<A>

We might want to compose together many functions that return Eithers.

function composition with eithers

If the result of one function is of type Right pass the value to the next function. If the return value is of type Left exit the compostion and return early.

However, these pieces don’t exactly fit.

Each function has type (a: A) => Either<E, B>. Each function returns a value wrapped in an Either but each function takes as input an unwrapped value.

This is where chain comes in…


Chain

Chain allows us to convert a one-to-two track function to a full two-track function.

chain function

Chain converts a function from type (a: A) => Either<E, B> to a function of type (ma: Either<E, A>) => Either<E, B>. Put another way chain converts a function that accepts an unwrapped value to a function that accepts a value wrapped in an Either.

Now that we have full two-track functions these can be composed properly.

chained function composition

An example in fp-ts could use composition to check if a given user has a paid account, is a senior member, and has a number of pets between 1 and 10. If so return the user. Otherwise return an error.

const validateUser = (user: User): E.Either<Error, User> => {
  return pipe(
    user,
    hasPaidAcct,
    E.chain(isSeniorMember),
    E.chain(hasCorrectPetAmnt)
  )
}

Here are the sequence of events.

  1. hasPaidAcct returns an Either.
  2. This Either is fed to the chained version of isSeniorMember, which can now accept an Either.
  3. If the Either is a Left, exit the composition with that Left.
  4. If the Either is a Right, the value is unwrapped and fed as input to isSeniorMember.

chained unwrapping

See the full code
import { pipe } from 'fp-ts/lib/function'
import * as E from 'fp-ts/lib/Either'

interface Pet {
  name: string
  age: number
}

interface User {
  pets: Array<Pet>
  hasPaidAcct: boolean
  startMembershipYear: number
  username: string
  id: number
}

const currentYear = 2022

const hasPaidAcct = (user: User): E.Either<Error, User> => {
  return user.hasPaidAcct
    ? E.right(user)
    : E.left(Error('user does not have paid account'))
}

const isSeniorMember = (user: User): E.Either<Error, User> => {
  const isSeniorMember = (currentYear - user.startMembershipYear) >= 10

  return isSeniorMember
    ? E.right(user)
    : E.left(Error('user is not senior member'))
}

const hasCorrectPetAmnt = (user: User): E.Either<Error, User> => {
  return user.pets.length && user.pets.length <= 10
    ? E.right(user)
    : E.left(Error('user has no pets'))
}

const validateUser = (user: User): E.Either<Error, User> => {
  return pipe(
    user,
    hasPaidAcct,
    E.chain(isSeniorMember),
    E.chain(hasCorrectPetAmnt)
  )
}

const user1 = {
  pets: [
    { name: 'Fido', age: 5 },
    { name: 'Felix', age: 1 },
  ],
  hasPaidAcct: true,
  startMembershipYear: 2000,
  username: 'animallover789',
  id: 1234
}

const result = validateUser(user1)

Map

map is a utility similar to chain. It allows us to convert a full one-track function to a full two-track function.

map function

map converts a function of type (a: A) => B to a function of type (fa: Either<E, A>) => Either<E, B>. It converts a function that receives and returns an unwrapped value to a function that recieves and returns a value wrapped in an Either.

This allows us to include a function of type (a: A) => B into a composition that deals in Eithers.

Take the function getPets. It takes a user and returns their list of pets. This function has no knowledge of Eithers and works only in unwrapped values.

const getPets = (user: User): Array<Pet> => user.pets

Now take our previous example that validate if a user has a paid account, is a senior member, and has the correct amount of pets. If they pass all criteria, return the user’s list of pets.

const getPetsOrErr = (user: User): E.Either<Error, Array<Pet>> => {
  return pipe(
    user,
    hasPaidAcct,
    E.chain(isSeniorMember),
    E.chain(hasCorrectPetAmnt),
    E.map(getPets)
  )
}

Here are the sequence of events.

  1. hasCorrectPetAmnt returns an Either.
  2. This Either is fed to the mapped version of getPets, which can now accept an Either.
  3. If the Either is a Left, exit the composition with that Left.
  4. If the Either is a Right, the value is unwrapped and fed as input to getPets.
  5. The result of getPets is then wrapped in a Right and returned from the composition.

chain and map composition


Further reading