- Published on
Refactor Any Curried Function with Only Two Basic Functions in Typescript (or Any Language)
Suppose you have a curried function that takes four arguments, for example:
const getPaymentAmount =
(price: number) =>
(quantity: number) =>
(discount: number) =>
(taxRate: number): number =>
(price * quantity - discount) * (1 + taxRate)
assert.deepStrictEqual(getPaymentAmount(10)(2)(5)(0.2), 18)
Often, a programmer would want to extend this getPaymentAmount
function without modifying it. For example, one may want to produce a new function:
- with a fixed discount: by moving the third argument
discount
to the top level, so one can partially apply it away; or - that accepts an argument of another type: by changing the type of the fourth argument
taxRate
fromnumber
tostring
.
A naive way would be to wrap around the function with a new function like so:
const getPaymentAmountWrapped =
(/* moved to top level */ discount: number) =>
(price: number) =>
(quantity: number) =>
(taxRate: /* changed from number to */ string) =>
getPaymentAmount(price)(quantity)(discount)(parseFloat(taxRate))
const getPaymentAmountNoDiscount = getPaymentAmountWrapped(0)
assert.deepStrictEqual(getPaymentAmountNoDiscount(10)(2)('0.2'), 24)
Though it works, the code is very redundant. The programmer has to repeat the same boilerplate wrapping everytime in similar situations.
Is there a way to refactor and/or extend any curried function in a generic, concise, and mechanical way?
The answer is yes.
You can find all the code in this post and play around with them at Typescript Playground.
compose
and flip
Let's quickly go over two basic and commonly used functions in functional programming languages such as Haskell.
compose
compose
combines two functions and outputs a new one.
If you have a function f :: b -> c
and a function g :: a -> b
, then you can get a new function h :: a -> c
by composing f
and g
.
In Haskell, it is written tersely as:
compose :: (b -> c) -> (a -> b) -> a -> c
compose f g = \x -> f (g x)
In Typescript, it looks a bit more verbose:
const compose =
<B, C>(f: (b: B) => C) =>
<A>(g: (a: A) => B) =>
(a: A): C =>
f(g(a))
const f = (x: number): string => x.toString()
const g = (b: boolean): number => (b ? 1 : 0)
const h: (b: boolean) => string = compose(f)(g)
assert.deepStrictEqual(h(true), '1')
flip
flip
swaps the first argument and the second argument of a function:
flip :: (a -> b -> c) -> b -> a -> c
flip f a b = f b a
In Typescript :-
const flip =
<A, B, C>(f: (a: A) => (b: B) => C) =>
(b: B) =>
(a: A) =>
f(a)(b)
const concat = (x: string) => (y: string) => x + y
assert.deepStrictEqual(flip(concat)('left')('right'), 'rightleft')
Swap Any Arguments at Any Level
Here comes the fun part.
If we want to swap the first and the second arguments of any function, we can apply flip
:-
const _1234 = (_1: string) => (_2: string) => (_3: string) => (_4: string) => _1 + _2 + _3 + _4
const _2134 = flip(_1234)
assert.deepStrictEqual(_2134('2')('1')('3')('4'), '1234')
But what if we want to swap the second _2
and the third _3
arguments? We will need to apply flip
to the result of the outermost function.
Semantic Editor Combinators
Semantic editor combinators ("SEC") is a term coined by Conal Elliot. It describes an alternative way to interpret, inter alia, compose
and flip
.
The compose
function, under the lens of SEC, has the effect of getting the result out of a function, when viewing compose
as unary instead of binary.
The best way to gain some intuitions is to see its effects.
First, let's rename compose
with a more intuitive label:
const result = compose
Then, to swap the second _2
and the third _3
arguments, we can compose flip
with result
:-
const flip2_3: Flip2_3 = result(flip)
const _1324 = flip2_3(_1234)
assert.deepStrictEqual(_1324('1')('3')('2')('4'), '1234')
Note: For readability, I omitted some typings from the code snippets. You can find them in the Typescript Playground link above.
Note that we need to spell out the type Flip2_3
for Typescript. This is because, unfortunately, Typescript's type system could not infer the type for us (vs Haskell, which does).
One naturally wonders if the third argument _3
and the fourth argument _4
could similarly be swapped. Indeed we can, we just need to apply result
one more time!
const flip3_4: Flip3_4 = result(result(flip))
const _1243 = flip3_4(_1234)
assert.deepStrictEqual(_1243('1')('2')('4')('3'), '1234')
In fact, you can go crazy and create functions that swap arguments at arbitrarily deep nested level:
const flip6_7: Flip6_7 = result(result(result(result(result(flip)))))
One can easily build an arsenal of these functions and export them as a library.
Application
Back to our original example: to move the argument discount
to the top, we can do so mechanically:
const _getPaymentAmount = flip(flip2_3(getPaymentAmount))
const _getPaymentAmountNoDiscount = _getPaymentAmount(0)
assert.deepStrictEqual(_getPaymentAmountNoDiscount(10)(2)(0.2), 24)
Alter any arguments at any level
Let's take a step further.
Instead of swapping arguments, what if we want to alter the arguments? In our example above, we want to convert taxRate
from string
to number
before feeding it to the getPaymentAmount
function.
First, note that if we apply flip
to compose
, the result is precompose
:-
precompose :: (a -> b) -> (b -> c) -> a -> c
precompose = flip compose
In Typescript :-
const precompose = flip(compose) as Precompose
As a dual with result
a.k.a compose
, we can think of precompose
as returning thearguments
const argumnts = precompose
Again we can create arbitrarily deep level as we want:
const argumnts2: Argumnts2 = (x) => result(argumnts(x))
const argumnts4: Argumnts4 = (x) => result(result(result(argumnts(x))))
const sum4 = (a: number) => (b: number) => (c: number) => (d: number) => a + b + c + d
const sum4WithString = argumnts4(parseInt)(sum4)
assert.deepStrictEqual(sum4WithString(1)(2)(3)('4'), 10)
Note that we changed the fourth argument of the sum4
function from number
to string
by just precomposing with parseInt
!
Application
Ok, back to our initial example. The application is mechanical :-
const _getPaymentAmountWithString = argumnts4(parseFloat)(getPaymentAmount)
assert.deepStrictEqual(_getPaymentAmountWithString(10)(2)(5)('0.2'), 18)
The fourth argument taxRate
now takes a string
instead of a number
, as promised.
Conclusion
We covered a lot of grounds.
With two basic functions compose
and flip
, we can generate a bunch of functions that swap and/or alter arguments at any level, for any curried function. In addition, this approach comes with the following benefits:
- Purely functional: this means that it comes with all the benefits of FP: referential transparency, testability, conciseness, etc.
- Generic & mechanical: as mentioned above, one could write a bunch of these functions and export them as a library. Then, it can be applied anywhere.
- Universal: finally, since the underlying mechanics is "just maths", it is applicable to any programming languages, not just Typescript.