Chapter 4
SequenceBuilder: context accumulation
SequenceBuilder is the core API. Each .task() call does two things at once:
- At runtime it returns a new builder with the ASL state definition appended. The original builder is never mutated.
- At compile time the new builder carries a wider context type that includes the new task's output.
So after .task('loadFile', …), the context type grows from { bucket, key } to { bucket, key, loadFile: LoadFileOutput }. The next task's payload callback can then reach into ctx.loadFile.* with autocomplete and type checking.
A single task
Every task needs three things: a Zod schema for what the Lambda accepts, a Zod schema for what it returns, and the ARN to invoke. The payload callback receives ctx and returns an object matching inputSchema.
const const asl: AslStateMachineasl = new new SequenceBuilder<Input, Input, []>(): SequenceBuilder<Input, Input, []>Builds a sequence of Step Function states with type-safe context accumulation.
Each `.task()` call appends a Lambda Task state and expands the context
type with that state's output. The payload callback receives a typed proxy
of the current context, so every ref is validated at compile time.
`.build()` wires up `Next`/`End` pointers and returns the ASL structure.SequenceBuilder<type Input = {
bucket: string;
key: string;
}
Input>()
.task(
'loadFile',
{
inputSchema: const LoadFileInput: z.ZodObject<{
step: z.ZodLiteral<"load-file">;
bucket: z.ZodString;
key: z.ZodString;
}, z.core.$strip>
LoadFileInput,
outputSchema: const LoadFileOutput: z.ZodObject<{
fileUpload: z.ZodObject<{
id: z.ZodString;
filename: z.ZodString;
}, z.core.$strip>;
}, z.core.$strip>
LoadFileOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(ctx: Proxied<Input>ctx) => ({
step: "load-file"step: 'load-file' as type const = "load-file"const,
bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket,
key: Ref<string>key: ctx: Proxied<Input>ctx.key: Ref<string>key,
})
)
.build();
Hover ctx above. It isn't the plain Input object — it's a Proxied<Input>, where every leaf is a Ref<T> carrying both a JSONPath and the type that path points at. That's what makes bucket: ctx.bucket serialize to "bucket.$": "$.bucket" instead of a literal string.
The context widens with each task
This is the part worth seeing rather than reading. Hover CtxAfterLoad:
const const builder: Widened<Input, [], "loadFile", {
fileUpload: {
id: string;
filename: string;
};
}>
builder = new new SequenceBuilder<Input, Input, []>(): SequenceBuilder<Input, Input, []>Builds a sequence of Step Function states with type-safe context accumulation.
Each `.task()` call appends a Lambda Task state and expands the context
type with that state's output. The payload callback receives a typed proxy
of the current context, so every ref is validated at compile time.
`.build()` wires up `Next`/`End` pointers and returns the ASL structure.SequenceBuilder<type Input = {
bucket: string;
key: string;
}
Input>().task(
'loadFile',
{
inputSchema: const LoadFileInput: z.ZodObject<{
step: z.ZodLiteral<"load-file">;
bucket: z.ZodString;
key: z.ZodString;
}, z.core.$strip>
LoadFileInput,
outputSchema: const LoadFileOutput: z.ZodObject<{
fileUpload: z.ZodObject<{
id: z.ZodString;
filename: z.ZodString;
}, z.core.$strip>;
}, z.core.$strip>
LoadFileOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(ctx: Proxied<Input>ctx) => ({
step: "load-file"step: 'load-file' as type const = "load-file"const,
bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket,
key: Ref<string>key: ctx: Proxied<Input>ctx.key: Ref<string>key,
})
);
type CtxAfterLoad = type InferContext<B extends AnyBuilder> = B extends {
_ctx: infer Ctx;
} ? Ctx : never
Extract the accumulated context type from a SequenceBuilder.InferContext<typeof const builder: Widened<Input, [], "loadFile", {
fileUpload: {
id: string;
filename: string;
};
}>
builder>;
The loadFile key appeared because the task was named loadFile, and its shape came from LoadFileOutput. Nothing was declared twice.
Chaining
Now the second task can reference the first one's output, and the compiler knows the path exists:
const const asl: AslStateMachineasl = new new SequenceBuilder<Input, Input, []>(): SequenceBuilder<Input, Input, []>Builds a sequence of Step Function states with type-safe context accumulation.
Each `.task()` call appends a Lambda Task state and expands the context
type with that state's output. The payload callback receives a typed proxy
of the current context, so every ref is validated at compile time.
`.build()` wires up `Next`/`End` pointers and returns the ASL structure.SequenceBuilder<type Input = {
bucket: string;
key: string;
}
Input>()
.task(
'loadFile',
{
inputSchema: const LoadFileInput: z.ZodObject<{
step: z.ZodLiteral<"load-file">;
bucket: z.ZodString;
key: z.ZodString;
}, z.core.$strip>
LoadFileInput,
outputSchema: const LoadFileOutput: z.ZodObject<{
fileUpload: z.ZodObject<{
id: z.ZodString;
filename: z.ZodString;
}, z.core.$strip>;
}, z.core.$strip>
LoadFileOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(ctx: Proxied<Input>ctx) => ({
step: "load-file"step: 'load-file' as type const = "load-file"const,
bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket,
key: Ref<string>key: ctx: Proxied<Input>ctx.key: Ref<string>key,
})
)
.task(
'analyze',
{
inputSchema: const AnalyzeInput: z.ZodObject<{
step: z.ZodLiteral<"analyze">;
fileId: z.ZodString;
filename: z.ZodString;
}, z.core.$strip>
AnalyzeInput,
outputSchema: const AnalyzeOutput: z.ZodObject<{
width: z.ZodNumber;
height: z.ZodNumber;
duration: z.ZodNumber;
}, z.core.$strip>
AnalyzeOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(ctx: Proxied<{
bucket: string;
key: string;
loadFile: {
fileUpload: {
id: string;
filename: string;
};
};
}>
ctx) => ({
step: "analyze"step: 'analyze' as type const = "analyze"const,
fileId: Ref<string>fileId: ctx: Proxied<{
bucket: string;
key: string;
loadFile: {
fileUpload: {
id: string;
filename: string;
};
};
}>
ctx.loadFile: Proxied<{
fileUpload: {
id: string;
filename: string;
};
}>
loadFile.fileUpload: Proxied<{
id: string;
filename: string;
}>
fileUpload.id: Ref<string>id,
filename: Ref<string>filename: ctx: Proxied<{
bucket: string;
key: string;
loadFile: {
fileUpload: {
id: string;
filename: string;
};
};
}>
ctx.loadFile: Proxied<{
fileUpload: {
id: string;
filename: string;
};
}>
loadFile.fileUpload: Proxied<{
id: string;
filename: string;
}>
fileUpload.filename: Ref<string>filename,
})
)
.build();
That compiles to the wiring you'd otherwise hand-write, with Next pointers threaded and ResultPath set per task:
{
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${lambda_arn}",
"Payload": {
"step": "analyze",
"fileId.$": "$.loadFile.fileUpload.id",
"filename.$": "$.loadFile.fileUpload.filename"
}
},
"ResultSelector": { "width.$": "$.Payload.width" },
"ResultPath": "$.analyze",
"End": true
}
What happens when you get it wrong
Rename a field in the Lambda's output schema and every reference to it stops compiling. This is the whole point of the library — here the error is real, produced by tsc when this page was built:
new new SequenceBuilder<Input, Input, []>(): SequenceBuilder<Input, Input, []>Builds a sequence of Step Function states with type-safe context accumulation.
Each `.task()` call appends a Lambda Task state and expands the context
type with that state's output. The payload callback receives a typed proxy
of the current context, so every ref is validated at compile time.
`.build()` wires up `Next`/`End` pointers and returns the ASL structure.SequenceBuilder<type Input = {
bucket: string;
key: string;
}
Input>()
.task(
'loadFile',
{
inputSchema: const LoadFileInput: z.ZodObject<{
step: z.ZodLiteral<"load-file">;
bucket: z.ZodString;
key: z.ZodString;
}, z.core.$strip>
LoadFileInput,
outputSchema: const LoadFileOutput: z.ZodObject<{
fileUpload: z.ZodObject<{
id: z.ZodString;
filename: z.ZodString;
}, z.core.$strip>;
}, z.core.$strip>
LoadFileOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(ctx: Proxied<Input>ctx) => ({
step: "load-file"step: 'load-file' as type const = "load-file"const,
bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket,
key: Ref<string>key: ctx: Proxied<Input>ctx.key: Ref<string>key,
})
)
.task(
'analyze',
{
inputSchema: const AnalyzeInput: z.ZodObject<{
step: z.ZodLiteral<"analyze">;
fileId: z.ZodString;
filename: z.ZodString;
}, z.core.$strip>
AnalyzeInput,
outputSchema: const AnalyzeOutput: z.ZodObject<{
width: z.ZodNumber;
height: z.ZodNumber;
}, z.core.$strip>
AnalyzeOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(ctx: Proxied<{
bucket: string;
key: string;
loadFile: {
fileUpload: {
id: string;
filename: string;
};
};
}>
ctx) => ({
step: "analyze"step: 'analyze' as type const = "analyze"const,
fileId: anyfileId: ctx: Proxied<{
bucket: string;
key: string;
loadFile: {
fileUpload: {
id: string;
filename: string;
};
};
}>
ctx.loadFile: Proxied<{
fileUpload: {
id: string;
filename: string;
};
}>
loadFile.upload.id, filename: Ref<string>filename: ctx: Proxied<{
bucket: string;
key: string;
loadFile: {
fileUpload: {
id: string;
filename: string;
};
};
}>
ctx.loadFile: Proxied<{
fileUpload: {
id: string;
filename: string;
};
}>
loadFile.fileUpload: Proxied<{
id: string;
filename: string;
}>
fileUpload.filename: Ref<string>filename,
})
);
In raw ASL, "fileId.$": "$.loadFile.upload.id" is a perfectly valid JSON string. It fails at 2am in production with JSONPath '$.loadFile.upload.id' returned no results.