Chapter 9
choice: branching that converges itself
A Choice state evaluates conditions and routes to the matching branch. In raw ASL you then have to wire every branch's terminal state back to wherever execution resumes — tedious, and easy to get wrong in a way that only shows up on the branch you didn't test.
The builder does that wiring. Non-terminal branches get their End: true replaced with a Next pointing at the state after the choice; Fail states stay terminal; empty branches skip straight to the convergence point.
Conditions
A condition compares a typed variable against a value. The variable is a ref, so the field has to exist:
type type Ctx = {
assetType: string;
frameCount: number;
isReady: boolean;
}
Ctx = { assetType: stringassetType: string; frameCount: numberframeCount: number; isReady: booleanisReady: boolean };
const const ctx: Proxied<Ctx>ctx = createProxy<Ctx>(path?: string[]): Proxied<Ctx>Creates a typed Proxy that records property access as JSONPath segments.
Every property access on the returned proxy returns a new proxy with
the property name appended to the path. Numeric keys (e.g. `[0]`) are
recorded as array index segments.createProxy<type Ctx = {
assetType: string;
frameCount: number;
isReady: boolean;
}
Ctx>();
const const strCond: ChoiceConditionstrCond: ChoiceCondition = {
variable: Ref<string>variable: const ctx: Proxied<Ctx>ctx.assetType: Ref<string>assetType,
stringEquals: stringstringEquals: 'video',
};
// → { Variable: '$.assetType', StringEquals: 'video' }
const const numCond: ChoiceConditionnumCond: ChoiceCondition = {
variable: Ref<number>variable: const ctx: Proxied<Ctx>ctx.frameCount: Ref<number>frameCount,
numericGreaterThan: numbernumericGreaterThan: 0,
};
// → { Variable: '$.frameCount', NumericGreaterThan: 0 }
The operator has to agree with the variable's type. { variable: ctx.frameCount, stringEquals: 'x' } does not compile — stringEquals wants a string-typed ref. Only the is* type tests accept a variable of any type, since checking the type is the point.
and, or and not nest arbitrarily:
const const ctx: Proxied<{
type: string;
size: number;
}>
ctx = createProxy<{
type: string;
size: number;
}>(path?: string[]): Proxied<{
type: string;
size: number;
}>
Creates a typed Proxy that records property access as JSONPath segments.
Every property access on the returned proxy returns a new proxy with
the property name appended to the path. Numeric keys (e.g. `[0]`) are
recorded as array index segments.createProxy<{ type: stringtype: string; size: numbersize: number }>();
const const compound: ChoiceConditioncompound: ChoiceCondition = {
and: ChoiceCondition[]and: [
{ variable: Ref<string>variable: const ctx: Proxied<{
type: string;
size: number;
}>
ctx.type: Ref<string>type, stringEquals: stringstringEquals: 'video' },
{ not: ChoiceConditionnot: { variable: Ref<number>variable: const ctx: Proxied<{
type: string;
size: number;
}>
ctx.size: Ref<number>size, numericLessThan: numbernumericLessThan: 100 } },
],
};
{
"And": [
{ "Variable": "$.type", "StringEquals": "video" },
{ "Not": { "Variable": "$.size", "NumericLessThan": 100 } }
]
}
Comparing two fields
The plain operators compare against a literal. The *Path variants compare against another value in the state data, and both sides are typed refs that must agree:
const const ctx: Proxied<{
produced: number;
expected: number;
}>
ctx = createProxy<{
produced: number;
expected: number;
}>(path?: string[]): Proxied<{
produced: number;
expected: number;
}>
Creates a typed Proxy that records property access as JSONPath segments.
Every property access on the returned proxy returns a new proxy with
the property name appended to the path. Numeric keys (e.g. `[0]`) are
recorded as array index segments.createProxy<{ produced: numberproduced: number; expected: numberexpected: number }>();
const const condition: ChoiceConditioncondition: ChoiceCondition = {
variable: Ref<number>variable: const ctx: Proxied<{
produced: number;
expected: number;
}>
ctx.produced: Ref<number>produced,
numericLessThanPath: Ref<number>numericLessThanPath: const ctx: Proxied<{
produced: number;
expected: number;
}>
ctx.expected: Ref<number>expected,
};
// → { Variable: '$.produced', NumericLessThanPath: '$.expected' }
Pointing numericLessThanPath at a string field is a compile error. Every comparison operator has a Path variant except stringMatches — the ASL spec defines no StringMatchesPath.
Branching and convergence
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 = {
assetType: string;
}
Input>()
.SequenceBuilder<Input, Input, []>.choice(name: string, configFn: (ctx: Proxied<Input>) => ChoiceConfig<Input>): SequenceBuilder<Input, Input, []> (+1 overload)Append a Choice state to the sequence.
A Choice state evaluates conditions and routes execution to the
matching branch. All non-terminal branches automatically converge
to the next state after the choice (implicit convergence).choice('checkType', (ctx: Proxied<Input>ctx) => ({
ChoiceConfig<Input>.choices: ChoiceBranch<Input>[]choices: [
{
ChoiceBranch<Input>.when: ChoiceConditionwhen: { variable: Ref<string>variable: ctx: Proxied<Input>ctx.assetType: Ref<string>assetType, stringEquals: stringstringEquals: 'video' },
ChoiceBranch<Input>.then: (b: SequenceBuilder<Input, Input, []>) => AnyBuilderthen: (b: SequenceBuilder<Input, Input, []>b) =>
b: SequenceBuilder<Input, Input, []>b.task(
'processVideo',
{
inputSchema: const ProcessVideoInput: z.ZodObject<{
step: z.ZodLiteral<"process-video">;
assetType: z.ZodString;
}, z.core.$strip>
ProcessVideoInput,
outputSchema: const ProcessVideoOutput: z.ZodObject<{
videoId: z.ZodString;
}, z.core.$strip>
ProcessVideoOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(c: Proxied<Input>c) => ({ step: "process-video"step: 'process-video' as type const = "process-video"const, assetType: Ref<string>assetType: c: Proxied<Input>c.assetType: Ref<string>assetType })
),
},
{
ChoiceBranch<Input>.when: ChoiceConditionwhen: { variable: Ref<string>variable: ctx: Proxied<Input>ctx.assetType: Ref<string>assetType, stringEquals: stringstringEquals: 'image' },
ChoiceBranch<Input>.then: (b: SequenceBuilder<Input, Input, []>) => AnyBuilderthen: (b: SequenceBuilder<Input, Input, []>b) =>
b: SequenceBuilder<Input, Input, []>b.task(
'processImage',
{
inputSchema: const ProcessImageInput: z.ZodObject<{
step: z.ZodLiteral<"process-image">;
assetType: z.ZodString;
}, z.core.$strip>
ProcessImageInput,
outputSchema: const ProcessImageOutput: z.ZodObject<{
imageId: z.ZodString;
}, z.core.$strip>
ProcessImageOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(c: Proxied<Input>c) => ({ step: "process-image"step: 'process-image' as type const = "process-image"const, assetType: Ref<string>assetType: c: Proxied<Input>c.assetType: Ref<string>assetType })
),
},
],
ChoiceConfig<Input>.default?: ((b: SequenceBuilder<Input, Input, []>) => AnyBuilder) | undefineddefault: (b: SequenceBuilder<Input, Input, []>b) =>
b: SequenceBuilder<Input, Input, []>b.SequenceBuilder<Input, Input, []>.fail(name: string, config: {
error?: string;
cause?: string;
}): SequenceBuilder<Input, Input, []>
Append a Fail state to the sequence.
A Fail state terminates the execution with an error. It has no
`Next` or `End` field in ASL.fail('unknownType', {
error?: string | undefinederror: 'UnknownAssetType',
cause?: string | undefinedcause: 'The asset type is not supported',
}),
}))
// Both branches lead here — this is the convergence point.
.task(
'finalize',
{
inputSchema: const FinalizeInput: z.ZodObject<{
step: z.ZodLiteral<"finalize">;
assetType: z.ZodString;
}, z.core.$strip>
FinalizeInput,
outputSchema: const FinalizeOutput: z.ZodObject<{
done: z.ZodBoolean;
}, z.core.$strip>
FinalizeOutput,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN,
},
(ctx: Proxied<Input>ctx) => ({ step: "finalize"step: 'finalize' as type const = "finalize"const, assetType: Ref<string>assetType: ctx: Proxied<Input>ctx.assetType: Ref<string>assetType })
)
.SequenceBuilder<{ assetType: string; finalize: { done: boolean; }; }, Input, [["finalize", { done: boolean; }]]>.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();
What comes out:
CheckType.Choices[0].NextisProcessVideo,[1].NextisProcessImage,DefaultisUnknownType.ProcessVideo.NextandProcessImage.Nextare bothFinalize— theirEnd: truewas rewritten.UnknownTypeis aFailstate, so it keeps noNextand noEnd.Finalize.Endistrue.
An empty branch — default: (b) => b — skips directly to the convergence point, which is how you express "do nothing extra in this case" without an empty Pass state.
Asserting what all branches produce
The builder can't see inside branches to know what they add to the context, so by default a choice contributes nothing. When every branch sets the same field, declare it with the type parameter:
const const builder: Rebased<Input & {
isWholeVideo: boolean;
}>
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 = {
sceneCount: number;
}
Input>().choice<{
isWholeVideo: booleanisWholeVideo: boolean;
}>('checkSceneCount', (ctx: Proxied<Input>ctx) => ({
ChoiceConfig<Input>.choices: ChoiceBranch<Input>[]choices: [
{
ChoiceBranch<Input>.when: ChoiceConditionwhen: { variable: Ref<number>variable: ctx: Proxied<Input>ctx.sceneCount: Ref<number>sceneCount, numericEquals: numbernumericEquals: 1 },
ChoiceBranch<Input>.then: (b: SequenceBuilder<Input, Input, []>) => AnyBuilderthen: (b: SequenceBuilder<Input, Input, []>b) =>
b: SequenceBuilder<Input, Input, []>b.pass('setWhole', { result: booleanresult: true, resultPath: "$.isWholeVideo"resultPath: '$.isWholeVideo' }),
},
],
ChoiceConfig<Input>.default?: ((b: SequenceBuilder<Input, Input, []>) => AnyBuilder) | undefineddefault: (b: SequenceBuilder<Input, Input, []>b) =>
b: SequenceBuilder<Input, Input, []>b.pass('setNotWhole', { result: booleanresult: false, resultPath: "$.isWholeVideo"resultPath: '$.isWholeVideo' }),
}));
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: Rebased<Input & {
isWholeVideo: boolean;
}>
builder>;
This is an assertion, not an inference — you are promising that every path through the choice sets isWholeVideo. It is the one place in the library where the compiler takes your word for it, so it is worth a second look when a branch is added later.