DEV Community

Cover image for Introducing The Recursive `Pipe` and `Compose` Types
Babak for Hemaka.com

Posted on

Introducing The Recursive `Pipe` and `Compose` Types

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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'
]


Enter fullscreen mode Exit fullscreen mode

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 )


Enter fullscreen mode Exit fullscreen mode

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> >


Enter fullscreen mode Exit fullscreen mode

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'


Enter fullscreen mode Exit fullscreen mode

returns done and we can return a new function made up of the initial parameters and the last return type



'done': ( ...args: InitalParams ) => ReturnType,


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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 ) 
  ) 
) 


Enter fullscreen mode Exit fullscreen mode

Let see it work:



const average = pipe(
  ( xs: number[]) => ( [sum(xs), xs.length] ),
  ( [ total, length ] ) => total / length
)



Enter fullscreen mode Exit fullscreen mode

✅ 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 )
)


Enter fullscreen mode Exit fullscreen mode

✅ 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,
)


Enter fullscreen mode Exit fullscreen mode

✅ 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>



Enter fullscreen mode Exit fullscreen mode

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 ) 
  )
}


Enter fullscreen mode Exit fullscreen mode

Let's put our new compose to the test:



const longestWordComposeEdition = compose(
  length,
  longestWord,
)


Enter fullscreen mode Exit fullscreen mode

✅ 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)

Collapse
 
ackvf profile image
Vítězslav Ackermann Ferko • Edited

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:

interface InnerProps { c: number, d: number }
interface H1in { a: number }
interface H1out { b: number }
interface H2in { b: number }
interface H2out { c: number }

declare const WrappedComponent: (props: InnerProps) => any // React component
declare const H1: (props: H1in) => H1out & H1in // also must pass rest properties ({a, ...rest}) => ({b: a + 10, ...rest})
declare const H2: (props: H2in) => H2out & H2in // ...rest properties

const OuterComponent: (props: OuterProps) => any = pipe(H1, H2)(WrappedComponent)
/*
interface OuterProps { 
  a: number // from H1
  d: number // WrappedComponent own props that are not satisfied by any available HOC
  // b, c are not exposed as they are satisfied from the chain
}
*/

A real life usage could be

interface MyComponentProps { giveMeThisProp: any }
declare const MyComponent: React.FC<MyComponentProps & Theme & Query>

pipe(
  withSettings,
  withApolloQuery(query),
  withTheme
)(
  MyComponent
)

Where the MyComponent doesn't really care about what withSettings returns, but withApolloQuery needs it. MyComponent then cares about the result, theme and the consumer should provide the additional required prop: <MyComponent giveMeThisProp={true} />

Collapse
 
lexlohr profile image
Alex Lohr

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:

setStore<Store>(
  ...selectors: StoreSelectors<Store¹>[],
  setter: StoreSetter<Store²>
) => void
Enter fullscreen mode Exit fullscreen mode

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

export type SetStoreFunction<Store> = <Args extends any[]>(
  ...args: Args & SetStoreArgs<Store, Args>
) => void;

setStore<{ test: 1, test2: { test3: 2 }}>('test2', 'test3', 3);
Enter fullscreen mode Exit fullscreen mode

I get the error that my Selectors are matching undefined. Any ideas or pointers on what I'm doing wrong?