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.
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).
/* _____________ 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, reversGet access to all 200+ challenges, including every medium, hard, and extreme one.
Monthly subscription.
Cancel anytime + 30-day money-back guarantee.
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>>
: () => voidA 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.
[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) => stringInside 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.
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.
F extends [(...args: infer A) => any, ...Func[]]
? (...args: A) => ReturnType<Last<F>>
: () => voidA 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.
pipe() → () => void. F is [], which fails the [(...args: infer A) => any, ...Func[]] check and falls into the false branch.pipe(reverseString) → (str: string) => string. With one function, first and last are the same element, and the parameter name survives the trip through infer A.pipe(getString, reverseString, getLength, addTen, timesByTwo) → () => number. The parameter list comes from getString, the return type from timesByTwo, and the three steps in between only have to agree with their neighbours.pipe(getString, addTen) is rejected at addTen, because getString returns string while addTen asks for a number.pipe(timesByTwo, addTen, reverseString) is rejected at reverseString only. The first two steps agree on number, so positions 0 and 1 pass and position 2 is the one that fails.This challenge is originally from here.
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges