Chapter 8
map: iteration with a dual context
A Map state iterates an array in the state data, running a processor sub-machine per element. That creates a problem the other states don't have: inside the processor you need two roots.
$— the outer state data, shared across every iteration.$$— the iteration context, holding$$.Map.Item.Valueand$$.Map.Item.Index.
The itemSelector callback is the bridge. It receives the item, the outer context, and returns the processor's starting context.
The $$ proxies
createMapItemProxy builds the iteration-rooted proxies. You rarely call it directly — map hands them to itemSelector — but it shows what the paths look like:
type type Scene = {
id: string;
startFrame: number;
endFrame: number;
}
Scene = { id: stringid: string; startFrame: numberstartFrame: number; endFrame: numberendFrame: number };
const const item: MapItemRef<Scene>item = createMapItemProxy<Scene>(): MapItemRef<Scene>Creates typed proxy references for Map state iteration variables.
Returns proxies rooted at `$$` (the Step Functions context object)
instead of `$` (the state data).createMapItemProxy<type Scene = {
id: string;
startFrame: number;
endFrame: number;
}
Scene>();
function pathOf(ref: Ref<unknown>): stringExtracts the JSONPath string from a Ref.
Converts the internal path segments into a dot-separated JSONPath string,
with array indices attached directly (no dot before brackets).pathOf(const item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value); // '$$.Map.Item.Value'
function pathOf(ref: Ref<unknown>): stringExtracts the JSONPath string from a Ref.
Converts the internal path segments into a dot-separated JSONPath string,
with array indices attached directly (no dot before brackets).pathOf(const item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value.id: Ref<string>id); // '$$.Map.Item.Value.id'
function pathOf(ref: Ref<unknown>): stringExtracts the JSONPath string from a Ref.
Converts the internal path segments into a dot-separated JSONPath string,
with array indices attached directly (no dot before brackets).pathOf(const item: MapItemRef<Scene>item.MapItemRef<Scene>.index: Ref<number>index); // '$$.Map.Item.Index'
Types survive the $$ root exactly as they do under $: item.value.startFrame is a Ref<number>, item.index is a Ref<number>.
A map state
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 = {
scenes: Scene[];
outputBucket: string;
}
Input>()
.map('processScenes', {
items: ((ctx: Proxied<Input>) => Ref<readonly Scene[]>) & ((ctx: Proxied<Input>) => Ref<readonly Scene[]>)Typed ref selector for the array to iterate — preferred.items: (ctx: Proxied<Input>ctx) => ctx: Proxied<Input>ctx.scenes: Proxied<Scene[]>scenes,
MapConfig<Ctx, ItemType, S extends Record<string, unknown>, ProcessorCtx, CatchKey extends string = never>.maxConcurrency?: number | undefinedMax concurrent iterations (default: unlimited).maxConcurrency: 5,
itemSelector: (item: MapItemRef<Scene>item, ctx: Proxied<Input>ctx) => ({
// Iteration context ($$) and outer context ($), mixed freely
sceneId: Ref<string>sceneId: item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value.id: Ref<string>id,
startFrame: Ref<number>startFrame: item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value.startFrame: Ref<number>startFrame,
endFrame: Ref<number>endFrame: item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value.endFrame: Ref<number>endFrame,
outputBucket: Ref<string>outputBucket: ctx: Proxied<Input>ctx.outputBucket: Ref<string>outputBucket,
}),
processor: (b) =>
b.task(
'extractFrames',
{
inputSchema: 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<{
sceneId: string;
startFrame: number;
endFrame: number;
outputBucket: string;
}>
ctx) => ({
step: "extract-frames"step: 'extract-frames' as type const = "extract-frames"const,
// The processor's context is inferred from itemSelector's return,
// with refs unwrapped to their real types.
sceneId: Ref<string>sceneId: ctx: Proxied<{
sceneId: string;
startFrame: number;
endFrame: number;
outputBucket: string;
}>
ctx.sceneId: Ref<string>sceneId,
startFrame: Ref<number>startFrame: ctx: Proxied<{
sceneId: string;
startFrame: number;
endFrame: number;
outputBucket: string;
}>
ctx.startFrame: Ref<number>startFrame,
endFrame: Ref<number>endFrame: ctx: Proxied<{
sceneId: string;
startFrame: number;
endFrame: number;
outputBucket: string;
}>
ctx.endFrame: Ref<number>endFrame,
outputBucket: Ref<string>outputBucket: ctx: Proxied<{
sceneId: string;
startFrame: number;
endFrame: number;
outputBucket: string;
}>
ctx.outputBucket: Ref<string>outputBucket,
})
),
})
.build();
The ItemSelector is where the two roots show up side by side in the emitted ASL:
{
"Type": "Map",
"ItemsPath": "$.scenes",
"MaxConcurrency": 5,
"ResultPath": "$.processScenes",
"ItemSelector": {
"sceneId.$": "$$.Map.Item.Value.id",
"startFrame.$": "$$.Map.Item.Value.startFrame",
"endFrame.$": "$$.Map.Item.Value.endFrame",
"outputBucket.$": "$.outputBucket"
},
"ItemProcessor": {
"StartAt": "ExtractFrames",
"ProcessorConfig": { "Mode": "INLINE" }
}
}
items over itemsPath
Both spellings work, and they differ in whether the compiler can help:
// Typed: `ctx.scenes` autocompletes, a typo won't compile, pointing it at a
// non-array won't compile, and the item type is inferred.
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 = {
scenes: Scene[];
bucket: string;
}
Input>().map('a', {
items: ((ctx: Proxied<Input>) => Ref<readonly Scene[]>) & ((ctx: Proxied<Input>) => Ref<readonly Scene[]>)Typed ref selector for the array to iterate — preferred.items: (ctx: Proxied<Input>ctx) => ctx: Proxied<Input>ctx.scenes: Proxied<Scene[]>scenes,
itemSelector: (item: MapItemRef<Scene>item, ctx: Proxied<Input>ctx) => ({ scene: Proxied<Scene>scene: item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value, bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket }),
processor: (b: SequenceBuilder<{
scene: Scene;
bucket: string;
}, {
scene: Scene;
bucket: string;
}, []>
b) => b: SequenceBuilder<{
scene: Scene;
bucket: string;
}, {
scene: Scene;
bucket: string;
}, []>
b.pass('mark', (c: Proxied<{
scene: Scene;
bucket: string;
}>
c) => ({ id: Ref<string>id: c: Proxied<{
scene: Scene;
bucket: string;
}>
c.scene: Proxied<Scene>scene.id: Ref<string>id })),
});
// Blind: the path is a string, and the item type must be annotated by hand.
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 = {
scenes: Scene[];
bucket: string;
}
Input>().map('b', {
itemsPath: "$.scenes"JSONPath to the array to iterate (e.g. `'$.scenes'`). Prefer `items`
— it autocompletes and typos don't compile; a literal path here is
type-checked against the context but without completion. Exactly one
of `itemsPath`/`items` is required (enforced at build time).itemsPath: '$.scenes',
itemSelector: (item: MapItemRef<Scene>item: interface MapItemRef<T>Proxy references for a Map state's context object (`$$`).
- `value` is `$$.Map.Item.Value` — the current iteration element
- `index` is `$$.Map.Item.Index` — the current iteration indexMapItemRef<type Scene = {
id: string;
startFrame: number;
}
Scene>, ctx: Proxied<Input>ctx) => ({
scene: Proxied<Scene>scene: item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value,
bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket,
}),
processor: (b: SequenceBuilder<{
scene: Scene;
bucket: string;
}, {
scene: Scene;
bucket: string;
}, []>
b) => b: SequenceBuilder<{
scene: Scene;
bucket: string;
}, {
scene: Scene;
bucket: string;
}, []>
b.pass('mark', (c: Proxied<{
scene: Scene;
bucket: string;
}>
c) => ({ id: Ref<string>id: c: Proxied<{
scene: Scene;
bucket: string;
}>
c.scene: Proxied<Scene>scene.id: Ref<string>id })),
});
Both emit "ItemsPath": "$.scenes". Prefer items — itemsPath is the escape hatch for paths the type system can't express.
The result
A map contributes an array to the context: one entry per iteration, each holding the processor's accumulated context.
const const builder: Widened<Input, [], "processScenes", {
bucket: string;
scene: Scene;
mark: {
id: string;
};
}[]>
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 = {
scenes: Scene[];
bucket: string;
}
Input>().map('processScenes', {
items: ((ctx: Proxied<Input>) => Ref<readonly Scene[]>) & ((ctx: Proxied<Input>) => Ref<readonly Scene[]>)Typed ref selector for the array to iterate — preferred.items: (ctx: Proxied<Input>ctx) => ctx: Proxied<Input>ctx.scenes: Proxied<Scene[]>scenes,
itemSelector: (item: MapItemRef<Scene>item, ctx: Proxied<Input>ctx) => ({ scene: Proxied<Scene>scene: item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value, bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket }),
processor: (b: SequenceBuilder<{
scene: Scene;
bucket: string;
}, {
scene: Scene;
bucket: string;
}, []>
b) => b: SequenceBuilder<{
scene: Scene;
bucket: string;
}, {
scene: Scene;
bucket: string;
}, []>
b.pass('mark', (c: Proxied<{
scene: Scene;
bucket: string;
}>
c) => ({ id: Ref<string>id: c: Proxied<{
scene: Scene;
bucket: string;
}>
c.scene: Proxied<Scene>scene.id: Ref<string>id })),
});
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<Input, [], "processScenes", {
bucket: string;
scene: Scene;
mark: {
id: string;
};
}[]>
builder>;
Downstream tasks reference the whole array as an ordinary ref — ctx.processScenes serializes to "$.processScenes". Per-element access is not typed here, because the number of iterations isn't known until runtime; that is the honest shape.