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.Value and $$.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).
@example```ts const item = createMapItemProxy<Scene>(); pathOf(item.value); // "$$.Map.Item.Value" pathOf(item.value.id); // "$$.Map.Item.Value.id" pathOf(item.index); // "$$.Map.Item.Index" ```
createMapItemProxy
<
type Scene = {
    id: string;
    startFrame: number;
    endFrame: number;
}
Scene
>();
function pathOf(ref: Ref<unknown>): string
Extracts 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).
@example```ts pathOf(proxy.foo.bar) // "$.foo.bar" pathOf(proxy.items[0].name) // "$.items[0].name" ```
pathOf
(const item: MapItemRef<Scene>item.MapItemRef<Scene>.value: Proxied<Scene>value); // '$$.Map.Item.Value'
function pathOf(ref: Ref<unknown>): string
Extracts 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).
@example```ts pathOf(proxy.foo.bar) // "$.foo.bar" pathOf(proxy.items[0].name) // "$.items[0].name" ```
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>): string
Extracts 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).
@example```ts pathOf(proxy.foo.bar) // "$.foo.bar" pathOf(proxy.items[0].name) // "$.items[0].name" ```
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.
@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 = {
    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 | undefined
Max 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.
@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 = {
    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.
@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 = {
    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 index
MapItemRef
<
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 itemsitemsPath 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.
@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 = {
    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.
@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<Input, [], "processScenes", {
    bucket: string;
    scene: Scene;
    mark: {
        id: string;
    };
}[]>
builder
>;
type Ctx = {
    scenes: Scene[];
    bucket: string;
    processScenes: {
        bucket: string;
        scene: Scene;
        mark: {
            id: string;
        };
    }[];
}

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.