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:fetchUsertakes{ userId: string }and returns{ name: string; tier: 'FREE' | 'PRO' }.calculateDiscountreads{ tier: string }and returns{ discount: number }.createInvoicereads{ userId: string; name: string; discount: number }and returns{ invoiceId: string }.
- The God Context: Define a giant
OrderPipelineContextinterface upfront where every field produced by later steps is markedoptional?:, 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.
@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:
Step.create(), TypeScript infers TName as a string literal type ('fetchUser'), not just string:
'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>:
MergeStepContext is written this way:
- Tuple wrapping (
[TOut] extends [...]): WrappingTOutin a 1-tuple prevents conditional type distribution ifTOuthappens to be a union (such as{ cached: true; data: string } | void). - Side-effect steps (
[TOut] extends [void | undefined]): Many workflow steps—such as logging, metrics, or sending an email—returnvoid. Those steps leave the accumulated contextTAccuntouched. - Key eviction before intersection (
Omit<TAcc, keyof TOut> & TOut): By omittingkeyof TOutfrom the prior accumulated contextTAccbefore intersecting& TOut, downstream steps cleanly narrow or overwrite upstream keys without ever collapsing a property type tonever.
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:
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:
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:
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
- Type definitions in
@siglo/core:packages/core/src/features/step/index.ts - TypeScript Reference Guide: TypeScript Reference
