DEV Community

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

Introducing The Recursive `Pipe` and `Compose` Types

Babak
Twitter @babakness https://twitter.com/babakness
・5 min read

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!

Discussion (1)

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