Skip to main content

How TypeScript Accumulates Step Types Across Pipelines

One of the oldest ergonomics problems in TypeScript pipeline libraries is context typing across independent steps. Suppose you have three decoupled steps in an order pipeline:
  1. fetchUser takes { userId: string } and returns { name: string; tier: 'FREE' | 'PRO' }.
  2. calculateDiscount reads { tier: string } and returns { discount: number }.
  3. createInvoice reads { userId: string; name: string; discount: number } and returns { invoiceId: string }.
In most workflow engines or middleware chains, you end up choosing between two painful compromises:
  • The God Context: Define a giant OrderPipelineContext interface upfront where every field produced by later steps is marked optional?:, littering downstream steps with non-null assertions (ctx.discount!).
  • Manual Plumbing: Write a wrapper function that manually awaits each step and threads the outputs into the next call, losing declarative graph inspection, retries, and automatic Saga rollbacks.
When designing Siglo (@siglo/core), we wanted each step to remain completely decoupled while giving the composed flow() exact compile-time knowledge of both the accumulated flat context and the per-step execution history ($steps). Here is how TypeScript’s type system makes that work under the hood.

1. Capturing Literal Step Names with Step<TInput, TOutput, TName>

Every step in Siglo carries three type parameters:
When you define a step with Step.create(), TypeScript infers TName as a string literal type ('fetchUser'), not just string:
Capturing 'fetchUser' as a literal type parameter N1 extends string is the key that unlocks typed step history (result.$steps.fetchUser) later in the pipeline.

2. Merging Contexts Without Duplicate Property Collisions (MergeStepContext)

At runtime, when a step returns a plain object { name: 'Alice', tier: 'PRO' }, Siglo merges those keys into the active workflow context. If a later step returns an updated property with the same key (for example, overriding { status: 'PENDING' } with { status: 'APPROVED' }), the later step’s property wins. If you model this in TypeScript with a naive intersection (TAcc & TOut), overriding a property with a different type produces never (e.g., { status: 'PENDING' } & { status: 'APPROVED' } makes status: never). Instead, Siglo uses MergeStepContext<TAcc, TOut>:
Let’s break down why each piece of MergeStepContext is written this way:
  1. Tuple wrapping ([TOut] extends [...]): Wrapping TOut in a 1-tuple prevents conditional type distribution if TOut happens to be a union (such as { cached: true; data: string } | void).
  2. Side-effect steps ([TOut] extends [void | undefined]): Many workflow steps—such as logging, metrics, or sending an email—return void. Those steps leave the accumulated context TAcc untouched.
  3. Key eviction before intersection (Omit<TAcc, keyof TOut> & TOut): By omitting keyof TOut from the prior accumulated context TAcc before intersecting & TOut, downstream steps cleanly narrow or overwrite upstream keys without ever collapsing a property type to never.

3. Preserving Shadowed Outputs in $steps (FlowContextResult)

What if two steps intentionally return a field with the same name—for example, reserveWarehouseA and reserveWarehouseB both return { reservationId: string }—and a downstream step needs both? alongside the flat merged context, Siglo records every step’s raw output under result.$steps[stepName]. At the type level, FlowContextResult combines the flat context with a mapped $steps record:
In flow(), each step’s literal name N1, N2, N3 and output type T1Out, T2Out, T3Out are folded into both MergeStepContext and the $steps history intersection:
Here is what the developer experiences in their IDE:

4. Extending Type Inference to Schema Validation (StandardSchemaV1) and @siglo/ai

This exact same type accumulation mechanism powers @siglo/ai. Because Siglo supports the Standard Schema (StandardSchemaV1) specification—implemented by Zod, Valibot, and ArkType—an aiStep infers its structured output type directly from your schema without tying @siglo/ai to a specific validation library:
When you drop an aiStep into a flow(), StandardSchemaV1.InferOutput<S> becomes TOut for that step, automatically merging the validated LLM output and token/cost telemetry ($ai) into the downstream TypeScript context.

Explore the Code