Chapter 7

parallel: tuple typing, not unions

A Parallel state runs branches concurrently and produces an array: index 0 is branch 0's output, index 1 is branch 1's, and so on.

The naive typing is a union — (BranchA | BranchB)[]. That throws away exactly the information you need, because every index then has every branch's type and none of them safely. So branch outputs are typed as a tuple instead, and each index keeps its own shape.

const 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
>().parallel('process', [
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(
'extractFrames', { inputSchema:
const ExtractFramesInput: z.ZodObject<{
    step: z.ZodLiteral<"extract-frames">;
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
ExtractFramesInput
,
outputSchema:
const ExtractFramesOutput: z.ZodObject<{
    frameStorageRefs: z.ZodArray<z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>>;
}, z.core.$strip>
ExtractFramesOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx: Proxied<Input>ctx) => ({ step: "extract-frames"step: 'extract-frames' as type const = "extract-frames"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, }) ), 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(
'transcode', { inputSchema:
const TranscodeInput: z.ZodObject<{
    step: z.ZodLiteral<"transcode">;
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
TranscodeInput
,
outputSchema:
const TranscodeOutput: z.ZodObject<{
    previewStorageRef: z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>;
}, z.core.$strip>
TranscodeOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx: Proxied<Input>ctx) => ({ step: "transcode"step: 'transcode' as type const = "transcode"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 Ctx =
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 builder>;
type Ctx = {
    bucket: string;
    key: string;
    process: [{
        bucket: string;
        key: string;
        extractFrames: {
            frameStorageRefs: {
                bucket: string;
                key: string;
            }[];
        };
    }, {
        bucket: string;
        key: string;
        transcode: {
            previewStorageRef: {
                bucket: string;
                key: string;
            };
        };
    }];
}

Each index is that branch's delta — the keys it added beyond the shared base context. Branch 0 contributed extractFrames, branch 1 contributed transcode. Input's own keys are not repeated inside each element, because both branches started from the same base.

Reading the result downstream

Because the tuple survives, a later task reaches into a specific branch and the compiler checks it:

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
>()
.parallel('process', [ 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(
'extractFrames', { inputSchema:
const ExtractFramesInput: z.ZodObject<{
    step: z.ZodLiteral<"extract-frames">;
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
ExtractFramesInput
,
outputSchema:
const ExtractFramesOutput: z.ZodObject<{
    frameStorageRefs: z.ZodArray<z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>>;
}, z.core.$strip>
ExtractFramesOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx: Proxied<Input>ctx) => ({ step: "extract-frames"step: 'extract-frames' as type const = "extract-frames"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, }) ), 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(
'transcode', { inputSchema:
const TranscodeInput: z.ZodObject<{
    step: z.ZodLiteral<"transcode">;
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
TranscodeInput
,
outputSchema:
const TranscodeOutput: z.ZodObject<{
    previewStorageRef: z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>;
}, z.core.$strip>
TranscodeOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx: Proxied<Input>ctx) => ({ step: "transcode"step: 'transcode' as type const = "transcode"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( 'finalize', { inputSchema: FinalizeInput, outputSchema:
const FinalizeOutput: z.ZodObject<{
    assetId: z.ZodString;
}, z.core.$strip>
FinalizeOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx) => ({ step: "finalize"step: 'finalize' as type const = "finalize"const,
previewStorageRef: Proxied<{
    bucket: string;
    key: string;
}>
previewStorageRef
: ctx.process[1].
transcode: Proxied<{
    previewStorageRef: {
        bucket: string;
        key: string;
    };
}>
transcode
.
previewStorageRef: Proxied<{
    bucket: string;
    key: string;
}>
previewStorageRef
,
frameCount: numberframeCount: 30, }) ) .build();

That serializes to "previewStorageRef.$": "$.process[1].transcode.previewStorageRef".

Reaching into the wrong branch — ctx.process[0].transcode — is a compile error, and so is an out-of-range index like ctx.process[2] on a two-branch parallel.

Factory branches

Repeating new SequenceBuilder<Input>() per branch is noise, and it hard-codes the context type. Passing a callback instead gets a fresh builder already seeded with the current context, so branches follow automatically when an upstream task widens it:

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
>()
.parallel('process', [ (b: SequenceBuilder<Input, Input, []>b) => b: SequenceBuilder<Input, Input, []>b.task( 'extractFrames', { inputSchema:
const ExtractFramesInput: z.ZodObject<{
    step: z.ZodLiteral<"extract-frames">;
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
ExtractFramesInput
,
outputSchema:
const ExtractFramesOutput: z.ZodObject<{
    frameStorageRefs: z.ZodArray<z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>>;
}, z.core.$strip>
ExtractFramesOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: '${lambda_arn}', }, (ctx: Proxied<Input>ctx) => ({ step: "extract-frames"step: 'extract-frames' as type const = "extract-frames"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, }) ), // Both forms mix freely in one call. 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
>().pass('mark', () => ({ marked: booleanmarked: true })),
]) .build();

Per-index typing is identical either way.

Catching branch failures

parallel takes a catch list, like task does. The handler receives a builder seeded with the context plus the caught error at whatever resultPath you name:

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
>()
.parallel( 'process', [ (b: SequenceBuilder<Input, Input, []>b) => b: SequenceBuilder<Input, Input, []>b.task( 'extractFrames', { inputSchema:
const ExtractFramesInput: z.ZodObject<{
    step: z.ZodLiteral<"extract-frames">;
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
ExtractFramesInput
,
outputSchema:
const ExtractFramesOutput: z.ZodObject<{
    frameStorageRefs: z.ZodArray<z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>>;
}, z.core.$strip>
ExtractFramesOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx: Proxied<Input>ctx) => ({ step: "extract-frames"step: 'extract-frames' as type const = "extract-frames"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, }) ), ], { catch?: CatchConfig<Input, "error">[] | undefinedcatch: [ { CatchConfig<Ctx, Key extends string = never>.errorEquals: AslErrorName[]errorEquals: ['States.TaskFailed'], CatchConfig<Input, "error">.resultPath?: "$.error" | undefinedresultPath: '$.error', CatchConfig<Input, "error">.handler: (b: SequenceBuilder<Input & Record<"error", AslCatchErrorOutput>, Input & Record<"error", AslCatchErrorOutput>, []>) => AnyBuilderhandler: (b: SequenceBuilder<Input & Record<"error", AslCatchErrorOutput>, Input & Record<"error", AslCatchErrorOutput>, []>b) => b: SequenceBuilder<Input & Record<"error", AslCatchErrorOutput>, Input & Record<"error", AslCatchErrorOutput>, []>b.task( 'handleError', { inputSchema:
const HandleErrorInput: z.ZodObject<{
    step: z.ZodLiteral<"handle-error">;
    errorInfo: z.ZodUnknown;
}, z.core.$strip>
HandleErrorInput
,
outputSchema:
const HandleErrorOutput: z.ZodObject<{
    recovered: z.ZodBoolean;
}, z.core.$strip>
HandleErrorOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx: Proxied<Input & Record<"error", AslCatchErrorOutput>>ctx) => ({ step: "handle-error"step: 'handle-error' as type const = "handle-error"const, errorInfo: Proxied<AslCatchErrorOutput>errorInfo: ctx: Proxied<Input & Record<"error", AslCatchErrorOutput>>ctx.error: Proxied<AslCatchErrorOutput>error, }) ), }, ], } ) .build();

The handler is inlined as a sibling state and the Catch entry's Next points at it, so there is no separate wiring step. ctx.error is typed as the caught error rather than unknown, which means ctx.error.Cause is a usable Ref<string>.