Skip to content

Heavily overloaded functions (pipe/compose) for type-safe function composition in TypeScript

License

Notifications You must be signed in to change notification settings

biggyspender/ts-functional-pipe

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

57 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

npm version Build Status

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") // "Hello, mike-o-saurus!"

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)) // "Hello, mike-o-saurus!"

or, less verbosely:

applyArgs("mike")(pipe(dinosaurify, sayHello)) // "Hello, mike-o-saurus!"

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>

// helper function for making iterables from generator functions
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),  // x is inferred as number
      map(x => x * 2)            // x is inferred as number
    )
  ) // iterable with values [2, 6]

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 is inferred as (src:Iterable<number>)=>Iterable<string>
    pipe(
      // typescript can infer all other types when 
      // we provide this input type annotation (number)
      filter(x:number => x % 2 === 1), 
      map(x => x.toString()),   // x is inferred as number
      map(x => x + " " + x)     // x is inferred as string
    )

So oddNumbersMultipliedByTwoPipe has the inferred type

(src: Iterable<number>) => Iterable<string>

and when we use it...

const r = oddMultipliedByTwo([1, 2, 3]) 
// arr has type string[]
const arr = [...r] // ["1 1", "2 2"]

arr has type string[]

acknowledgements

Created using the wonderful https://github.com/alexjoverm/typescript-library-starter.