DEV Community

Cover image for Type a pipe() That Checks Every Step: Variadic Tuples, infer and One Recursive Type
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Type a pipe() That Checks Every Step: Variadic Tuples, infer and One Recursive Type


Open Effect's Function.ts and count the signatures of pipe. There are 20: one for a bare value, then one more for every extra function, up to 19. RxJS 7's standalone pipe types nine functions and then falls back to a catch-all signature that returns UnaryFunction<T, unknown>. Hand it a tenth function and the output type is gone.

Those libraries know exactly what they are doing. When TypeScript 4.0 shipped variadic tuple types, the release notes called this pattern "death by a thousand overloads", and overloads are still how the big libraries type pipe. The reason is that a single generic signature that checks every step is harder to write than it looks, and it gives some things up.

You're going to write that single signature anyway: about 40 lines of types, checked by tsc --noEmit, and then the four places it breaks so you know when overloads win.

Step 1: read the ends with infer

Start with four small helpers. In and Out pull the parameter and return type out of a one-argument function. First and Last pull the first and last element out of a tuple.

// pipe.ts
export type Fn = (arg: any) => any;

type In<F> = F extends (arg: infer A) => any ? A : never;
type Out<F> = F extends (arg: any) => infer R ? R : never;

type First<T extends unknown[]> =
  T extends [infer F, ...unknown[]] ? F : never;
type Last<T extends unknown[]> =
  T extends [...unknown[], infer L] ? L : never;
Enter fullscreen mode Exit fullscreen mode

Last matches with a leading rest element, [...unknown[], infer L], which TypeScript has accepted since 4.2. With these you can write the obvious pipe type: the input of the first function, the output of the last.

type NaivePipe<Fns extends Fn[]> =
  (input: In<First<Fns>>) => Out<Last<Fns>>;
Enter fullscreen mode Exit fullscreen mode

It reads the ends and never looks at the middle. Give it trim (string to string), len (string to number) and shout (string to string) and it reports (input: string) => string, even though shout would receive a number at runtime. A pipe type that only reads the ends is an as cast with better marketing.

A pipeline of joined segments where only the first and last joints are inspected and a mismatched joint in the middle goes unnoticed
Reading only the first input and the last output leaves every joint in the middle unchecked.

Step 2: walk the list with one recursive type

The real check has to visit every adjacent pair. Recursive conditional types (TypeScript 4.1) let a type refer to itself in its own branches, so you peel one function off the front, compare its input with the value flowing in, and recurse on the rest.

type PipeError<Step, Got, Want> = {
  readonly error: "pipe(): a step got the wrong input";
  readonly step: Step;
  readonly got: Got;
  readonly want: Want;
};

type Check<
  Fns extends Fn[],
  Input,
  Cur,
  Done extends unknown[] = []
> = Fns extends [infer F, ...infer Rest extends Fn[]]
  ? [Cur] extends [In<F>]
    ? Check<Rest, Input, Out<F>, [...Done, F]>
    : PipeError<[...Done, F]["length"], Cur, In<F>>
  : (input: Input) => Cur;

export type Pipe<Fns extends [Fn, ...Fn[]]> = Check<
  Fns,
  In<First<Fns>>,
  In<First<Fns>>
>;
Enter fullscreen mode Exit fullscreen mode

Four details carry the weight here.

infer Rest extends Fn[] puts a constraint on the inferred rest, a feature from TypeScript 4.7. Without it, Rest can't be passed back into Check, whose first parameter demands Fn[].

[Cur] extends [In<F>] wraps both sides in brackets on purpose. A bare Cur extends ... on a type parameter is distributive: if a step returns string | number, the check would split into one branch per member and hand you a union of a function and an error. The brackets compare the whole union at once.

Done collects the functions already visited. Its only job is to number the failing step: [...Done, F]["length"] is the 1-based position of F as a number literal type.

The recursive call is the entire branch result. Nothing wraps it, and that makes Check tail-recursive, which matters when chains get long.

The runtime part is short:

export function pipe<Fns extends [Fn, ...Fn[]]>(
  ...fns: Fns
): Pipe<Fns> {
  const run = (input: unknown) =>
    fns.reduce((acc: unknown, fn) => fn(acc), input);
  return run as unknown as Pipe<Fns>;
}
Enter fullscreen mode Exit fullscreen mode

Fns extends [Fn, ...Fn[]] on a rest parameter makes TypeScript infer the arguments as a tuple, one element per function, which is what Check walks. The double cast is the honest part: the body can't prove a deferred conditional type, so you assert once at the boundary and callers get the checked type. The overloaded libraries pay the same toll in their own way; Effect's implementation signature is pipe(a: unknown, ...args: Array<any>): unknown.

Step 3: prove it with @ts-expect-error

A type you can't test is a type you will break by accident. // @ts-expect-error (TypeScript 3.9) suppresses the error on the next line, and if that line has no error, tsc reports Unused '@ts-expect-error' directive. That turns every expected failure into an assertion.

// pipe.types.ts
import { pipe } from "./pipe";

type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2)
    ? true
    : false;
type Expect<T extends true> = T;

const trim = (s: string) => s.trim();
const len = (s: string) => s.length;
const isLong = (n: number) => n > 10;
const shout = (s: string) => s.toUpperCase();

const ok = pipe(trim, len, isLong);
export type OkIsTyped = Expect<
  Equal<typeof ok, (input: string) => boolean>
>;

// @ts-expect-error the input must be a string
ok(42);

const broken = pipe(trim, len, shout);

// @ts-expect-error step 3 wants a string, gets a number
broken("  hello  ");

// @ts-expect-error a broken pipe is not a function
const f: (s: string) => string = broken;

// @ts-expect-error pipe() needs at least one step
pipe();
Enter fullscreen mode Exit fullscreen mode

Equal compares two types strictly, so a result that drifts to any or to a wider type fails it; Expect fails to compile when handed false. A minimal tsconfig.json next to the two files:

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler"
  },
  "include": ["pipe.ts", "pipe.types.ts"]
}
Enter fullscreen mode Exit fullscreen mode

Install the compiler with npm i -D typescript, then run npx tsc. The config needs TypeScript 5.0 or newer for "moduleResolution": "bundler"; the types themselves need 4.7. If any expectation stops holding, either an Expect line or an unused directive fails the build, locally or in CI.

Where the recursive version breaks

Look again at the broken test. The pipe(trim, len, shout) line compiles. The type of broken is PipeError<3, number, string>, and the error only appears where you call it or assign it to a function type. Hovering broken tells you the step, what it got and what it wanted, which beats a silent unknown, but the red squiggle sits on the wrong line. Overloads don't have this problem: each step is its own parameter, so a mismatch is an argument error on the exact function that's wrong. There is a variant that rewrites the rest parameter to the expected tuple so the error moves onto the bad argument; ecyrbe's write-up shows one. I haven't run it, and a commenter there reports a gap in its first-to-second step check, so test it the same way before you trust it.

Generic steps lose their type parameter. Take const wrap = <T>(x: T): T[] => [x]. When In and Out match a generic signature against infer, the type parameter doesn't survive; TypeScript issue #38964 shows ReturnType of <T = string>() => T coming out as unknown. So pipe(trim, wrap, join), with join taking string[], fails with got: unknown[]. The fix is an instantiation expression (4.7): pass wrap<string> as the step and the chain checks. Overloads get this for free, because each step is matched against (a: A) => B with A already known, and since TypeScript 3.4 a composer can even propagate a generic step's type parameters into the result.

Inline lambdas aren't typed from the previous step. In Effect, pipe(" hi ", (s) => s.trim(), (s) => s.length) types each s from the step before it, because every overload names the intermediate types as separate type parameters. In ...fns: Fns, nothing ties the second slot's parameter to the first slot's return, so an unannotated (s) => s.length has no way to learn that s is a string. The best it gets is the any from the Fn constraint, and an any step checks nothing. Annotate every inline step, or write the steps as named functions.

Long chains hit the depth limit, depending on how you wrote the recursion. The TypeScript PR that added tail-recursion elimination lets tail-recursive conditional types loop 1,000 times before giving up, and lowered the general limit for the rest to 100. Check is tail-recursive, so a 30-step pipe is nowhere near the ceiling. Rewrite it so the recursive call sits inside something else, a tuple spread or a union, and you are back under the general limit, where the failure is the familiar Type instantiation is excessively deep and possibly infinite from the 4.5 release notes. Even well under the limit, the 4.1 notes warn that recursive types can increase type-checking time, so keep the step types themselves simple.

When overloads are still the better choice

A tall stack of near-identical cards on one side and a single looping arrow passing through a row of function blocks on the other
Overloads write one signature per chain length. The recursive type writes one signature that walks the list.

The rule that falls out of the four breaks: choose by who writes the steps.

If callers write steps inline, as lambdas, or pass generic helpers like map and filter, use overloads. That is the RxJS and Effect situation, where most steps are operators with type parameters and most callbacks are unannotated. Contextual typing per step and per-argument errors are worth more than an unlimited chain length, and very few real pipelines need more than the 19 functions Effect covers.

If callers pass named, already-typed, non-generic functions, use the recursive type. Validation chains, middleware lists and data transforms assembled from a registry all fit: the steps already carry their own types, chains can be longer than any overload stack, and the PipeError type names the failing step. Ship it with the @ts-expect-error file so the next refactor can't quietly break the check.


If this was useful

The recursive Check type is a small version of a pattern that keeps coming back: walk a tuple with infer, carry state in an accumulator, keep the recursion in tail position. The TypeScript Type System goes deep on that toolkit: generics, mapped and conditional types, infer, template literals, branded types. If you already write TypeScript daily, that's the one to read next; if you're still getting comfortable with narrowing and modules, start with TypeScript Essentials.

Coming from Kotlin, Java or PHP? The two bridge books replace the first two for you. Shipping TypeScript at work, with build tooling and published packages? TypeScript in Production covers that layer.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (1)

Collapse
 
koda2026 profile image
Harun - solo dev •

gabriel, this is a masterclass in advanced typescript. the breakdown of the trade-offs between "death by a thousand overloads" and a single recursive type is exactly the kind of deep dive the community needs.

the [cur] extends [in<f>] trick to prevent distributive conditional types is a pro-level detail that so many miss. wrapping both sides in tuples is the only way to safely compare unions as a whole without them splitting into a mess.

in my own work building backend pipelines (like validation chains or middleware in hono/supabase edge functions), i heavily lean toward this recursive approach. when the steps are named, pre-typed functions, it keeps the codebase incredibly dry without needing 20+ overload signatures. plus, that pipeerror type makes debugging a failed chain so much more explicit than just getting a silent unknown.

fantastic, highly technical read. the distinction on when to use which approach is the perfect takeaway. 🐯🔧