Skip to main content
Marcel Krčah

Getting started with functional programming in Typescript

Published on , in , ,

I'm building a Typescript app, and I have reached a level of accidental complexity that I'm not able to remove. I believe a paradigm change is needed to cross the chasm. Namely, to introduce parts of functional programming (FP). Now, to start with functional programming in Typescript, I need two things: a library to use as a backbone and resources to learn from. Here's what I've found so far.

Libraries #

Based on what I've seen, the most popular libraries are Lodash, Ramda (Rambda, Rambdax) and fp-ts. It seems that Lodash and Ramda and Rambda were built for JS first, with Typescript types added later, while fp-ts was built for Typescript first. One can see that in the interfaces:

# rambdax
import { pipeAsync } from 'rambdax'
await pipeAsync(foo)('hello')
// rambdax's pipeAsync is curried,
// the compiler has no way to check if foo is compatible with 'hello'

# fp-ts
import { pipe } from 'fp-ts/function'
pipe('hello', foo)
// fp-ts's pipe is not curried,
// the compiler can check the compatibility of foo with 'hello'

Interestingly, fp-ts also contains Task that can replace Promise for better type safety. See Should I use fp-ts Task? post.

I'm intrigued by fp-ts.

Resources #

I've found these posts to be excellent resources for starting with FP:

And here are some more interesting articles/posts about fp-ts:

fp-ts example #

Here's a non-trivial example (source) of fp-ts that includes async execution against a REST API, validation, and error handling. Many things here decomplected, code is concise, extensible, and with static type checking. The tradeoff is the cost of learning a whole new functional API.

import axios, { AxiosResponse } from "axios"
import { flatten, map } from "fp-ts/lib/Array"
import * as TE from "fp-ts/lib/TaskEither"
import * as E from "fp-ts/lib/Either"
import * as T from "fp-ts/lib/Task"
import { sequenceT } from "fp-ts/lib/Apply"
import { pipe } from "fp-ts/lib/pipeable"
import { flow } from "fp-ts/lib/function"
import { failure } from "io-ts/lib/PathReporter"
import * as t from "io-ts"

//create a schema to load our user data into
const users = t.type({
  data: t.array(
    t.type({
      first_name: t.string,
    })
  ),
})
type Users = t.TypeOf<typeof users>

//schema to hold the deepest of answers
const answer = t.type({
  ans: t.number,
})

//Convert our api call to a TaskEither
const httpGet = (url: string) =>
  TE.tryCatch<Error, AxiosResponse>(
    () => axios.get(url),
    (reason) => new Error(String(reason))
  )

//function to decode an unknown into an A
const decodeWith = <A>(decoder: t.Decoder<unknown, A>) =>
  flow(
    decoder.decode,
    E.mapLeft((errors) => new Error(failure(errors).join("\n"))),
    TE.fromEither
  )

//takes a url and a decoder and gives you back an Either<Error, A>
const getFromUrl = <A>(url: string, codec: t.Decoder<unknown, A>) =>
  pipe(
    httpGet(url),
    TE.map((x) => x.data),
    TE.chain(decodeWith(codec))
  )

const getAnswer = pipe(TE.right({ ans: 42 }), TE.chain(decodeWith(answer)))

const apiUrl = (page: number) => `https://reqres.in/api/users?page=${page}`

const smashUsersTogether = (users1: Users, users2: Users) =>
  pipe(
    flatten([users1.data, users2.data]),
    map((item) => item.first_name)
  )

const runProgram = pipe(
  sequenceT(TE.taskEither)(
    getAnswer,
    getFromUrl(apiUrl(1), users),
    getFromUrl(apiUrl(2), users)
  ),
  TE.fold(
    (errors) => T.of(errors.message),
    ([ans, users1, users2]) =>
      T.of(
        smashUsersTogether(users1, users2).join(",") +
          `\nThe answer was ${ans.ans} for all of you`
      )
  )
)()

runProgram.then(console.log)

This blog is written by Marcel Krcah, an independent consultant for product-oriented software engineering. If you like what you read, sign up for my newsletter