Practical Guide to Fp-ts: P3 — Task, Either, TaskEither
Ryan Lee
Posted on August 20, 2020
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
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>
}
Another way to define task is using a function type definition.
type Task<A> = () => Promise<A>
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
}
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)
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
}
}
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
}
}
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)
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
}
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.
- 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
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.
- Declare the Password Type
// password.ts
export interface Password {
_tag: 'Password'
value: string
isHashed: boolean
}
- 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 }
}
- 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 })
}
}
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.
- 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,
})
}
- 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'),
),
),
)
- Test using an invalid password
console.log(pipe('pw123', pipeline))
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
}
}
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
}
}
- Test using a valid password.
console.log(pipe('Password123', pipeline))
Produces the following:
{
_tag: 'Right',
right: {
_tag: 'Password',
value: '42f749ade7f9e195bf475f37a44cafcb',
isHashed: true,
isValidated: true
}
}
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.
- 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,
})),
)
}
- 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')),
),
),
)
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
>
But if we swap chainW
with chain
, we would only get the final error type in the chain.
E.Either<Error, Password.Password>
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' } }
})()
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)
* }
*/
})()
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>
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)),
),
),
)
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.
Posted on August 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.