Strongly Typed pipe()/compose() (Variadic Tuples vs Overloads)

How to type function pipelines like a library author: overloads, variadic tuples, inference boundaries, and what interviewers really want to see.

F

Frontend Interview Team

March 01, 2026

2 min read
Strongly Typed pipe()/compose() (Variadic Tuples vs Overloads)

What you’ll learn

  • How to type a function pipeline so each step is type-safe
  • Two production patterns: overloads and variadic tuples
  • Where inference breaks (and how to design around it)

30‑second interview answer

To type pipe(a, f1, f2, f3) you need TypeScript to infer the output of each function as the input of the next. The common approach is either (1) a small set of overloads (simple + readable) or (2) variadic tuple types (more generic, more complex). In interviews, the key is showing you understand type inference boundaries and can choose the maintainable option.


The goal

We want this to be safe:

const result = pipe(
  "  hello world  ",
  s => s.trim(),
  s => s.toUpperCase(),
  s => s.split(" ")
);
// result: string[]

…and this to be a type error:

pipe("x", s => s.length, n => n.toUpperCase());
//                          ^ number has no toUpperCase

Approach 1: Overloads (most production teams should do this)

Overloads are boring and work great.

type Unary<A, R> = (arg: A) => R;
 
export function pipe<A, B>(a: A, ab: Unary<A, B>): B;
export function pipe<A, B, C>(a: A, ab: Unary<A, B>, bc: Unary<B, C>): C;
export function pipe<A, B, C, D>(
  a: A,
  ab: Unary<A, B>,
  bc: Unary<B, C>,
  cd: Unary<C, D>
): D;
export function pipe(a: unknown, ...fns: Array<Unary<any, any>>) {
  return fns.reduce((v, fn) => fn(v), a);
}

Why overloads are a good default

  • Easy to read
  • Great error messages
  • You control complexity (add overloads up to N steps)

Approach 2: Variadic tuples (library-author mode)

This is harder, but can type arbitrary-length pipelines.

A pragmatic version is:

type Fn = (a: any) => any;
 
type PipeResult<Input, Fns extends Fn[]> =
  Fns extends []
    ? Input
    : Fns extends [(a: infer A) => infer B, ...infer Rest]
      ? Input extends A
        ? Rest extends Fn[]
          ? PipeResult<B, Rest>
          : never
        : never
      : never;
 
export function pipe<Input, Fns extends Fn[]>(
  input: Input,
  ...fns: Fns
): PipeResult<Input, Fns> {
  return fns.reduce((v, fn) => fn(v), input) as any;
}

Trade-offs

  • Harder to maintain
  • Error messages can become cryptic
  • Runtime is still simple, types are the complicated part

Where inference breaks (important)

Type inference struggles when:

  • You pass functions with overloaded signatures
  • You build arrays of functions dynamically
  • You use generics that need explicit type parameters

Production pattern: if you need dynamic pipelines, you often accept a weaker type and validate at boundaries.


Production rule of thumb

  • For apps: use overloads up to 4–8 steps.
  • For libraries: consider variadic tuples, but prioritize developer experience (clear errors) over cleverness.

Interview questions

  1. Q: Why are overloads common even though variadic tuples exist?

    • A: They’re simpler, with clearer errors, and cover most real use cases.
  2. Q: What’s the hardest part of typing pipe?

    • A: Ensuring each function’s output matches the next function’s input via inference.

Quick recap

  • pipe typing is about chaining inference.
  • Overloads are often the best engineering choice.
  • Variadic tuples are powerful but come with complexity cost.