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'];
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.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, })
);
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.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.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.InferContext<typeof const builder: Widened<{
url: string;
}, [], "upload", {
storageRef: {
bucket: string;
key: string;
};
}>
builder>;
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.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}',
},
() => ({ 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.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.build();
This was a latent-failure trap before it was enforced, which is why it is a compile error rather than a lint rule.