Skip to content

Using fp-ts for HTTP requests and validation

fp-ts is a great TypeScript library for functional programming. I haven't found that many real world usage examples out there, so I thought it might be fun to take a repository and try adding fp-ts there. More specifically, I wanted to learn more about making HTTP requests with fp-ts.

Through my work on unmock-js, I've seen quite a few libraries using third-party APIs. I therefore decided to use fp-ts in one of those, namely danger-js. Their GitLabAPI class is a reasonably simple wrapper around a GitLab SDK and it's also easy to detach from the rest of the codebase.

The full code can be found in this repository, which is a stripped down version of the danger-js code.

Before starting functionalizing, a few disclaimers 🤓 There's nothing wrong with the existing GitLabAPI class, it's all for practice! This post is also not meant to be about the general pros and cons of using functional programming versus some other programming style. I'm also not an expert in FP, so my way of rewriting the API requests in the library is not the "correct" way. Let me know in the comments if you think this made or did not make any sense, or take my repo and write one of the methods in your own way! I'm happy to see alternative and better implementations in comments.

Getting started

Let's get to it! For fetching the user profile for the user owning the GitLab API token, GitLabAPI has the following function:

class GitLabAPI {
    ...
    getUser = async (): Promise<GitLabUserProfile> => {
        debugLog("getUser");
        const user: GitLabUserProfile = (await this.api.Users.current()) as GitLabUserProfile;
        debugLog("getUser", user);
        return user;
    };
}

I've changed a few things here compared to the original GitLabAPI. First, I added typing to this.api with the following additions:

import { GitLab } from "gitlab";
...
type GitLab = InstanceType<GitLab>;

class GitLabAPI {
    this.api: GitLab;
    ...
}

The GitLab constructor is imported from here. In the original code, this.api was typed as any, so all type-checks were disabled.

Because of the added type-checking, this.api.users.current() returns a GetResponse type instead of any. Therefore, one needs an explicit type-cast ("as GitLabUserProfile") to make the return type correct.

Alright, here's my version of getUser in a more FP style:

// GitLabAPI.ts
import { Lazy } from "fp-ts/lib/function";
import { Either, left, right } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/pipeable";
import { TaskEither } from "fp-ts/lib/TaskEither";
import TE from "./TaskEitherUtils";

...

const logValue = TE.logValueWith(debugLog);

...

getUserFp = (): TaskEither<Error, GitLabUserProfile> => {
    // I/O action for fetching user from API
    const getUserThunk: Lazy<Promise<GetResponse>> = () => {
        debugLog("getUser");
        return this.api.Users.current();
    };

    // Validate user profile
    const validateUserProfile = (
        response: object
    ): Either<Error, GitLabUserProfile> => {
        // TODO Better validation
        return hasKey(response, "id")
        ? right(response as GitLabUserProfile)
        : left(Error("Invalid user profile"));
    };

    // Pipe computations
    return pipe(
        getUserThunk,
        TE.fromThunk,
        logValue("getUser"),
        TE.chainEither(validateUserProfile)
    );
};

Wow, that got longer! And the snippet did not even include the helpers in TaskEitherUtils.ts. Let's go through this step by step.

TaskEither 101

Let us start from the return type of our new function, TaskEither<Error, GitLabUserProfile>. In fp-ts, TaskEither is described as follows:

TaskEither<E, A> represents an asynchronous computation that either yields a value of type A or fails yielding an error of type E.

That sounds a lot like an API call! A call to an external API is asynchronous and it definitely can fail. Also note that TaskEither represents a computation, not the result of the computation. We can call getUserFp ten times without worrying about hitting the GitLab API every time.

We've made getUserFp a pure function: it does not have observable side effects such as logging to console, sending a HTTP request to GitLabAPI, throwing an error, or feeding a neighbor's dog. Pure functions also always return the same result for the same input: that makes them really nice to test and compose!

Let's dig deeper into TaskEither. It's defined as follows in fp-ts:

// fp-ts/lib/TaskEither.ts
export interface TaskEither<E, A> extends Task<Either<E, A>> {}

Ok, so it's an alias for Task<Either<E, A>>. Task is defined as follows:

// fp-ts/lib/Task.ts
export interface Task<A> {
  (): Promise<A>
}

With this definition, we can conclude that TaskEither<E, A> is an alias for () => Promise<Either<E, A>>: a thunk that when called launches an asynchronous computation. The result of the computation is wrapped in a Promise.

The return value of the Promise is Either, described as follows in fp-ts:

An instance of Either is either an instance of Left or Right. ... Convention dictates that Left is used for failure and Right is used for success.

So an Either can contain either a value representing error ("left") or a value representing success ("right").

Creating a TaskEither

How do we create a TaskEither for the API call? In getUserFp, the following is a computation launching the API request:

// GitLabAPI.ts
// I/O action for fetching user from API
const getUserThunk: Lazy<Promise<GetResponse>> = () => this.api.Users.current()

When called and awaited, it can throw. Instead, we want to wrap the computation in TaskEither so that it never throws, but failure is instead wrapped in the Either type resolved in the Promise. A computation like getUserThunk can be lifted to a TaskEither with the tryCatch function:

// fp-ts/lib/TaskEither.ts
export function tryCatch<E, A>(
  f: Lazy<Promise<A>>,
  onRejected: (reason: unknown) => E
): TaskEither<E, A> {
  return () =>
    f().then(
      (a) => E.right(a),
      (reason) => E.left(onRejected(reason))
    )
}

Here Lazy<A> is simply a synonym for a thunk returning an A:

// fp-ts/lib/function.ts
export interface Lazy<A> {
  (): A
}

tryCatch takes a thunk that returns a promise that may fail (like our getUserThunk) and returns a TaskEither containing a promise that never fails. If the original promise failed, the promise resolved from TaskEither will contain a "left", otherwise it's "right".

For lifting any thunk of type () => Promise<A> to a TaskEither<Error, A>, I've defined the following helper function in TaskEitherUtils.ts:

// TaskEitherUtils.ts
import { toError } from "fp-ts/lib/Either.ts"

function fromThunk<A>(thunk: Lazy<Promise<A>>): TaskEither<Error, A> {
  return tryCatch(thunk, toError)
}

You can see this being used as the first function in the pipe at the end of getUserFp.

Validating the response

Let us now take a look at validation. User profile is validated with the following function:

// GitLabAPI.ts
// Validate user profile
const validateUserProfile = (
  response: object
): Either<Error, GitLabUserProfile> => {
  // TODO Better validation
  return hasKey(response, "id")
    ? right(response as GitLabUserProfile)
    : left(Error("Invalid user profile"))
}

Here hasKey is defined as

// GitLabAPI.ts
function hasKey<K extends string>(o: {}, k: K): o is { [_ in K]: any } {
  return typeof o === "object" && k in o
}

I'm being very lazy here and just checking if the response body has an id field (which a valid GitLabUserProfile should have). If it does have it, the response is cast to a GitLabUserProfile object and returned wrapped in a right, signifying successful validation. If the validation fails, we return an instance of left with Error.

In the real world, we'd probably want to validate the response using, for example, io-ts, a great run-time type-checking library written by the author of fp-ts. To keep this post a bit shorter, I won't get into that here. The important point is that the validation step returns an Either that we want to include in our function's return value.

Putting it all together

Finally, everything's put together in the pipe at the end of the function:

// GitLabAPI.ts
return pipe(
  getUserThunk, // Lazy<Promise<GetResponse>>
  TE.fromThunk, // -> TaskEither<Error, GetResponse>
  logValue("getUser"), // -> TaskEither<Error, GetResponse>
  TE.chainEither(validateUserProfile) // -> TaskEither<Error, GitLabUserProfile>
)

Here's what the typing for pipe looks like:

// fp-ts/lib/pipeable.ts
export function pipe<A>(a: A): A
export function pipe<A, B>(a: A, ab: (a: A) => B): B
export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C
// ...and so on

We can see that pipe takes a value of type A as the first argument and pipes it through the functions that follow. The type returned from the pipe is the return type of the last function argument.

The first function in our pipe is the TE.fromThunk that we saw above, converting the lazy promise to an TaskEither. The following is logValue("getUser"), where logValue is defined as follows:

// GitLabAPI.ts
const debugLog = debug("GitLabAPI")
const logValue = TE.logValueWith(debugLog)

Here debugLog is a function used for logging and TE.logValueWith is defined in TaskEitherUtils.ts as follows:

// TaskEitherUtils.ts
import { map } from "fp-ts/lib/TaskEither"

function logValueWith(logger: (firstArg: any, ...args: any[]) => void) {
  return <A,>(logString: String) =>
    map((obj: A) => {
      logger(logString, obj)
      return obj
    })
}

This function takes a logger and returns a function. The returned function takes a log string (like "getUser") and returns a map that can be applied to a TaskEither instance. map applies a function to the value inside TaskEither (if it's an instance of right). In our case, the value is the response returned by the API. After logging the value with logger(logString, obj), the value is returned so that it's available for the next function of the pipe.

You might be asking if going through all this to log a single line is worth the trouble, and that's a good question. We'll talk about that at the end of this post.

Ok, final piece of the puzzle is the TE.chainEither(validateUserProfile), which is the last function in our pipe. TE.chainEither is defined as follows:

// TaskEitherUtils.ts
import { flow } from "fp-ts/lib/function"
import { chain, TaskEither, fromEither } from "fp-ts/lib/TaskEither"
import { Either } from "fp-ts/lib/Either"

function chainEither<A, B>(
  f: (a: A) => Either<Error, B>
): (ma: TaskEither<Error, A>) => TaskEither<Error, B> {
  return chain(flow(f, fromEither))
}

Let's first look at the type. It takes a function of the type f: (a: A) => Either<Error, B>. This is the type of our validation function, with A and B replaced by GetResponse and GitLabUserProfile, respectively. chainEither returns a function of type (ma: TaskEither<Error, A>) => TaskEither<Error, B>. Replacing B with GitLabUserProfile, we see that this function fits as last part of the pipe.

Ok, the types make sense, so let's look at the implementation. The chain is a function that's used for mapping the value wrapped inside TaskEither with a function that returns a TaskEither. This can be a bit confusing, so I'll try to clarify the types:

const getResponseTe: TaskEither<Error, GetResponse> = ...  // This is our TaskEither
const validateUserProfile = (getResponseTe: GetResponse) => TaskEither<Error, GitLabUserProfile> = ...  // This is the validation function we want apply to `a: GetResponse`
chain(validateUserProfile)  // Function with signature: `TaskEither<Error, GetResponse>` => `TaskEither<Error, GitLabUserProfile>`
chain(validateUserProfile)(getResponseTe)  // TaskEither<Error, GitLabUserProfile>

This would be perfect for us, if the validation function returned a TaskEither. But I cheated: it returns an Either. So it was all for nothing!

JK, it wasn't! TaskEither.ts has a function called fromEither for creating an Either from a TaskEither. So if we apply that to the result of validation, we'll have composed a function of type GetResponse => TaskEither<Error, GitLabUserProfile>. And then we use chain to get a function of type TaskEither<Error, GetResponse> to TaskEither<Error, GitLabUserProfile>.

In fp-ts, functions can be composed with flow from fp-ts/lib/function.ts. Here is its type definition:

// fp-ts/lib/function.ts
export function flow<A extends Array<unknown>, B>(
  ab: (...a: A) => B
): (...a: A) => B
export function flow<A extends Array<unknown>, B, C>(
  ab: (...a: A) => B,
  bc: (b: B) => C
): (...a: A) => C
// ...and so on

In our chainEither, flow(f, fromEither) is a function composition from left to right: it's a function that first applies f (the validation) and then applies fromEither (to make the type compatible with chain).

That concludes our rewrite of getUser!

Why's it so long?

Clearly the method getUser got a lot longer in my rewrite. However, there are a few points one can make here in the FP style's defence.

First, we wrote a lot of helper functions. Without the helpers, we would have been able to cut the number of lines to following:

// GitLabAPI.ts
// getUserFpShort
    return pipe(
      () => {
        debugLog("getUser");
        return this.api.Users.current();
      },
      thunk => tryCatch(thunk, toError),
      map((obj: GetResponse) => {
        debugLog("getUser", obj);
        return obj;
      }),
      chain(
        flow(
          validateUserProfile,
          fromEither
        )
      )

That's not so much longer than the original function. However, we have lost reusability: re-writing the rest of the functions in FP style requires duplicating code. With the helper functions, it becomes rather trivial.

Second, the function is now pure and the return type is an abstract data type with a rich set of combinators. One can therefore easily compose longer pipelines from smaller functions using all the combinators available for TaskEither.

Third, we added an explicit validation step that was done only implicitly (as GitLabUserProfile) in the original function, so that also added a few lines.

Was it worth it?

Finally, one can ponder whether it's worth the effort to refactor the methods in GitLabAPI to be pure functions returning TaskEithers. I think that depends on how the GitLabAPI class is used. If the user of the class immediately throws away TaskEither by awaiting the promise and checking for error, we have only added unnecessary complexity and replaced a try-catch block with isLeft check.

On the other hand, if the user of the class takes the TaskEither and composes it with other tasks to create bigger stories, I think the more FP-like solution might be in place. One could also take a bit softer stand on purity and allow functions to log to console at will.

This was my first post in dev.to! I hope someone finds it useful: I learned a lot of fp-ts writing it and also got lot of satisfaction for the successful type-checks. Thank you for reading and sharing your comments!

Resources