Practical Guide to Fp-ts: P3 — Task, Either, TaskEither

ryanleecode

Ryan Lee

Posted on August 20, 2020

Practical Guide to Fp-ts: P3 — Task, Either, TaskEither

Introduction

This is the third post in my series on learning fp-ts the practical way. In my last post, I introduced the Option type and the map, flatten, and chain operators.

This post will introduce two concepts in fp-ts: asynchronous tasks and error handling. Namely we will look at the Task, Either, and TaskEither types.

Task

Task-Promise

Every asynchronous operation in modern Typescript is done using a Promise object. A task is a function that returns a promise which is expected to never be rejected.

The type definition for task can be found below.

interface Task<A> {
  (): Promise<A>
}
Enter fullscreen mode Exit fullscreen mode

Another way to define task is using a function type definition.

type Task<A> = () => Promise<A>
Enter fullscreen mode Exit fullscreen mode

Tasks are expected to always succeed but can fail when an error occurs outside our expectations. In this case, the error is thrown and breaks the functional pipeline. An analogy to this is awaiting a Promise that throws an error without putting a try-catch-finally block in front. Test your assumptions before using Task.

Why use Tasks?

A Task is more than a glorified promise; it is also an expression of intent.

From a client perspective, when you are using a library, all asynchronous functions will have a type definition that returns a Promise<T>. Some of the functions might never fail but are asynchronous out of necessity. A Promise provides no indication about whether the function can fail. As such, in the imperative model, you are forced to handle these errors using a try-catch-finally block.

By using Task<T>, we relieve the burden on the client to handle errors that don't exist.

When can an operation "never fail"?

In the age of distributed computing, errors are the norm. Languages like Go and Rust embrace this model by forcing you to handle errors. To understand when an operation can never fail, we must first understand the most common ways a function can fail in the first place.

Functions commonly fail because of invalid preconditions. Take the function below, where the precondition is the length of id must be less than 36.

async function someTask(id: string) {
  if (id.length > 36) {
    throw new Error('id must have length greater than 36')
  }

  // do async work here
}
Enter fullscreen mode Exit fullscreen mode

If we knew the exact implementation of the function and we knew all errors stem from pre-condition failing, then we can assume the function will never fail if and only if we know the length of id is <= 36. As such, we can wrap the function into a Task and argue it never fails.

const id = 'abc'
const task: T.Task<void> = () => someTask(id)
Enter fullscreen mode Exit fullscreen mode

In general, we don't make these assumptions because we don't always know the implementation. It's also risky because the implementation can change without us knowing.

Handled Failures Can't Fail

A more real-world example is when you have an operation that can fail, but is handled by reducing both the success and failure outcomes into a single type. Since the error has been handled, the function, although asynchronous, will always return a Promise that is fulfilled.

Take this function that reduces both the success and failure outcomes into a boolean result.

async function boolTask(): Promise<boolean> {
  try {
    await asyncFunction()
    return true
  } catch (err) {
    return false
  }
}
Enter fullscreen mode Exit fullscreen mode

By definition, this function already implements the Task interface, but because the return type is a Promise, the result is still ambiguous to the client. We can remove the ambiguity by adjusting the syntax.

import { Task } from 'fp-ts/lib/Task'

const boolTask: Task<boolean> = async () => {
  try {
    await asyncFunction()
    return true
  } catch (err) {
    return false
  }
}
Enter fullscreen mode Exit fullscreen mode

Constructors

Any arbitrary value can become a Task by using the of operator to lift it into the Task world. This is equivalent to calling Promise.resolve.

import * as T from 'fp-ts/lib/Task'

const foo = 'asdf' // string
const bar = T.of(foo) // T.Task<string>

// Same As
const fdsa: T.Task<string> = () => Promise.resolve(foo)
Enter fullscreen mode Exit fullscreen mode

Either

An Either is a type that represents a synchronous operation that can succeed or fail. Much like Option, where it is Some or None, the Either type is either Right or Left. Right represents success and Left represents failure. It is analogous to the Result type in Rust.

As such, we get the following type definitions.

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

export interface Left<E> {
  readonly _tag: 'Left'
  readonly left: E
}

export interface Right<A> {
  readonly _tag: 'Right'
  readonly right: A
}
Enter fullscreen mode Exit fullscreen mode

The Either type is a union type of Left and Right. The _tag field is used as a discriminator to differentiate between Left and Right.

Why use Eithers

Eithers are essential for capturing error states in functional programming. We need the Eithers because we cannot break pipelines by throwing errors. Error states must either be handled or propagated up the call stack.

Eithers are also advantageous to their try-catch-finally counterparts because the error is always type-safe. When you use a catch block, the error is always of type unknown. This is inconvenient for you as the client because you need to use instanceof to narrow down the error type. Even worse is when you are forced to define your own custom type guards to do the same thing.

With Eithers, we know every possible error state based on the type signature. We can choose to handle them in a switch statement or continue to propagate up the call stack.

Eithers in Action

Let’s contrive an example where we are validating a password for security. The password must be at least 8 characters long and have at least 1 capital letter. If the password is valid, we will hash it using a very insecure md5 hash.

  1. Create 2 error classes to represent the two different error states. Join them together into a discriminated union.
// password.ts

export class MinLengthValidationError extends Error {
  public _tag: 'PasswordMinLengthValidationError'

  public minLength: number

  private constructor(minLength: number) {
    super('password fails to meet min length requirement: ${minLength}')
    this._tag = 'PasswordMinLengthValidationError'
    this.minLength = minLength
  }

  public static of(minLength: number): MinLengthValidationError {
    return new MinLengthValidationError(minLength)
  }
}

export class CapitalLetterMissingValidationError extends Error {
  public _tag: 'PasswordCapitalLetterMissingValidationError'

  private constructor() {
    super(`password is missing a capital letter`)
    this._tag = 'PasswordCapitalLetterMissingValidationError'
  }

  public static of(): CapitalLetterMissingValidationError {
    return new CapitalLetterMissingValidationError()
  }
}

export type PasswordValidationError =
  | MinLengthValidationError
  | CapitalLetterMissingValidationError
Enter fullscreen mode Exit fullscreen mode

Note we are using the Error class instead of declaring the error as a plain object because it comes with built-in stack-trace, which is necessary for debugging.

  1. Declare the Password Type
// password.ts

export interface Password {
  _tag: 'Password'
  value: string
  isHashed: boolean
}
Enter fullscreen mode Exit fullscreen mode
  1. Create the constructors for the Password Type
// password.ts

export function of(value: string): Password {
  return { _tag: 'Password', value, isHashed: false }
}

export function fromHashed(value: string): Password {
  return { _tag: 'Password', value, isHashed: true }
}
Enter fullscreen mode Exit fullscreen mode
  1. Validate the password using a Password specification.
// password.ts

export type PasswordSpecification = {
  minLength?: number
  capitalLetterRequired?: boolean
}

export function validate({
  minLength = 0,
  capitalLetterRequired = false,
}: PasswordSpecification = {}) {
  return (password: Password): E.Either<PasswordValidationError, Password> => {
    if (password.value.length < minLength) {
      return E.left(MinLengthValidationError.of(minLength))
    }

    if (capitalLetterRequired && !/[A-Z]/.test(password.value)) {
      return E.left(CapitalLetterMissingValidationError.of())
    }

    return E.right({ ...password, isValidated: true })
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how validate doesn't return a Password type directly, but a function that returns a Password type. We could have put the PasswordSpecification and Password as parameters to a single function, but the reason why we want to separate them is to make function chaining easier.

When we construct the Password using of or fromHashed, we want to directly pipe the result of that function, Password, into the next function. If our validate function were to take two parameters instead of one, it would break the whole flow. This methodology of splitting function parameters is called currying.

You may also notice we can only propagate a single error upwards. But what if multiple validations fail? It would be better to propagate all of them. We will learn about this in the next post.

  1. Define a hash function that takes a curried hash function.
// password.ts

export type HashFn = (value: string) => string

export function hash(hashFn: HashFn) {
  return (password: Password): Password => ({
    ...password,
    value: hashFn(password.value),
    isHashed: true,
  })
}
Enter fullscreen mode Exit fullscreen mode
  1. Create a pipeline
// index.ts

import { flow, identity, pipe } from 'fp-ts/lib/function'
import * as Password from './password'
import crypto from 'crypto'
import * as E from 'fp-ts/lib/Either'

const pipeline = flow(
  Password.of,
  Password.validate({ minLength: 8, capitalLetterRequired: true }),
  E.map(
    Password.hash((value) =>
      crypto.createHash('md5').update(value).digest('hex'),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode
  1. Test using an invalid password
console.log(pipe('pw123', pipeline))
Enter fullscreen mode Exit fullscreen mode

Produces the following:

{
  _tag: 'Left',
  left: Error: password fails to meet min length requirement: 8
      at new MinLengthValidationError (/tmp/either-demo/password.ts:9:5)
      at Function.MinLengthValidationError.of (/tmp/either-demo/password.ts:15:12)
      at /tmp/either-demo/password.ts:61:46
      at /tmp/either-demo/node_modules/fp-ts/lib/function.js:92:27
      at Object.pipe (/tmp/either-demo/node_modules/fp-ts/lib/function.js:190:20)
      at Object.<anonymous> (/tmp/either-demo/index.ts:16:13)
      at Module._compile (internal/modules/cjs/loader.js:1118:30)
      at Module.m._compile (/tmp/either-demo/node_modules/ts-node/src/index.ts:858:23)
      at Module._extensions..js (internal/modules/cjs/loader.js:1138:10)
      at Object.require.extensions.<computed> [as .ts] (/tmp/either-demo/node_modules/ts-node/src/index.ts:861:12) {
    _tag: 'PasswordMinLengthValidationError',
    minLength: 8
  }
}
Enter fullscreen mode Exit fullscreen mode

Due to the way Node prints errors, left doesn't look like a regular
typescript object. The underlying object looks like this.

{
  _tag: 'Left',
  left: {
    message: 'password fails to meet min length requirement: 8',
    stack: `Error: password fails to meet min length requirement: 8
      at new MinLengthValidationError (/tmp/either-demo/password.ts:9:5)
      at Function.MinLengthValidationError.of (/tmp/either-demo/password.ts:15:12)
      at /tmp/either-demo/password.ts:61:46
      at /tmp/either-demo/node_modules/fp-ts/lib/function.js:92:27
      at Object.pipe (/tmp/either-demo/node_modules/fp-ts/lib/function.js:190:20)
      at Object.<anonymous> (/tmp/either-demo/index.ts:16:13)
      at Module._compile (internal/modules/cjs/loader.js:1118:30)
      at Module.m._compile (/tmp/either-demo/node_modules/ts-node/src/index.ts:858:23)
      at Module._extensions..js (internal/modules/cjs/loader.js:1138:10)
      at Object.require.extensions.<computed> [as .ts] (/tmp/either-demo/node_modules/ts-node/src/index.ts:861:12)`
    _tag: 'PasswordMinLengthValidationError',
    minLength: 8
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Test using a valid password.
console.log(pipe('Password123', pipeline))
Enter fullscreen mode Exit fullscreen mode

Produces the following:

{
  _tag: 'Right',
  right: {
    _tag: 'Password',
    value: '42f749ade7f9e195bf475f37a44cafcb',
    isHashed: true,
    isValidated: true
  }
}
Enter fullscreen mode Exit fullscreen mode

Chaining Eithers

What if hash was an operation that could also fail and return an Either? We can chainW operator to chain both validate and hash into a single Either type. We'll use the base Error type to represent this error for simplicity's sake.

  1. Update the hash function to return an Either
export type HashFn = (value: string) => E.Either<Error, string>

export function hash(hashFn: HashFn) {
  return (password: Password): E.Either<Error, Password> =>
    pipe(
      hashFn(password.value),
      E.map((value) => ({
        ...password,
        value,
        isHashed: true,
      })),
    )
}
Enter fullscreen mode Exit fullscreen mode
  1. Update the pipeline using chainW
const pipeline = flow(
  Password.of,
  Password.validate({ minLength: 8, capitalLetterRequired: true }),
  E.chainW(
    Password.hash((value) =>
      E.right(crypto.createHash('md5').update(value).digest('hex')),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

The reason why we use chainW instead of chain because we want to widen the final type to include both errors from validate and hash. If you hover over pipeline to inspect the type, this is what you would get.

E.Either<
  MinLengthValidationError | CapitalLetterMissingValidationError | Error,
  Password
>
Enter fullscreen mode Exit fullscreen mode

But if we swap chainW with chain, we would only get the final error type in the chain.

E.Either<Error, Password.Password>
Enter fullscreen mode Exit fullscreen mode

But note, chain only works here because Error is a superclass of all 3 of our errors. If the left side of the generic to the function hash was not an Error, we would be forced to use chainW to cover the two Errors from validate.

You can run the source code here.

TaskEither

We know a Task is an asynchronous operation that can't fail. We also know an Either is a synchronous operation that can fail. Putting the two together, a TaskEither is an asynchronous operation that can fail.

Performing an HTTP request is a good demonstration of this functionality.

import axios from 'axios'
import { pipe } from 'fp-ts/lib/function'
import * as TE from 'fp-ts/lib/TaskEither'
;(async () => {
  const ok = await pipe(
    TE.tryCatch(
      () => axios.get('https://httpstat.us/200'),
      (reason) => new Error(`${reason}`),
    ),
    TE.map((resp) => resp.data),
  )()

  console.log(ok)
  // { _tag: 'Right', right: { code: 200, description: 'OK' } }
})()
Enter fullscreen mode Exit fullscreen mode

Here we are making an http request using axios to httpstat which returns status code 200. An error will not occur because the http response is 200 –⁠ Ok. The right side gets printed out.

We can do the same thing for a 500 status code.

type Resp = { code: number; description: string }
;(async () => {
  const result = await pipe(
    TE.tryCatch(
      () => axios.get('https://httpstat.us/500'),
      (reason) => new Error(`${reason}`),
    ),
    TE.map((resp) => resp.data),
  )()

  console.log(result)
  /**
   * {
   *   _tag: 'Left',
   *   left: Error: Error: Request failed with status code 500
   *       at /tmp/either-demo/taskeither.ts:19:19
   *       at /tmp/either-demo/node_modules/fp-ts/lib/TaskEither.js:94:85
   *       at processTicksAndRejections (internal/process/task_queues.js:97:5)
   * }
   */
})()
Enter fullscreen mode Exit fullscreen mode

Folding

If we're hitting the https://httpstat.us/200 endpoint, we can assume the operation will succeed and use the fold operator to convert the output into a Task.

import { absurd, constVoid, pipe, unsafeCoerce } from 'fp-ts/lib/function'

const result = pipe(
  TE.tryCatch(
    () => axios.get('https://httpstat.us/200'),
    () => constVoid() as never,
  ),
  TE.map((resp) => unsafeCoerce<unknown, Resp>(resp.data)),
  TE.fold(absurd, T.of),
) // Not executing the promise

// Result is of type:
// T.Task<Resp>
Enter fullscreen mode Exit fullscreen mode

Notice how I'm passing T.of directly instead of creating an anonymous function that calls T.of. i.e. (a) => T.of(a).

Absurd is a function that takes a never and casts it to a generic type A, which in this case is Resp.

Asynchronously Error Handling

Sometimes your error handling is also asynchronous and this is common if you're doing a 2 Phase Commit. A good example is when you are processing a database transaction.

import { pipe } from 'fp-ts/lib/function'
import * as TE from 'fp-ts/lib/TaskEither'

declare function begin(): Promise<void>
declare function commit(): Promise<void>
declare function rollback(): Promise<void>

const result = pipe(
  TE.tryCatch(
    () => begin(),
    (err) => new Error(`begin txn failed: ${err}`),
  ),
  TE.chain(() =>
    TE.tryCatch(
      () => commit(),
      (err) => new Error(`commit txn failed: ${err}`),
    ),
  ),
  TE.orElse((originalError) =>
    pipe(
      TE.tryCatch(
        () => rollback(),
        (err) => new Error(`rollback txn failed: ${err}`),
      ),
      TE.fold(TE.left, () => TE.left(originalError)),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

In this example, we try to rollback if the begin or commit operations fail and return the original error. If rollback also fails, we return the rollback error.

Conclusion

Error handling and asynchronous operations are core components of any application. By understanding Task, Either, and TaskEither, you now have the building blocks you need to develop a simple application.

If you found this post helpful, be sure to also follow me on Twitter.

💖 💪 🙅 🚩
ryanleecode
Ryan Lee

Posted on August 20, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related