It turns out, the recursive Pipe
(and Compose
) types offer key advantages over the traditional method of using parameter overloading. The key advantages are:
- Preserve variable names
- Better tolerance of generics in function signature
- Variadic entry function
- Can theoretically compose unlimited functions
In this article, we'll explore how such a Pipe
and Compose
type are built.
If you want to follow along with the full source at hand, see this repo:
https://github.com/babakness/pipe-and-compose-types
Introduction
My journey for this project starts as a challenge to create recursive Pipe
and Compose
types, without relying on overloads that rename the parameters. To review an example of how to build this with overloads follow one of these links to the excellent library fp-ts
by @gcanti
Pipe:
https://github.com/gcanti/fp-ts/blob/master/src/function.ts#L222
Compose:
https://github.com/gcanti/fp-ts/blob/master/src/function.ts#L160
I've used this strategy in my own projects. Parameter names are lost and are replaced, in this case, with alphabetic names like a
and b
.
TypeScript shares its ecosystem with JavaScript. Because JavaScript lacks types, parameter names can be especially helpful in guiding usage.
Let's look at how these types work a bit closer.
Make a Recursive Pipe Type
First, we're going to need some helper types. These are going to extract information from a generic function:
export type ExtractFunctionArguments<Fn> = Fn extends ( ...args: infer P ) => any ? P : never
export type ExtractFunctionReturnValue<Fn> = Fn extends ( ...args: any[] ) => infer P ? P : never
Next two more helpers, a simple type to allow us to branch different types predicated on the test type and a short hand for express any function.
type BooleanSwitch<Test, T = true, F = false> = Test extends true ? T : F
export type AnyFunction = ( ...args: any[] ) => any
This next type is really esoteric and ad-hoc:
type Arbitrary = 'It was 1554792354 seconds since Jan 01, 1970 when I wrote this'
type IsAny<O, T = true, F = false> = Arbitrary extends O
? any extends O
? T
: F
: F
Essentially, this type detects any
and unknown
. It gets confused on {}
. At any rate, it isn't exported and intended for internal use.
With those helpers in place, here is the type Pipe:
type Pipe<Fns extends any[], IsPipe = true, PreviousFunction = void, InitalParams extends any[] = any[], ReturnType = any> = {
'next': ( ( ..._: Fns ) => any ) extends ( ( _: infer First, ..._1: infer Next ) => any )
? PreviousFunction extends void
? Pipe<Next, IsPipe, First, ExtractFunctionArguments<First>, ExtractFunctionReturnValue<First> >
: ReturnType extends ExtractFunctionArguments<First>[0]
? Pipe<Next, IsPipe, First, InitalParams, ExtractFunctionReturnValue<First> >
: IsAny<ReturnType> extends true
? Pipe<Next, IsPipe, First, InitalParams, ExtractFunctionReturnValue<First> >
: {
ERROR: ['Return type ', ReturnType , 'does comply with the input of', ExtractFunctionArguments<First>[0]],
POSITION: ['Position of problem for input arguments is at', Fns['length'], 'from the', BooleanSwitch<IsPipe, 'end', 'beginning'> , 'and the output of function to the ', BooleanSwitch<IsPipe, 'left', 'right'>],
}
: never
'done': ( ...args: InitalParams ) => ReturnType,
}[
Fns extends []
? 'done'
: 'next'
]
This type goes through a series of steps, it starts by iterating through each function, starting at the head and recursively passing the tail end to the next iteration. The key to making this work is to extract and separate first item in the array of functions from the rest using this technique:
( ( ..._: Fns ) => any ) extends ( ( _: infer First, ..._1: infer Next ) => any )
If we didn't do error checks, we could distill this next part as simply
PreviousFunction extends void
? Pipe<Next, IsPipe, First, ExtractFunctionArguments<First>, ExtractFunctionReturnValue<First> >
: Pipe<Next, IsPipe, First, InitalParams, ExtractFunctionReturnValue<First> >
PreviousFunction
is void only on the first iteration. In that case we extract the initial parameters. We pass InitialParams
back in each iteration with the last functions return type. Once we exhaust all functions in the list, this part
Fns extends []
? 'done'
: 'next'
returns done
and we can return a new function made up of the initial parameters and the last return type
'done': ( ...args: InitalParams ) => ReturnType,
The other bits are error detection. If it detects an error, it will return custom object which will point to the count were the error occurred. In other words, it has built-in error reporting.
I learned about this technique studying other people's libraries. One notable example is typescript-tuple
which we use later to construct Compose
Alright now let's create an alias for the pipe function itself
type PipeFn = <Fns extends [AnyFunction, ...AnyFunction[]] >(
...fns: Fns &
Pipe<Fns> extends AnyFunction
? Fns
: never
) => Pipe<Fns>
Here is another technique to illustrate. When our Pipe
function return that helpful error object, we want to actually raise a compiler error too. We do this by joining the matched type for fns
conditionally to either itself or never
. The later condition creating the error.
Finally, we're ready to define pipe.
I do this in a different project, not only in a different file in the same project. I do this for two reasons:
First, I want to separate the implementation from the type. You're free to use these types without potentially including any JavaScript.
Second, once the type has compiled correctly, I want to separate the pros and cons of future TypeScript versions from the type and implementation.
Implementing The Pipe Function
export const pipe: PipeFn = ( entry: AnyFunction, ...funcs: Function1[] ) => (
( ...arg: unknown[] ) => funcs.reduce(
( acc, item ) => item.call( item, acc ), entry( ...arg )
)
)
Let see it work:
const average = pipe(
( xs: number[]) => ( [sum(xs), xs.length] ),
( [ total, length ] ) => total / length
)
✅ We see average has the right type (xs: number[]) => string
and parameter names are preserved.
Let's try another example:
const intersparse = pipe(
( text: string, value: string ): [string[], string] => ([ text.split(''), value ]),
( [chars, value]: [ string[], string ] ) => chars.join( value )
)
✅ Both parameter names are preserved (text: string, value: string) => string
Let's try a variadic example:
const longerWord = ( word1: string, word2: string ) => (
word1.length > word2.length
? word1
: word2
)
const longestWord = ( word: string, ...words: string[]) => (
[word,...words].reduce( longerWord, '' )
)
const length = ( xs: string | unknown[] ) => xs.length
const longestWordLength = pipe(
longestWord,
length,
)
✅ Parameter names and types check, the type for longestNameLength
is (word: string, ...words: string[]) => number
Great!
Compose
It turns out we can do this for Compose
very easily. The helper we need we'll use from typescript-tuple
.
import { Reverse } from 'typescript-tuple'
export type Compose<Fns extends any[]> = Pipe<Reverse<Fns>, false>
export type ComposeFn = <Fns extends [AnyFunction, ...AnyFunction[]] >(
...fns: Fns &
Compose<Fns> extends AnyFunction
? Fns
: never
) => Compose<Fns>
The implementation is only slightly different
import { ComposeFn } from 'pipe-and-compose-types'
export const compose: ComposeFn = ( first: Function1, ...funcs: AnyFunction[] ): any => {
/* `any` is used as return type because on compile error we present an object,
which will not match this */
return ( ...arg: unknown[] ) => init( [first, ...funcs] ).reduceRight(
(acc, item) => item.call( item, acc ), last(funcs)( ...arg )
)
}
Let's put our new compose
to the test:
const longestWordComposeEdition = compose(
length,
longestWord,
)
✅ Parameter names and types check, the type for longestNameLength
is (word: string, ...words: string[]) => number
Closing
I encourage you to take a look at this repo to review the types
https://github.com/babakness/pipe-and-compose-types
to import the types into your own project, install using:
npm install pipe-and-compose-types
Also look at two great application of these types
https://github.com/babakness/pipe-and-compose
import these functions into your project using
npm install pipe-and-compose
Please share your thought! Feel free to reach out to me on Twitter as well!
Top comments (2)
Hi, at first I would like to say that I like your approach the most of all I've seen.
My question, coming from React, is:
Would it be possible to compose functions in a way, that any extra incoming arguments are passed along with current's hoc outgoing arguments to next hoc, and that any extra arguments of the wrapped function that are not satisfied by any hoc are also exposed in the outer interface?
In React it makes sense as higher order components/functions usually don't represent a chain of actions on a single value, but instead add some functionality and add additional props to the underlying consumer component. Though, it's all in a single object called props.
It's somewhat difficult to express clearly, so here's the idea:
A real life usage could be
Where the
MyComponent
doesn't really care about whatwithSettings
returns, butwithApolloQuery
needs it.MyComponent
then cares about the result, theme and the consumer should provide the additional required prop:<MyComponent giveMeThisProp={true} />
Great article and wow, what a complex and powerful type! Is there a way to get the actual argument (and not its type) recursively? I'm currently trying to create such a type for Solid.js' setStore function, which has basically the following interface:
A selector can be a
keyof Item
,(keyof Item)[]
, a range{ from: number, to: number }
or a filter function. A setter can either be a DeepPartial value of the selected type, undefined or a function that receives the current Item and returns aforesaid values.¹: each subsequent selector should not receive the Store, but the selection within it.
²: the setter should receive the selection of the last selector.
However, a selector could also be a string or number if the selected item inside the store was an object or array. Unfortunately, if I use
I get the error that my Selectors are matching
undefined
. Any ideas or pointers on what I'm doing wrong?