Yuzhe's Blog

yuzhes

typed-pipeline internals: how fn.length unifies runtime and type system

This article discusses one specific design decision: how typed-pipeline makes .pipe() support three different step types while users never need extra wrappers or decorators.

The answer is fn.length—a property almost forgotten in JavaScript that becomes the pivot of the entire design.


Problem: three behaviors in a single .pipe()

typed-pipeline supports three kinds of steps:

new Pipeline<number>()
  .pipe(n => n * 2)                       // 1. Plain transform
  .pipe(($$ , bonus = 3) => $$ + bonus)   // 2. Access "previous result", with optional extra args
  .saveAs('doubled')
  .pipe((current, $) => current + $['doubled'])  // 3. Access all saved intermediates

These three steps have different signatures and different behavior:

If this were split into three methods (.pipe() / .pipeWith$$() / .pipeWithSaved()), typing would be much simpler, but ergonomics suffer. The goal was to let users write plain functions and have .pipe() infer behavior itself.

How to distinguish them?


Key insight: fn.length counts only required parameters

In JavaScript, Function.prototype.length returns the number of required parameters; parameters with default values are not counted:

((n: number) => n * 2).length
// → 1

(($$ : number, bonus = 3) => $$ + bonus).length
// → 1  ← defaulted parameters are not counted!

((current: number, $: Record<string, any>) => current + $['x']).length
// → 2  ← both are required

This property is usually overlooked, but here it has an unexpectedly perfect use case:

PrevStep and PlainStep both have fn.length === 1, while SavedStep is 2. That is enough.


Distinguishing three step kinds

It is clear in the table:

Syntaxfn.lengthTypeRuntime call
n => n * 21PlainStepfn(input)
($$, bonus = 3) => $$ + bonus1PrevStepfn(input) (one arg only)
(current, $) => $['key']2SavedStepfn(input, savedMap)

PrevStep and PlainStep are indistinguishable at runtime (both use fn(input)), and they do not need to be distinguished there—the call is identical. SavedStep is identified by fn.length === 2, then savedMap is passed.


Runtime dispatch: five lines

The core of Job.run() is very small:

async run(input: TInput): Promise<TOutput> {
  const result = this.action.length >= 2
    ? await (this.action as SavedStep<TInput, TOutput>)(input, this.savedMap)
    : await (this.action as PlainStep<TInput, TOutput> | PrevStep<TInput, TOutput>)(input)
  this.after.emit(result)
  return result
}

fn.length >= 2 means SavedStep, so pass savedMap; otherwise pass only one argument.

That’s all. No reflection, no Symbol checks, no WeakMap lookups. One property access, one branch.


Type-system overload order

Runtime uses fn.length to classify steps. At compile time, .pipe() uses overloads. It has three signatures:

// 1. SavedStep first — two required parameters
pipe<TNext>(
  step: (current: TOutput, saved: TSaved) => MaybePromise<TNext>
): Pipeline<TInput, TNext, TSaved>

// 2. PrevStep — first parameter is Prev<T>; extra parameters have defaults
pipe<TNext>(
  step: PrevStep<TOutput, TNext>
): Pipeline<TInput, TNext, TSaved>

// 3. PlainStep — the default case
pipe<TNext>(
  step: PlainStep<TOutput, TNext>
): Pipeline<TInput, TNext, TSaved>

Order matters. TypeScript checks overloads top-down and stops at the first match.

SavedStep must go first: if PlainStep is placed first, (current, $) => ... is wrongly matched as PlainStep (TypeScript allows excess parameters in function matching, so extra parameters are ignored).

SavedStep has two required parameters and is structurally more specific, so placing it first lets contextual typing infer $ as TSaved instead of unknown.


Symmetry

One part I especially like is this: runtime and type layer use the same signal.

Runtime: fn.length >= 2 → SavedStep
Type-level: overloads are ordered from more parameters to fewer, so SavedStep matches first.

These are completely independent implementations—runtime property access versus TypeScript overload resolution—but their classification criterion is identical: parameter count.

No synchronization needed, no runtime type tags, no Symbol or WeakMap. The two layers align naturally because they describe the same thing.

This kind of symmetry is rare in system design. Usually runtime and type systems each maintain separate logic and drift is easy. Here there is no drift.


Limitation: PrevStep constraints are intentional

PrevStep requires extra parameters to have default values:

// ✅ valid
pipe(($$ , bonus = 3) => $$ + bonus)

// ❌ invalid — runtime passes only one argument, so bonus becomes undefined
pipe(($$ , bonus: number) => $$ + bonus)

This is a design constraint, not an implementation bug. Runtime always calls with one argument (fn(input)), so extra parameters must obtain values from defaults. If a required extra parameter is present, runtime will silently pass undefined, causing undefined behavior.

The type layer enforces this through PrevStep<T>:

export type PrevStep<TIn, TOut> = (
  prev: Prev<TIn>,
  ...rest: DefaultOnly[]  // all extra args must have defaults
) => MaybePromise<TOut>

DefaultOnly is a helper type ensuring extra args are optional at the type level. No runtime check is needed—the compile-time layer already blocks it.


Wrap-up

fn.length unifies runtime and type system; there is no magic, just a neat coincidence:

  1. Function.prototype.length in JavaScript counts only required parameters.
  2. The three step kinds differ in required parameter count (1 vs 2).
  3. The “extra parameters must have defaults” constraint for PrevStep is both a runtime requirement and the prerequisite for this differentiation.

The result is runtime-level 5-line branching and three overloads in typings, with each layer independent and perfectly aligned.

A good design constraint is often not a limitation; it’s the nail that aligns two systems naturally.