compose or pipe function requires combining Generics, Tuples, Variadic Arguments, and Function Overloads into a single masterpiece. This is the final boss of the Advanced TypeScript module.1. The Challenge of Composition#
We want to write a pipe function. It takes an initial value and a list of functions. The value is passed to the first function, its result is passed to the second, and so on.
const add1 = (x: number) => x + 1;
const toString = (x: number) => x.toString();
const makeGreeting = (s: string) => `Hello, ${s}`;
// Should strictly infer 'string'
const result = pipe(5, add1, toString, makeGreeting);If we just use any or Function[] for the variadic arguments, we lose all type safety. If the second function returns a string, but the third expects an Array, the compiler won’t warn us, and it will crash at runtime.
2. The Pragmatic Way: Function Overloads#
The most common, battle-tested way to solve this in massive libraries (like Redux, RxJS, or fp-ts) is to write Function Overloads for up to 10 or 20 arguments.
It is brute force, but it provides the absolute best autocomplete and error messages for developers.
// 1 Function: The output is just B
export function pipe<A, B>(
value: A,
fn1: (arg: A) => B
): B;
// 2 Functions: The output of fn1 (B) is the input of fn2!
export function pipe<A, B, C>(
value: A,
fn1: (arg: A) => B,
fn2: (arg: B) => C
): C;
// 3 Functions: B -> C -> D
export function pipe<A, B, C, D>(
value: A,
fn1: (arg: A) => B,
fn2: (arg: B) => C,
fn3: (arg: C) => D
): D;
// Implementation (Runtime)
export function pipe(value: any, ...fns: Function[]) {
return fns.reduce((acc, fn) => fn(acc), value);
}This works perfectly. The compiler will mathematically prove the chain: if you try to pass toString’s output (a string) into a function expecting a boolean, it will draw a red squiggly line exactly at that step in the chain!
3. The Metaprogramming Way: Recursive Tuples#
Can we type it for infinite arguments dynamically without writing 20 overloads?
Yes. But it requires extremely advanced recursive conditional types mapped over variadic tuples.
This pattern is strictly for demonstrating the Turing-completeness of TypeScript’s compiler. In production, this can cause massive Type instantiation is excessively deep errors and slow down your editor’s language server.
// We define a recursive type that steps through the tuple of functions
type PipeFns<TArgs extends any[], Initial> =
// Does the array have at least one function? Extract First and Rest.
TArgs extends [infer FirstFn, ...infer RestFns]
// Does the First function accept the Initial value? Extract its NextValue.
? FirstFn extends (arg: Initial) => infer NextValue
// If it matches, recurse! Pass the NextValue into the Rest of the functions.
? [FirstFn, ...PipeFns<RestFns, NextValue>]
// Error state: The signature is wrong. Break the chain.
: [(arg: Initial) => any, ...any[]]
// Base case: Empty array. We are done!
: [];
// We also need a recursive type to grab the very last return value
type LastOutput<TArgs extends any[], Initial> =
TArgs extends [...any[], (...args: any) => infer Final]
? Final
: Initial;
// The final variadic signature!
declare function dynamicPipe<Initial, Fns extends any[]>(
initial: Initial,
...fns: PipeFns<Fns, Initial> & Fns
): LastOutput<Fns, Initial>;Series Conclusion#
You have conquered the Advanced TypeScript Module.
You started from simple string primitives and structurally typed interfaces, and ended up writing recursive Lisp-like metaprogramming evaluations embedded directly into the compiler. You now have the skills to architect libraries, build heavily constrained type-safe APIs, and eliminate impossible states before your code even runs.
You are now fully prepared for the final boss of modern TypeScript backend architecture: Effect-TS.
In the upcoming Effect series, we will leave Object-Oriented design behind and explore pure, highly scalable Functional Architecture!

