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.
@example```ts type Input = { bucket: string; key: string }; const result = new SequenceBuilder<Input>() .task('runMediaInfo', { inputSchema: RunMediainfoStepInput, outputSchema: RunMediainfoStepOutput, functionArn: LAMBDA_ARN, }, ctx => ({ bucket: ctx.bucket, key: ctx.key, })) .task('createVideo', { inputSchema: CreateVideoInput, outputSchema: CreateVideoOutput, functionArn: LAMBDA_ARN, }, ctx => ({ mediaInfo: ctx.runMediaInfo.mediaInfo, })) .build(); ```
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.
@example```ts type Input = { bucket: string; key: string }; const result = new SequenceBuilder<Input>() .task('runMediaInfo', { inputSchema: RunMediainfoStepInput, outputSchema: RunMediainfoStepOutput, functionArn: LAMBDA_ARN, }, ctx => ({ bucket: ctx.bucket, key: ctx.key, })) .task('createVideo', { inputSchema: CreateVideoInput, outputSchema: CreateVideoOutput, functionArn: LAMBDA_ARN, }, ctx => ({ mediaInfo: ctx.runMediaInfo.mediaInfo, })) .build(); ```
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.
@example```ts const builder = new SequenceBuilder<{ bucket: string }>() .task('runMediaInfo', config, ctx => ({ ... })) .task('createVideo', config, ctx => ({ ... })); type Output = InferContext<typeof builder>; // = { bucket: string; runMediaInfo: MediaInfoOutput; createVideo: VideoOutput } ```
InferContext
<typeof
const builder: Widened<Input, [], "loadFile", {
    fileUpload: {
        id: string;
        filename: string;
    };
}>
builder
>;
type CtxAfterLoad = {
    bucket: string;
    key: string;
    loadFile: {
        fileUpload: {
            id: string;
            filename: string;
        };
    };
}

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.
@example```ts type Input = { bucket: string; key: string }; const result = new SequenceBuilder<Input>() .task('runMediaInfo', { inputSchema: RunMediainfoStepInput, outputSchema: RunMediainfoStepOutput, functionArn: LAMBDA_ARN, }, ctx => ({ bucket: ctx.bucket, key: ctx.key, })) .task('createVideo', { inputSchema: CreateVideoInput, outputSchema: CreateVideoOutput, functionArn: LAMBDA_ARN, }, ctx => ({ mediaInfo: ctx.runMediaInfo.mediaInfo, })) .build(); ```
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.
@example```ts type Input = { bucket: string; key: string }; const result = new SequenceBuilder<Input>() .task('runMediaInfo', { inputSchema: RunMediainfoStepInput, outputSchema: RunMediainfoStepOutput, functionArn: LAMBDA_ARN, }, ctx => ({ bucket: ctx.bucket, key: ctx.key, })) .task('createVideo', { inputSchema: CreateVideoInput, outputSchema: CreateVideoOutput, functionArn: LAMBDA_ARN, }, ctx => ({ mediaInfo: ctx.runMediaInfo.mediaInfo, })) .build(); ```
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,
Property 'upload' does not exist on type 'Proxied<{ fileUpload: { id: string; filename: string; }; }>'.
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.