#20747•Medium

Pipe

Type a variadic pipe() so every step accepts what the step before it returns, and the composed function keeps the first parameter list and the last return type.

A pipeline is only safe if every step knows what the step before it handed over.

pipe() takes a series of functions and returns a single function with the arguments of the first and the return type of the last. Typing it means doing two jobs at once: computing that outer signature, and rejecting a call where one function's output does not fit the next one's input, with the error landing on the function that broke the chain rather than on the whole call.

A simple JS implementation would be:

const pipe = (...fns: (() => any)[]) => (...args: any[]) => fns.reduce((p, c) => [c.apply(null, p)], args);
 
const stringToArray = (str: string) => Array.from(str);
const reverseArray = (arr: any[]) => arr.reverse();
const arrayToString = (arr: string[]) => arr.join('');
 
const reverseString = pipe(stringToArray, reverseArray, arrayToString); // should be (str: string) => string
// same as (str: string) => arrayToString(reverseArray(stringToArray(str)))

Each function passes its return value to the next one, which is where the name comes from.

Challenge Instructions: Pipe

Medium

Implement a type-safe version of the function composition pipe() function. pipe() takes a series of functions as an argument and returns a function with the arguments of the first and the return-type of the last.

A simple JS implementation would be:

const pipe = (...fns: (() => any)[]) => (...args: any[]) => fns.reduce((p, c) => [c.apply(null, p)], args);
 
const stringToArray = (str: string) => Array.from(str);
const reverseArray = (arr: any[]) => arr.reverse();
const arrayToString = (arr: string[]) => arr.join("");
 
const reverseString = pipe(stringToArray, reverseArray, arrayToString); // should be (str: string) => string
// same as (str: string) => arrayToString(reverseArray(stringToArray(str)))

It creates a pipeline, hence the name. Each function passes its return value to the next function. Running reverseString(str) would result in:

stringToArray(str) -> reverseArray(arr) -> arrayToString(arr)

View on GitHub: https://tsch.js.org/20747

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

declare function pipe(...fns: any[]): any;

/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'

const getString = () => "Hello World!";
const reverseString = (str: string) => Array.from(str).reverse().join("");
const getLength = (str: string) => str.length;
const addTen = (n: number) => n + 10;
const timesByTwo = (n: number) => n * 2;

const case1 = pipe();
const case2 = pipe(getString);
const case3 = pipe(getString, reverseString);
const case4 = pipe(reverseString);
const case5 = pipe(getString, revers

Pro Challenge

Get access to all 200+ challenges, including every medium, hard, and extreme one.

Monthly subscription.
Cancel anytime + 30-day money-back guarantee.

Detailed Explanation

type Func = (...args: any) => any
 
type Output<T> = T extends Func ? ReturnType<T> : any
 
type Chain<F extends Func[], Shifted extends Func[] = [Func, ...F]> = {
  [K in keyof F]: F[K] & ((input: Output<Shifted[K & keyof Shifted]>) => any)
}
 
type Last<F extends Func[]> = F extends [...Func[], infer L extends Func] ? L : never
 
declare function pipe<F extends Func[]>(
  ...fns: Chain<F>
): F extends [(...args: infer A) => any, ...Func[]]
  ? (...args: A) => ReturnType<Last<F>>
  : () => void

Capturing the argument list as a tuple

A rest parameter bound to a generic array type infers a tuple, not a loose array. For pipe(getString, reverseString) the compiler produces:

[object Object]

That tuple is the input to everything else. The obvious next move, ...fns: F, would infer it and then accept any list of functions at all. Chain<F> is the version that also checks.

Chain<F> is a mapped type over keyof F, and TypeScript can run those backwards: given the actual arguments, it recovers the F[K] that produced each element. That is why F is still inferred even though the parameter type is no longer plain F.

Shifting the tuple by one

[Func, ...F] prepends a single element, so every index moves up by one:

// F       = [() => string, (str: string) => string, (str: string) => number]
// Shifted = [Func, () => string, (str: string) => string, (str: string) => number]
// Shifted[0] = Func
// Shifted[1] = () => string
// Shifted[2] = (str: string) => string

Inside the mapped type, Shifted[K] is the step that runs immediately before F[K]. Index 0 lands on the Func placeholder, whose return type is any, so the first function stays unconstrained and may take any arguments it likes.

K & keyof Shifted only exists to satisfy the checker. Shifted is one element longer than F, so every key of F indexes it, but the compiler does not work that out on its own.

Constraining each step

With the predecessor in hand, the constraint for element K is (input: previous output) => any:

// Chain<F>[0] = (() => string)            & ((input: any) => any)
// Chain<F>[1] = ((str: string) => string) & ((input: string) => any)
// Chain<F>[2] = ((str: string) => number) & ((input: string) => any)

These are function types rather than methods, so under strictFunctionTypes their parameters are compared contravariantly. addTen, of type (n: number) => number, is assignable to (input: string) => any only if string is assignable to number, which it is not. That single rule is what rejects pipe(getString, addTen).

The intersection sits inside the mapped type on purpose. Writing the parameter as ...fns: F & Chain<F> type-checks just as well, but an intersection of two tuples is no longer a tuple, so the compiler compares the entire argument list in one go and reports the failure on the first argument. Keeping Chain<F> a tuple means arguments are checked position by position and the error appears on the function that actually broke the chain.

Building the outer signature

F extends [(...args: infer A) => any, ...Func[]]
  ? (...args: A) => ReturnType<Last<F>>
  : () => void

A captures the first function's parameter list as a tuple, names included, and (...args: []) => string is the same type as () => string, so the zero-argument cases come out clean rather than as some rest-parameter oddity. Last<F> reads the final element with the variadic pattern [...Func[], infer L extends Func], which matches from the right. The false branch covers pipe(): an empty tuple has no first element, so the result is () => void.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges