Introducing The Recursive `Pipe` and `Compose` Types

babak

Babak

Posted on April 12, 2019

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!

šŸ’– šŸ’Ŗ šŸ™… šŸš©
babak
Babak

Posted on April 12, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related