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:
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.
typescript
const getTotal = (price:number) => (qty:number) => (shipping:number) => price * qty + shipping;
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>
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?
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.
typescript
/** We lift getTotal into E.Either and apply each argument sequentially */
const price: E.Either<string, number> = pipe(
E.of(getTotal),
E.ap(getPrice(sku, qty)),
E.ap(getQty(sku, qty)),
E.ap(getShipping(sku, qty, address))
);
https://codesandbox.io/s/practical-lewin-yqsthc?file=/src/index.ts
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 isE.Either<never, (price: number) => (qty: number) => (shipping: number) => number>
-
getPrice(sku, qty)
This is anE.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 ofE.Either<string, (qty: number) => (shipping: number) => number>
Notice the difference in this signature to the one ofE.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 Either
s.
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?
typescript
const getTotal = (price:number) => (qty:number) => (shipping:number) => {
const exShipping = price * qty;
const shippingFee = exShipping < 100 ? shipping : 0;
return exShipping + shippingFee;
}
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. Either
s 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>;
/** We lift getTotal into TE.TaskEither and apply each argument sequentially */
const total: TE.TaskEither<string, number> = pipe(
TE.of(getTotal),
TE.ap(getPrice(sku, qty)),
TE.ap(getQty(sku, qty)),
TE.ap(getShipping(sku, qty, address))
);
const totalP = pipe(
total,
TE.fold(
(err) => () => Promise.reject(err),
(total) => () => Promise.resolve(total)
)
);
totalP().then((total) => console.log(`total is ${total}`), console.error);
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.
https://codesandbox.io/s/dreamy-hofstadter-4vuzou?file=/src/index.ts
Top comments (1)
Good one @derp, thanks