Chapter 3

Zod schemas as the contract

Every task declares two Zod schemas: what the Lambda accepts, and what it returns. They do three jobs.

The input schema checks the payload

TypedPayloadMapping<Schema> turns the input schema into the type the payload callback must return. Every field accepts either a literal value or a Ref to one — which is what lets you mix constants and references freely:

type 
type Mapping = {
    bucket: string | Ref<string> | IntrinsicExpr<string>;
    key: string | Ref<string> | IntrinsicExpr<string>;
} & {}
Mapping
= TypedPayloadMapping<typeof
const RunMediaInfoInput: z.ZodObject<{
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
RunMediaInfoInput
>;
type BucketField =
type Mapping = {
    bucket: string | Ref<string> | IntrinsicExpr<string>;
    key: string | Ref<string> | IntrinsicExpr<string>;
} & {}
Mapping
['bucket'];
type BucketField = string | Ref<string> | IntrinsicExpr<string>

So both of these are legal for bucket: a plain string, or a Ref<string> pointing at somewhere in the context.

The match must be exact. A missing required field is a compile error, and so is an extra one:

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(
'runMediaInfo', { inputSchema:
const RunMediaInfoInput: z.ZodObject<{
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
RunMediaInfoInput
,
outputSchema:
const RunMediaInfoOutput: z.ZodObject<{
    mediaInfo: z.ZodObject<{
        width: z.ZodNumber;
        height: z.ZodNumber;
    }, z.core.$strip>;
    assetType: z.ZodString;
}, z.core.$strip>
RunMediaInfoOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: '${lambda_arn}', }, (ctx: Proxied<Input>ctx) => ({ bucket: string | Ref<string> | IntrinsicExpr<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket, key: string | Ref<string> | IntrinsicExpr<string>key: ctx: Proxied<Input>ctx.key: Ref<string>key, bukcet: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket,
No overload matches this call. The last overload gave the following error. Type 'Ref<string>' is not assignable to type 'never'.
}) );

An extra field is almost always a typo, and it is the dangerous kind: it would be sent to the Lambda and silently ignored, while the field you meant to send is missing. The same check runs at build time too, so plain-JS callers get Payload field "bukcet" is not in the input schema rather than a mystery.

The output schema generates the ResultSelector

A Lambda's response arrives under $.Payload. Each output schema key becomes a "key.$": "$.Payload.key" entry, extracting the response into the state data:

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( 'runMediaInfo', { inputSchema:
const RunMediaInfoInput: z.ZodObject<{
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
RunMediaInfoInput
,
outputSchema:
const RunMediaInfoOutput: z.ZodObject<{
    mediaInfo: z.ZodObject<{
        width: z.ZodNumber;
        height: z.ZodNumber;
    }, z.core.$strip>;
    assetType: z.ZodString;
}, z.core.$strip>
RunMediaInfoOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: '${lambda_arn}', }, (ctx: Proxied<Input>ctx) => ({ 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();

produces:

{
  "ResultSelector": {
    "mediaInfo.$": "$.Payload.mediaInfo",
    "assetType.$": "$.Payload.assetType"
  }
}

You wrote the schema once and got the wiring for free.

Remapping with a typed resultSelector

The output schema describes what the Lambda actually returns, which is not always the shape you want in your context. Pass a resultSelector and you get a typed proxy of the output to reshape:

const 
const UploadOutput: z.ZodObject<{
    outputStorageRef: z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>;
}, z.core.$strip>
UploadOutput
= import zz.object({
outputStorageRef: z.ZodObject<{
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
outputStorageRef
: import zz.object({ bucket: z.ZodStringbucket: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), key: z.ZodStringkey: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }),
}); const
const builder: Widened<{
    url: string;
}, [], "upload", {
    storageRef: {
        bucket: string;
        key: string;
    };
}>
builder
= new
new SequenceBuilder<{
    url: string;
}, {
    url: string;
}, []>(): SequenceBuilder<{
    url: string;
}, {
    url: string;
}, []>
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
<{ url: stringurl: string }>().task(
'upload', { inputSchema: import zz.object({ url: z.ZodStringurl: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }), outputSchema:
const UploadOutput: z.ZodObject<{
    outputStorageRef: z.ZodObject<{
        bucket: z.ZodString;
        key: z.ZodString;
    }, z.core.$strip>;
}, z.core.$strip>
UploadOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: '${lambda_arn}', resultSelector: (
output: Proxied<{
    outputStorageRef: {
        bucket: string;
        key: string;
    };
}>
output
) => ({
storageRef: Proxied<{
    bucket: string;
    key: string;
}>
storageRef
:
output: Proxied<{
    outputStorageRef: {
        bucket: string;
        key: string;
    };
}>
output
.
outputStorageRef: Proxied<{
    bucket: string;
    key: string;
}>
outputStorageRef
,
}), }, (
ctx: Proxied<{
    url: string;
}>
ctx
) => ({ url: Ref<string>url:
ctx: Proxied<{
    url: string;
}>
ctx
.url: Ref<string>url })
); 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
const builder: Widened<{
    url: string;
}, [], "upload", {
    storageRef: {
        bucket: string;
        key: string;
    };
}>
builder
>;
type Ctx = {
    url: string;
    upload: {
        storageRef: {
            bucket: string;
            key: string;
        };
    };
}

The context gained storageRef, not the Lambda's outputStorageRef — the selector's return type is what lands in the context. output.outputStorageRef is itself checked against the output schema, so renaming a field the Lambda returns breaks here rather than at 2am. The emitted ASL is { "storageRef.$": "$.Payload.outputStorageRef" }.

Optional output fields force an explicit selector

The auto-generated selector references every output key, and JSONPath-mode ASL fails at runtime when a referenced key is absent. So a schema with .optional() fields cannot use it — the config stops typechecking:

const 
const TranscribeOutput: z.ZodObject<{
    transcript: z.ZodOptional<z.ZodString>;
    language: z.ZodString;
}, z.core.$strip>
TranscribeOutput
= import zz.object({
transcript: z.ZodOptional<z.ZodString>transcript: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodType<any, any, $ZodStringInternals<string>>.optional(): z.ZodOptional<z.ZodString>optional(), language: z.ZodStringlanguage: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), }); 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 = {
    videoId: string;
}
Input
>().task(
'transcribe', {
No overload matches this call. The last overload gave the following error. Argument of type '{ inputSchema: z.ZodObject<{ videoId: z.ZodString; }, z.core.$strip>; outputSchema: z.ZodObject<{ transcript: z.ZodOptional<z.ZodString>; language: z.ZodString; }, z.core.$strip>; functionArn: string; }' is not assignable to parameter of type 'LambdaTaskConfig<ZodObject<{ videoId: ZodString; }, $strip>, ZodObject<{ transcript: ZodOptional<ZodString>; language: ZodString; }, $strip>> & { ...; } & { ...; }'. Property ''output schema has optional fields, which the auto-generated ResultSelector would reference unconditionally — ASL errors at runtime on absent keys, so pass an explicit resultSelector'' is missing in type '{ inputSchema: z.ZodObject<{ videoId: z.ZodString; }, z.core.$strip>; outputSchema: z.ZodObject<{ transcript: z.ZodOptional<z.ZodString>; language: z.ZodString; }, z.core.$strip>; functionArn: string; }' but required in type '{ 'output schema has optional fields, which the auto-generated ResultSelector would reference unconditionally \u2014 ASL errors at runtime on absent keys, so pass an explicit resultSelector': "transcript"; }'.
inputSchema: import zz.object({ videoId: z.ZodStringvideoId: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }), outputSchema:
const TranscribeOutput: z.ZodObject<{
    transcript: z.ZodOptional<z.ZodString>;
    language: z.ZodString;
}, z.core.$strip>
TranscribeOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: '${lambda_arn}', }, () => ({ videoId: stringvideoId: 'v1' }) );

The fix is to select only what the Lambda always returns — or reshape however you like, since the selector is yours:

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 = {
    videoId: string;
}
Input
>()
.task( 'transcribe', { inputSchema: import zz.object({ videoId: z.ZodStringvideoId: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }), outputSchema:
const TranscribeOutput: z.ZodObject<{
    transcript: z.ZodOptional<z.ZodString>;
    language: z.ZodString;
}, z.core.$strip>
TranscribeOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: '${lambda_arn}',
resultSelector: (output: Proxied<{
    language: string;
    transcript?: string | undefined;
}>) => {
    language: Ref<string>;
}
resultSelector
: (
output: Proxied<{
    language: string;
    transcript?: string | undefined;
}>
output
) => ({ language: Ref<string>language:
output: Proxied<{
    language: string;
    transcript?: string | undefined;
}>
output
.language: Ref<string>language }),
}, (ctx: Proxied<Input>ctx) => ({ videoId: Ref<string>videoId: ctx: Proxied<Input>ctx.videoId: Ref<string>videoId }) ) .
SequenceBuilder<{ videoId: string; transcribe: { language: string; }; }, Input, [["transcribe", { language: string; }]]>.build(options?: {
    comment?: string;
}): AslStateMachine
Build the final ASL state machine structure. Wires up `Next` pointers between sequential states and sets `End: true` on the last state.
@paramoptions - Optional configuration for the state machine.@paramoptions.comment - A human-readable description of the state machine.@throwsIf the builder has no states.
build
();

This was a latent-failure trap before it was enforced, which is why it is a compile error rather than a lint rule.