Yuzhe's Blog

yuzhes

TypeScript 5 Type Gymnastics: Compile-Time Type Validation for a Pipeline Library

Recently I upgraded typed-pipeline to TypeScript 5 and used the chance to fully stretch the type system. This post records every type trick used.


Goal

What we want to implement is:

const p = fpipe(
  (x: number) => x * 2,       // number → number
  (n: number) => `val: ${n}`,  // number → string
  (s: string) => s.length,     // string → number
)

await p(5)  // Promise<number>,类型正确推断

And if types do not match, it should throw a compile-time error:

const p = fpipe(
  (x: number) => x * 2,
  (s: string) => s.length,  // ❌ 编译错误:上一步输出 number,这里期望 string
)

No runtime checks are needed. It is pure type-level enforcement.


TS 5 feature quick reference

Used here:

FeatureVersionNote
infer X extends Constraint4.8Constrain inferred infer variables
Recursive conditional types4.1Type-level recursion
Variadic tuples4.0Spread with [...T]
const type parameters5.0Preserve literal types
NoInfer<T>5.4Prevent type parameters from being inferred from a specific position
Template literal types4.1`error at step ${N}`

Step 1: Extract step input and output types

type Awaited_<T> = T extends Promise<infer U> ? Awaited_<U> : T

// 输出类型:解包 Promise
type StepOutput<S> =
  S extends (arg: any, ...rest: any[]) => MaybePromise<infer O>
    ? Awaited_<O>
    : never

// 输入类型:区分普通步骤和 $$-aware 步骤
type StepInput<S> =
  S extends PlainStep<infer TIn, any> ? TIn :
  S extends ($$: Prev<infer TIn>, ...rest: any[]) => any ? TIn :
  never

Prev<T> is a branded type used to mark a ”$$-aware” step:

export type Prev<T> = T & { readonly __fpipe_prev__: unique symbol }

unique symbol makes each Prev<T> nominally unique in TypeScript’s structural type system, so it will not be confused with plain T.


Step 2: Recursive pipeline threading

This is the core: thread a tuple of steps through their types from start to end:

type ThreadPipeline<
  Steps extends readonly AnyStep[],
  Seed,
> = Steps extends readonly []
  ? Seed
  : Steps extends readonly [
      infer Head extends AnyStep,   // ← TS 4.8: infer + constraint
      ...infer Tail extends readonly AnyStep[],
    ]
  ? ThreadPipeline<Tail, StepOutput<Head>>
  : never

infer Head extends AnyStep is syntax introduced in TS 4.8: constrain the inferred type at the same time as inferring it. In older TS versions you would write this in two steps:

// TS < 4.8 的写法(啰嗦)
: Steps extends readonly [infer Head, ...infer Tail]
  ? Head extends AnyStep
    ? Tail extends readonly AnyStep[]
      ? ThreadPipeline<Tail, StepOutput<Head>>
      : never
    : never
  : never

The new form is much clearer.


Step 3: Compile-time compatibility validation

Inferring the output types is not enough; we also want meaningful errors when steps are incompatible.

type ValidateSteps<
  Steps extends readonly AnyStep[],
  _Prev = never,          // 上一步的输出类型
  Index extends number = 0,
> = Steps extends readonly []
  ? true
  : Steps extends readonly [infer Head extends AnyStep, ...infer Tail extends readonly AnyStep[]]
  ? [_Prev] extends [never]                    // 第一步没有 Prev,跳过检查
    ? ValidateSteps<Tail, StepOutput<Head>, Add1<Index>>
    : StepInput<Head> extends _Prev            // 检查当前步骤输入是否兼容上一步输出
      ? ValidateSteps<Tail, StepOutput<Head>, Add1<Index>>
      : `Type error at step ${Index}: expected input assignable to '${Extract<_Prev, string>}'`
                                               // ↑ 模板字面量错误信息
  : never

Template literal types like `Type error at step ${Index}` make errors show as readable messages in the IDE. Add1<N> is a compile-time +1 operation:

type Add1<N extends number> =
  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16][N]

Using array indexing for addition works because the type system has no arithmetic primitive, but it does have array indexing.


Step 4: const type parameters + NoInfer

This is the key that makes the whole setup work:

export function fpipe<
  const Steps extends readonly AnyStep[],  // ← const (TS 5.0)
  Seed extends StepInput<Steps[0]>,
>(
  ...steps: Steps & (ValidateSteps<Steps> extends true ? Steps : never)
): (input: NoInfer<Seed>) => Promise<PipelineOutput<Steps, Seed>>

const type parameters (TS 5.0)

Without const, TypeScript widens the tuple type:

// 没有 const:Steps 被推断为 AnyStep[](信息丢失)
// 有 const:Steps 被推断为精确的元组 readonly [(n: number) => number, ...]

const tells TypeScript to preserve the full tuple shape so recursive types can work correctly.

NoInfer<T> (TS 5.4)

): (input: NoInfer<Seed>) => Promise<...>

NoInfer<T> tells TypeScript: “do not infer Seed from this position.”

That fixes a subtle problem: without NoInfer, TypeScript can infer Seed backwards from the return function call site, which can cause type cycles or accidental widening. NoInfer forces Seed to be inferred only from the input type of Steps[0], making behavior predictable.


Demo

Correctly inferred typing:

const p = fpipe(
  (x: number) => x * 2,
  (n: number) => `val: ${n}`,
)
// p: (input: number) => Promise<string> ✓

Error on incompatible typing:

const p = fpipe(
  (x: number) => x * 2,
  (s: string) => s.length,  // 传入的是 number,这里期望 string
)
// 错误:Argument of type '...' is not assignable to parameter of type 'never'
// 因为 ValidateSteps 返回了字符串(错误信息)而不是 true

$$ injected step typing is also correct:

const p = fpipe(
  (x: number) => x * 2,
  ($$: Prev<number>, bonus = 5) => $$ + bonus,  // $$ 类型 = number ✓
)
// p: (input: number) => Promise<number> ✓

Runtime implementation (just 5 lines)

Interestingly, the type-level code is around 80 lines, while the runtime implementation is very small:

export function fpipe(...steps: AnyStep[]): (input: unknown) => Promise<unknown> {
  return async (input: unknown) => {
    let current: unknown = input
    for (const step of steps) {
      current = await step(current as never)
    }
    return current
  }
}

All complexity is handled at the type level. This is one of the most interesting parts of TypeScript’s type system: runtime stays simple while type guarantees become strict.


Extra: Why the Pipeline class can infer params automatically

The functional fpipe(...) API requires manually annotated step types, but the Pipeline class does not:

// Pipeline 类:自动推断 ✅
new Pipeline<number>()
  .pipe(n => n * 2)   // n: number,不用写
  .pipe(n => `${n}`)  // n: number,不用写

// fpipe:需要手动标注 ❌
fpipe(
  (x: number) => x * 2,
  (n: number) => `${n}`,  // 必须写 n: number
)

The reason is that TypeScript contextual typing works during incremental calls, but not with rest parameters.

.pipe(n => ...) is a standalone call, where TOutput is already fixed, so TypeScript can infer n correctly. fpipe(s1, s2, s3) passes all three arguments in one call, and TypeScript has no mechanism to first determine s1’s output and then infer s2’s input.

Another key point is Overload vs Union:

// ❌ Union — TS 不知道对哪个类型做 contextual typing
pipe<TNext>(step: PlainStep<TOutput, TNext> | PrevStep<TOutput, TNext>): ...

// ✅ Overload — TS 对每个候选分别尝试,找到匹配的
pipe<TNext>(step: PlainStep<TOutput, TNext>): ...
pipe<TNext>(step: PrevStep<TOutput, TNext>): ...

With overloads split, both kinds of steps (plain steps and $$-aware steps) can be inferred automatically.


Wrap-up

The techniques used:

The code is in bkmashiro/typed-pipeline.