Type-safe function composition for Typescript
A micro-library for functional composition
In the absence of |>
(the pipe operator) it's useful to have a type-safe pipe function that can compose an a large (up to 64) number of unary functions. This minimal library contains a few different helper functions for this purpose.
NOTE
Versions <=2.x erroneously used the term compose
for left-to-right function composition. v3 is a major overhaul of this library and contains several breaking changes, both in the code, and in the meaning of compose
.
These are version >=3 documents. Please find v2.x documentation here
Requirements
This library makes use of leading/middle rest elements, introduced in Typescript version 4.2
Usage
Suppose we have the following unary functions:
const dinosaurify = (name:string) => `${name}-o-saurus`
const sayHello = (name:string) => `Hello, ${name}!`
We can compose these functions into a single function using the compose function:
const sayHelloToDinosaur = compose(sayHello, dinosaurify)
and call it
sayHelloToDinosaur("mike")
Note that with compose
, function composition occurs from right-to-left.
The pipe
function composes its parameters from left-to-right, so the equivalent pipe
version of the code above would be:
const sayHelloToDinosaur_withPipe = pipe(dinosaurify, sayHello)
The applyArgs
helper
Alternatively, we could have called the applyArgs
helper, which is useful for ensuring that type inference flows inutitively through the composed functions. This makes more sense later when we start using it with (apparently) untyped arrow functions.
applyArgs("mike").to(pipe(dinosaurify, sayHello))
or, less verbosely:
applyArgs("mike")(pipe(dinosaurify, sayHello))
pipeInto
function
This is shorthand to combine the applyArgs
helper with pipe
, reducing the amount of boilerplate. Using pipeInto
we can rewrite the above as:
pipeInto("mike", dinosaurify, sayHello)
In depth
Pipes work with unary-functions, using the return value of one function as the only parameter to the next function.
Defining higher-order unary map and filter functions
Say we create our own versions the Array map and filter functions to work over Iterable<T>
const toIterable = <T, TF extends () => IterableIterator<T>>(f: TF) => ({
[Symbol.iterator]: f
})
const _map = <T, TOut>(src: Iterable<T>, selector: (v: T, i: number) => TOut): Iterable<TOut> =>
toIterable(function*() {
let c = 0
for (const v of src) {
yield selector(v, c++)
}
})
const _filter = <T>(src: Iterable<T>, pred: (v: T, i: number) => boolean): Iterable<T> =>
toIterable(function*() {
let i = 0
for (const x of src) {
if (pred(x, i++)) {
yield x
}
}
})
Here, the _map
and _filter
are not unary functions so cannot be used in a pipe/compose.
Convert functions to unary with deferP0
We can use the provided deferP0
method to transform these functions into functions that return a unary function (that takes a single parameter that was the first parameter of the original source function)
So it turns functions of the form
(src: TSrc, b: B, c: C, d: D) => R
into functions of the form
(b: B, c: C, d: D) => (src: TSrc) => R
Functions that return unary functions
So, to make a composable map
function:
const map = deferP0(_map)
Here, we transform the _map
function with type
<T, TOut>(src: Iterable<T>, selector: (v: T, i: number) => TOut): Iterable<TOut>
into the generated map
function which has the type
<T, TOut>(selector: (v: T, i: number) => TOut) => (src: Iterable<T>): Iterable<TOut>
As can be seen, we end up with a function that generates a unary function.
We can do the same with _filter
const filter = deferP0(_filter)
Now the map
and filter
functions that we generated above return unary functions and can be used in a pipe/compose with type inference "flowing" through the composed functions.
Composing map
and filter
with pipe
Let's use them with the pipe
and the applyArgs
helper (so that type information propagates through all the function parameters)
const transformed =
applyArgs([1, 2, 3]).to(
pipe(
filter(x => x % 2 === 1),
map(x => x * 2)
)
)
When using "untyped" arrow functions, as above, by using the applyArgs
helper, we can see how types are propagated through the functions without needing to provide types for any function parameters. However, we might just want a re-useable function composed of multiple functions, so we can use compose(...unaryFuncs)
or pipe(...unaryFuncs)
on their own... but we'll need to supply type-information, usually in just one place, so that typescript can infer other types successfully:
const oddNumbersMultipliedByTwo =
pipe(
filter(x:number => x % 2 === 1),
map(x => x.toString()),
map(x => x + " " + x)
)
So oddNumbersMultipliedByTwoPipe
has the inferred type
(src: Iterable<number>) => Iterable<string>
and when we use it...
const r = oddMultipliedByTwo([1, 2, 3])
const arr = [...r]
arr
has type string[]
acknowledgements
Created using the wonderful https://github.com/alexjoverm/typescript-library-starter.