DEV Community


Posted on


Abstracting away async with fp-ts

Business logic is usually quite simple. It is the plumbing that is hard. You can use ap to isolate your business logic from your plumbing

Consider the e-commerce store Widgi-tech:

Widgets for $3 each, buying 3 plus shipping

The business logic for calculating the price of an order is usually quite simple. In the above example, our widgets are being sold for $3 each, we are buying three of them and we add shipping on top of that. The complexity usually comes from mixing our business logic with concerns like error handling and asynchronicity.

To begin, lets write our getTotal function.

const getTotal = (price:number) => (qty:number) => (shipping:number) => price * qty + shipping;
Enter fullscreen mode Exit fullscreen mode

Job done right? Well no, at Widgi-tech, each one of those arguments is the responsibility of a different team. The pricing team sets prices today based on the prices based on how many of an item you order. Oh and sometimes prices aren't available for a given SKU yet. Qty is controlled by the inventory team which tracks how many widgets they have in their warehouse and you can't order more of a widget than they have and shipping costs depend on the location and total weight of the order. Oh and you can't send items to addresses outside their delivery zone.

The problem then starts to look a bit more like this:

import * as E from "fp-ts/Either"

type SKU = string;
type Address = string;

type getPrice = (sku:SKU, qty:number) => E.Either<string, number>
//just ignore that we are representing money with a number type for now

type getQty = (sku:SKU, qty:number) => E.Either<string, number>

type getShipping = (sku:SKU, qty:number, address: Address) => E.Either<string, number>
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

We have a function signature for getPrice that takes in the SKU and the qty of an item and returns back either an error message (string) or the per unit price of the item (number).

We have a function signature for getQty that takes in the SKU and the quantity of items and returns back either an error message when they can't fulfill the item or just the quantity back.

We have a function signature for getShipping that takes in the SKU, the quantity of items and the address. We either get back an error message when they can't send the items there or we get back the shipping cost.

Now we are at a conundrum, we started with our beautiful and simple getTotal function but now we have to care about error messages and the ugliness of error messages and reality. How do we make the getTotal function fit?

Square peg, round hole

The secret is that Either is an instance of an applicative functor. We can lift our simple getTotal function into an Either and then apply each of our arguments sequentially.

/** We lift getTotal into E.Either and apply each argument sequentially */
const price: E.Either<string, number> = pipe(
  E.ap(getPrice(sku, qty)),
  E.ap(getQty(sku, qty)),
  E.ap(getShipping(sku, qty, address))
Enter fullscreen mode Exit fullscreen mode

Let's take a closer look at how ap works by examining the type signature of different parts:

  • E.of(getTotal) This lifts our getTotal function into an Either and the type signature of this is E.Either<never, (price: number) => (qty: number) => (shipping: number) => number>
  • getPrice(sku, qty) This is an E.Either<string, number>
  • E.ap This is the Either ap function, in this case, it has a signature of <string, number>(fa: E.Either<string, number>) => <B>(fab: E.Either<string, (a: number) => B>) => E.Either<string, B>
  • pipe(E.of(getTotal), E.ap(getPrice(sku,qty)) This has a type signature of E.Either<string, (qty: number) => (shipping: number) => number> Notice the difference in this signature to the one of E.of(getTotal). We have partially applied our price in our getTotal function

Phew. That was a lot but let's take a step back and admire what we have just done. By using the power of applicative, we have isolated the business logic inside our getTotal function and deferred all the error handling to our Eithers.

So why is this isolation useful? Because business logic changes all the time. Let's say there's a promotion where orders get free shipping if the total order is over $100, what that look like?

const getTotal = (price:number) => (qty:number) => (shipping:number) => {
  const exShipping = price * qty;
  const shippingFee = exShipping < 100 ? shipping : 0;
  return exShipping + shippingFee;
Enter fullscreen mode Exit fullscreen mode

Notice the lack of any change to the plumbing code or any of the wiring. Think also about how easy it is to test this function in isolation.

Now at this point, you the reader will probably be thinking. Hang on Derp, our different departments would have APIs that are asynchronous. How do we handle that?

To answer that question, we'll need to reach for another applicative. Eithers encapsulate the concept of an error state, a Task encapsulates the concept of asynchronicity. The applicative we need is a TaskEither which encapsulates both.

So our changes are:

/** Note: all that chnages is going from E.Either to TE.TaskEither */
type GetPrice = (sku: SKU, qty: number) => TE.TaskEither<string, number>;
type GetQty = (sku: SKU, qty: number) => TE.TaskEither<string, number>;
type GetShipping = (sku: SKU, qty: number, address: Address) => TE.TaskEither<string, number>;
Enter fullscreen mode Exit fullscreen mode
/** We lift getTotal into TE.TaskEither and apply each argument sequentially */
const total: TE.TaskEither<string, number> = pipe(
  TE.ap(getPrice(sku, qty)),
  TE.ap(getQty(sku, qty)),
  TE.ap(getShipping(sku, qty, address))

const totalP = pipe(
    (err) => () => Promise.reject(err),
    (total) => () => Promise.resolve(total)

totalP().then((total) => console.log(`total is ${total}`), console.error);
Enter fullscreen mode Exit fullscreen mode

Notice our simple, beautiful getTotal function has remained completely unchanged. It cares not for the messy, asynchronous and error prone state of the world. We have successfully abstracted all of this away from it with the power of fp-ts and functional programming.

Top comments (0)

11 Tips That Make You a Better Typescript Programmer


1 Think in {Set}

Type is an everyday concept to programmers, but it’s surprisingly difficult to define it succinctly. I find it helpful to use Set as a conceptual model instead.

#2 Understand declared type and narrowed type

One extremely powerful typescript feature is automatic type narrowing based on control flow. This means a variable has two types associated with it at any specific point of code location: a declaration type and a narrowed type.

#3 Use discriminated union instead of optional fields


Read the whole post now!