Using TaskEither with fp-ts in your TypeScript code (Part 2)

Michael
7 min readFeb 19, 2023

--

(fp-ts series 1: Container)

  1. Option
  2. Either
  3. TaskEither (part 1) — Basic introduction
  4. 👇🏻 TaskEither (part 2) — Dependent Promises
  5. TaskEither (part 3) — Independent Promises
  6. TaskEither (part 4) — More examples

Quick Review on TaskEither

If you haven’t read my previous article TaskEither (part 1), please do so to gain more understanding on the basic of TaskEither, and examples are also provided to illustrate the concepts of TaskEither.

To recapitulate, for every action that produces a side effect which could lead to a failed state, such as an asynchronous call to the database or a third party endpoint, you should use TaskEither. For asynchronous action that never fails, use Task instead.

In JavaScript, Promise is used to represent an asynchronous action. So, to create a data that has two possible states (Error/Value) after an asynchronous action, we use TaskEither to wrap the Promise value with a function.

In this article, we will focus on solving Dependent Promises using Do Notation in TaskEither.

Do Notation in TaskEither

There are several useful functions in Do notation:

.Do, .apS, .apSW, .bind, .bindTo, .bindW and .let .

They are just syntactic sugar functions to manipulate the value inside the TaskEither container. These functions help you to avoid unnecessary unwrapping and wrapping the values between several TaskEither container. For example, you may do something like this without using Do notation:

import * as taskEither from 'fp-ts/TaskEither';
import * as either from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import axios from 'axios';

const fetchAPI = taskEither.tryCatchK(
(url: string) => axios.get(url),
(reason) => new Error(String(reason))
);

const main = async () => {
try {
const fetchUserUrl = 'https://api.github.com/users/github';
const userResultInTaskEither = pipe(
fetchUserUrl,
fetchAPI,
);

const userResultInEither = await userResultInTaskEither();
// Throw the error value
if (either.isLeft(userResultInEither)) {
throw userResultInEither;
};

const userResult = userResultInEither.right;

// Fetch token using the user id
const fetchTokenUrl = `https://fetchTokenExample/${userResult.data.id}`;
const tokenResultInTaskEither = pipe(
fetchTokenUrl,
fetchAPI,
);

const tokenResultInEither = await tokenResultInTaskEither();
// Throw the error value
if (either.isLeft(tokenResultInEither)) {
throw tokenResultInEither;
};

const tokenResult = tokenResultInEither.right;

// Use both the token and id to fetch orders
const fetchOrdersUrl = `https://fetchOrdersExample?token=${token}&id=${userId}`;
const ordersEitherResultInTaskEither = pipe(fetchOrdersUrl, fetchAPI);
const ordersResultInEither = await ordersEitherResultInTaskEither();

// Continue with the result ....
} catch (error) {
console.error(error);
}
}
main()

Notes: In some functional programming language like F#, the keyword bind is used for flattenMap (Monad). In fp-ts, the keyword chain is used for that instead.

When to use Do notation in TaskEither?

If the value in TaskEither is only consumed by the next function in your pipeline of functions, you do not need to use the functions in Do notation. You can just use .chain or .map or other helper functions to pipe the value to the next function. However, if you need to use the value in TaskEither more than once, not just the next function, you should use the functions in Do notation.

For instances, in the above example, we need the user id from the first fetch result to make another fetch after the second fetch. So, we could avoid unwrapping the values by using the functions in Do notation like this:

// File: taskEither-part-2.ts

import * as taskEither from 'fp-ts/TaskEither';
import * as either from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import axios from 'axios';

const fetchAPI = taskEither.tryCatchK(
(url: string) => axios.get(url),
(reason) => new Error(String(reason))
);

export const getAllResults = () => {
const fetchUserUrl = 'https://api.github.com/users/github';
const userResultInTaskEither = pipe(fetchUserUrl, fetchAPI);

const allResultInTaskEither = pipe(
taskEither.Do,
/*
The next function will receive a result in object type with key "userResult",
you can access the result of userResultInTaskEither by using the key "userResult"

Use .apS if you have a TaskEither
*/
taskEither.apS('userResult', userResultInTaskEither),
/*
An object type with key "userResult" is accessible in the argument.
The "tokenResult" will be added to the object
Use .bind if you have a function that return a TaskEither
*/
taskEither.bind('tokenResult', resultObj => {
const userId = resultObj.userResult.data.id;
// Fetch token using the user id from the resultObj
const fetchTokenUrl = `https://fetchTokenExample?id=${userId}`;
const tokenResultInTaskEither = pipe(fetchTokenUrl, fetchAPI);
return tokenResultInTaskEither;
}),
/*
Both "userResult" and "tokenResult" are accessible.
The "ordersResult" will be added to the object
*/
taskEither.bind('ordersResult', resultObj => {
const userId = resultObj.userResult.data.id;
const token = resultObj.tokenResult.data.token;
// Use both the token and id to fetch orders
const fetchOrdersUrl = `https://fetchOrdersExample?token=${token}&id=${userId}`;
const ordersEitherResultInTaskEither = pipe(fetchOrdersUrl, fetchAPI);
return ordersEitherResultInTaskEither;
})
);
return allResultInTaskEither;
};

const main = async () => {
try {
/*
All the "userResult", "tokenResult" and "ordersResult" are accessible.
*/
const allResultInEither = await getAllResults()();
pipe(
allResultInEither,
either.match(
error => {
throw error;
},
// Same as result => result
identity
)
);
} catch (error) {
console.error(error);
}
};

Default behaviour of Do notation in TaskEither

Here are the example codes to test the .getAllResults function above using jest:

// taskEither-part-2.test.ts

import axios from 'axios';
import {pipe} from 'fp-ts/function';
import * as either from 'fp-ts/Either';
// import the getAllResults function from the above example
import {getAllResults} from './taskeither-part-2';

const notOnRight = (result: unknown) => {
expect(result).toStrictEqual('Should be either.left');
};
const notOnLeft = (result: unknown) => {
expect(result).toStrictEqual('Should be either.right');
};

afterEach(() => {
jest.restoreAllMocks();
});

// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('test taskEither Do notation', () => {
// Mock data
const userDataMock = {
data: {
id: 'idMock',
},
};

const tokenDataMock = {
data: {
token: 'tokenMock',
},
};

const ordersDataMock = {
data: {
orders: [{id: '123', name: 'demo'}],
},
};
const userNotFoundErrorMock = 'user not found';
const tokenNotFoundErrorMock = 'token not found';
const ordersNotFoundErrorMock = 'orders not found';

describe('Case 1: All fetch are successful', () => {
it('should return the final result with user, token, orders data', async () => {
mockedAxios.get.mockResolvedValueOnce(userDataMock);
mockedAxios.get.mockResolvedValueOnce(tokenDataMock);
mockedAxios.get.mockResolvedValueOnce(ordersDataMock);
const resultE = await getAllResults()();
const expectedOutput = {
userResult: userDataMock,
tokenResult: tokenDataMock,
ordersResult: ordersDataMock,
};
const onRight = (result: unknown) => {
console.log('Case 1 result: ', result);
expect(result).toStrictEqual(expectedOutput);
};
expect(mockedAxios.get).toBeCalledTimes(3);
pipe(resultE, either.match(notOnLeft, onRight));
});
});
describe('Case 2: Fetch user failed', () => {
it('should return error message user not found', async () => {
mockedAxios.get.mockRejectedValueOnce(userNotFoundErrorMock);
mockedAxios.get.mockRejectedValueOnce(tokenNotFoundErrorMock);
mockedAxios.get.mockRejectedValueOnce(ordersNotFoundErrorMock);
const resultE = await getAllResults()();
const expectedOutput = new Error(userNotFoundErrorMock);
const onLeft = (error: unknown) => {
console.log('Case 2 error: ', error);
expect(error).toStrictEqual(expectedOutput);
};
expect(mockedAxios.get).toBeCalledTimes(1);
pipe(resultE, either.match(onLeft, notOnRight));
});
});
describe('Case 3: Fetch user successful but fetch token failed ', () => {
it('should return error message token not found', async () => {
mockedAxios.get.mockResolvedValueOnce(userDataMock);
mockedAxios.get.mockRejectedValueOnce(tokenNotFoundErrorMock);
mockedAxios.get.mockRejectedValueOnce(ordersNotFoundErrorMock);
const resultE = await getAllResults()();
const expectedOutput = new Error(tokenNotFoundErrorMock);
const onLeft = (error: unknown) => {
console.log('Case 3 error: ', error);
expect(error).toStrictEqual(expectedOutput);
};
expect(mockedAxios.get).toBeCalledTimes(2);
pipe(resultE, either.match(onLeft, notOnRight));
});
});
describe('Case 4: Fetch user and token successful but fetch orders failed ', () => {
it('should return error message orders not found', async () => {
const errorMock = 'orders not found';
mockedAxios.get.mockResolvedValueOnce(userDataMock);
mockedAxios.get.mockResolvedValueOnce(tokenDataMock);
mockedAxios.get.mockRejectedValueOnce(ordersNotFoundErrorMock);
const resultE = await getAllResults()();
const expectedOutput = new Error(errorMock);
const onLeft = (error: unknown) => {
console.log('Case 4 error: ', error);
expect(error).toStrictEqual(expectedOutput);
};
expect(mockedAxios.get).toBeCalledTimes(3);
pipe(resultE, either.match(onLeft, notOnRight));
});
});
});

The default behaviour for taskEither.bind will only trigger the function it receives to process the input data only if the input data is a TaskEither.right value.

For example, in case 2, when the fetching of user failed, axios.get is triggered only once for fetching the user. The fetching for token and orders are not triggered because userResultInTaskEither returns a TaskEither.left(Error('user not found')), the next two taskEither.bind for fetching token and orders will not trigger the fetch function when they receive a TaskEither.left value. As a result, you will get the Error message ‘user not found’ wrapped in TaskEither.left.

A simplified version of the implementation of taskEither.bind to illustrate the idea:

// Inside TaskEither
const bind = (keyname, fn) => (ma) => {
// if it receives a TaskEither.left value, just return it
if (taskEither.isLeft(ma)) {
return ma;
}

/*
If it receives a TaskEither.right value,
extract the wrapped value,
then trigger the function fn with the value,
*/
return ma()
// a is an Either
.then(a => {
if (either.isLeft(a)) {
return taskEither.left(a);
};
return taskEither.right({
...a.right,
[keyname]: fn(a.right)
})
})
}

Since the behaviour and implementation are related to another big topic — Monad, another article will explain that in depth.

For .apSW, .bindW , the W refers to Widen, which is useful when you have different TaskEither.left value in those asynchronous calls.

For .bindTo , you can use it to replace .Do at the beginning of Do notation when you already have a TaskEither value instead of a dummy TaskEither value.

For .let , it receives a function with type signature f: a: A -> b: B instead of f: a: A -> TaskEither<E,B> . It becomes handy when you need to transform the data synchronously in between the asynchronous calls inside Do notation.

What’s Next:

This article covers the scenario when the asynchronous calls are dependent on each other. In the next parts, we will cover the scenario of making independent asynchronous calls (working with an array of Promises like Promise.all, Promise.allSettled, etc..), and a more advance topic — Traversable, which helps to swap between another Container with TaskEither (such as turning an Array of TaskEither into a TaskEither of Array).

More about fp-ts.

Thanks for reading 🫡
Hope you enjoy this article and gain something from it. I wish it is less daunting now for you to learn Functional Programming :)

Ka Hin

--

--

Michael

Software Engineer🧑🏻‍💻Master in Computer Science🎓. Helping people to learn and use Functional Programming in JS. F# and JS developer🧑🏻‍💻 FP-TS ➡️