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.
@example```ts type Ctx = { foo: { bar: string[] } }; const proxy = createProxy<Ctx>(); const ref = proxy.foo.bar; pathOf(ref); // "$.foo.bar" ```
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.
@example```ts type Ctx = { foo: { bar: string[] } }; const proxy = createProxy<Ctx>(); const ref = proxy.foo.bar; pathOf(ref); // "$.foo.bar" ```
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.
@example```ts type Ctx = { foo: { bar: string[] } }; const proxy = createProxy<Ctx>(); const ref = proxy.foo.bar; pathOf(ref); // "$.foo.bar" ```
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.
@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 = {
    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).
@paramname - State name for the Choice state.@paramconfigFn - Callback that receives the typed context proxy and returns a `ChoiceConfig` with conditions and branch builders.@returnsThe same builder (context type unchanged — can't know which branch will execute at runtime).@example```ts builder.choice('checkType', ctx => ({ choices: [ { when: { variable: ctx.assetType, stringEquals: 'video' }, then: b => b.task('processVideo', videoConfig, c => ({ ... })), }, ], default: b => b.fail('unknownType', { error: 'UnknownAssetType' }), })) ```
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.
@paramname - State name for the Fail state.@paramconfig - Error and cause strings.@example```ts builder.fail('validationFailed', { error: 'ValidationError', cause: 'Input file is not a supported format', }) ```
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.
@paramoptions - Optional configuration for the state machine.@paramoptions.comment - A human-readable description of the state machine.@throwsIf the builder has no states.
build
();

What comes out:

  • CheckType.Choices[0].Next is ProcessVideo, [1].Next is ProcessImage, Default is UnknownType.
  • ProcessVideo.Next and ProcessImage.Next are both Finalize — their End: true was rewritten.
  • UnknownType is a Fail state, so it keeps no Next and no End.
  • Finalize.End is true.

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.
@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 = {
    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.
@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: Rebased<Input & {
    isWholeVideo: boolean;
}>
builder
>;
type Ctx = {
    sceneCount: number;
    isWholeVideo: boolean;
}

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.