Typescript types for a pipe() function

Viewed 5765

Consider the following TypeScript code:

type operator<T> = (input:T) => T

const pipe = <T>(...operators:operator<T>[]) => (input:T):T => operators.reduce((output, f) => f(output), input)

const add2:operator<number> = x => x+2

const times3:operator<number> = x => x*3

console.log(pipe(add2, times3)(1))    //output 9

The pipe function simply pipes the input of one operator into the result of the next operator.

Now consider this new definition of the operator type:

type operator<T, U> = (input:T) => U

How should the pipe function be rewritten in order for the IDE to let me know if I am using the types correctly?

E.g.: consider these two operators:

const times3:operator<number, number> = x => x*3

const toStr:operator<number, string> = x => `${x}`

I would like this to work properly:

pipe(times3, toStr)(1)

And here I would like the IDE to warn me that the types are wrong:

pipe(toStr, times3)(1)

I can't figure this out, thank in advance.

3 Answers

Here is how RxJS does it:

pipe(): Observable<T>;
pipe<A>(op1: OperatorFunction<T, A>): Observable<A>;
pipe<A, B>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>): Observable<B>;
pipe<A, B, C>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>): Observable<C>;
pipe<A, B, C, D>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>,
    op4: OperatorFunction<C, D>
): Observable<D>;
pipe<A, B, C, D, E>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>,
    op4: OperatorFunction<C, D>,
    op5: OperatorFunction<D, E>
): Observable<E>;
pipe<A, B, C, D, E, F>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>,
    op4: OperatorFunction<C, D>,
    op5: OperatorFunction<D, E>,
    op6: OperatorFunction<E, F>
): Observable<F>;
pipe<A, B, C, D, E, F, G>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>,
    op4: OperatorFunction<C, D>,
    op5: OperatorFunction<D, E>,
    op6: OperatorFunction<E, F>,
    op7: OperatorFunction<F, G>
): Observable<G>;
pipe<A, B, C, D, E, F, G, H>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>,
    op4: OperatorFunction<C, D>,
    op5: OperatorFunction<D, E>,
    op6: OperatorFunction<E, F>,
    op7: OperatorFunction<F, G>,
    op8: OperatorFunction<G, H>
): Observable<H>;
pipe<A, B, C, D, E, F, G, H, I>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>,
    op4: OperatorFunction<C, D>,
    op5: OperatorFunction<D, E>,
    op6: OperatorFunction<E, F>,
    op7: OperatorFunction<F, G>,
    op8: OperatorFunction<G, H>,
    op9: OperatorFunction<H, I>
): Observable<I>;
pipe<A, B, C, D, E, F, G, H, I>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>,
    op4: OperatorFunction<C, D>,
    op5: OperatorFunction<D, E>,
    op6: OperatorFunction<E, F>,
    op7: OperatorFunction<F, G>,
    op8: OperatorFunction<G, H>,
    op9: OperatorFunction<H, I>,
    ...operations: OperatorFunction<any, any>[]
): Observable<unknown>;

It's not pretty, but it gets the job done.

I know it isn't the same exact function signature but... might I suggest using a builder pattern?

Typescript Playground example

const pipe = <A, B>(fn: (a: A) => B) => {
    return {
        f: function<C>(g: (x: B) => C) { return pipe((arg: A) => g(fn(arg)))},
        build: () => fn
    }
}

const compose = <A, B>(fn: (a: A) => B) => {
    return {
        f: function<C>(g: (x: C) => A) { return compose((arg: C) => fn(g(arg)))},
        build: () => fn
    }
}


const add = (x: number) => (y: number) => x + y
const format = (n: number) => `value: ${n.toString()}`
const upper = (s: string) => s.toUpperCase()

const process = pipe(add(2))
  .f(add(6))
  .f(format)
  .f(upper)
  .build()


const process2 = compose(upper)
  .f(format)
  .f(add(6))
  .f(add(5))
  .build()


console.log(process(6))
console.log(process2(6))

Goblinlord's answer is inspiring, if the runtime recursion is the concern, we may type erase the actual implementation so we can replace the recursion with iteration. Type erasing brings a risk that flaws could escape compile time type check but I think that is a price I am willing to pay.

type Fn<T, U> = (i: T) => U
type Pipe<T, U> = { f: <K>(fn: Fn<U, K>) => Pipe<T, K>, build: () => Fn<T, U> }

function pipe<T, U>(fn: Fn<T, U>): Pipe<T, U> {
    const fns: Fn<any, any>[] = [fn]
    const p: Pipe<any, any> = {
        f: (fn) => {
            fns.push(fn);
            return p;
        },
        build: () => {
            return (input) => fns.reduce((prev, curr) => curr(prev), input);
        }
    }
    return p;
}


const add = (x: number) => (y: number) => x + y
const format = (n: number) => `value: ${n.toString()}`
const upper = (s: string) => s.toUpperCase()

const process = pipe(add(2))
  .f(add(6))
  .f(format)
  .f(upper)
  .build()

console.log(process(1))

Related