Chapter 5

Serialization — refs become ASL

Writing ctx.runMediaInfo.width in a payload gives you a Ref<number>. The ASL needs "width.$": "$.runMediaInfo.width". serializeParameters is the bridge.

The .$ suffix is ASL's way of saying "this value is a JSONPath, not a literal", and generating it correctly is the whole job.

type 
type Ctx = {
    bucket: string;
    metadata: {
        width: number;
    };
}
Ctx
= { bucket: stringbucket: string;
metadata: {
    width: number;
}
metadata
: { width: numberwidth: number } };
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 = {
    bucket: string;
    metadata: {
        width: number;
    };
}
Ctx
>();
function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({
bucket: Ref<string>bucket: const ctx: Proxied<Ctx>ctx.bucket: Ref<string>bucket, width: Ref<number>width: const ctx: Proxied<Ctx>ctx.
metadata: Proxied<{
    width: number;
}>
metadata
.width: Ref<number>width,
});
{
  "bucket.$": "$.bucket",
  "width.$": "$.metadata.width"
}

Static values pass through

Anything that isn't a ref keeps its key unchanged and its value literal — no .$:

function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({
format: stringformat: 'mp4', quality: numberquality: 90, enabled: booleanenabled: true, }); // → { format: 'mp4', quality: 90, enabled: true }

Which means refs and statics mix freely in one object, and each key gets the treatment it needs:

const 
const ctx: Proxied<{
    inputBucket: string;
    inputKey: string;
}>
ctx
=
createProxy<{
    inputBucket: string;
    inputKey: string;
}>(path?: string[]): Proxied<{
    inputBucket: string;
    inputKey: string;
}>
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
<{ inputBucket: stringinputBucket: string; inputKey: stringinputKey: string }>();
function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({
bucket: Ref<string>bucket:
const ctx: Proxied<{
    inputBucket: string;
    inputKey: string;
}>
ctx
.inputBucket: Ref<string>inputBucket,
key: Ref<string>key:
const ctx: Proxied<{
    inputBucket: string;
    inputKey: string;
}>
ctx
.inputKey: Ref<string>inputKey,
outputFormat: stringoutputFormat: 'webp', maxWidth: numbermaxWidth: 1920, });
{
  "bucket.$": "$.inputBucket",
  "key.$": "$.inputKey",
  "outputFormat": "webp",
  "maxWidth": 1920
}

Nesting and arrays

Nested objects are recursed, including objects inside arrays:

const 
const ctx: Proxied<{
    id: string;
}>
ctx
=
createProxy<{
    id: string;
}>(path?: string[]): Proxied<{
    id: string;
}>
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
<{ id: stringid: string }>();
function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({
tags: string[]tags: ['video', 'processed'],
env: ({
    Name: string;
    Value: Ref<string>;
} | {
    Name: string;
    Value: string;
})[]
env
: [
{ type Name: stringName: 'ID', type Value: Ref<string>Value:
const ctx: Proxied<{
    id: string;
}>
ctx
.id: Ref<string>id },
{ type Name: stringName: 'TYPE', type Value: stringValue: 'asset' }, ], });
{
  "tags": ["video", "processed"],
  "env": [
    { "Name": "ID", "Value.$": "$.id" },
    { "Name": "TYPE", "Value": "asset" }
  ]
}

The one case that is refused

A bare ref as an array element throws rather than serializing:

const 
const ctx: Proxied<{
    id: string;
    name: string;
}>
ctx
=
createProxy<{
    id: string;
    name: string;
}>(path?: string[]): Proxied<{
    id: string;
    name: string;
}>
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
<{ id: stringid: string; name: stringname: string }>();
function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({ ids: Ref<string>[]ids: [
const ctx: Proxied<{
    id: string;
    name: string;
}>
ctx
.id: Ref<string>id,
const ctx: Proxied<{
    id: string;
    name: string;
}>
ctx
.name: Ref<string>name] });
// throws — the message points you at statesArray

The reason is worth internalising: ASL substitutes JSONPaths only in object keys ending in .$. An array has no keys, so there is nowhere to put the suffix. A bare ref there would reach the running state machine as the literal string "$.id" — not an error, just quietly wrong data.

Use statesArray instead, which builds the array inside the state machine:

const 
const ctx: Proxied<{
    id: string;
    name: string;
}>
ctx
=
createProxy<{
    id: string;
    name: string;
}>(path?: string[]): Proxied<{
    id: string;
    name: string;
}>
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
<{ id: stringid: string; name: stringname: string }>();
function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({ ids: IntrinsicExpr<string[]>ids: statesArray<string>(...items: (string | Ref<string> | IntrinsicExpr<string>)[]): IntrinsicExpr<string[]>
Step Functions `States.Array()` intrinsic function. Builds an array from refs, intrinsics, and literal values. This is the way to put JSONPath values into an array — a bare ref as a plain array element would serialize to a literal string, since ASL only substitutes paths in object keys ending in `.$`.
@example```ts statesArray(ctx.a, 'literal', ctx.b) // → "States.Array($.a, 'literal', $.b)" ```
statesArray
(
const ctx: Proxied<{
    id: string;
    name: string;
}>
ctx
.id: Ref<string>id,
const ctx: Proxied<{
    id: string;
    name: string;
}>
ctx
.name: Ref<string>name) });
// → { "ids.$": "States.Array($.id, $.name)" }

Intrinsics use the same suffix

An IntrinsicExpr serializes like a ref — same .$ key, with a function-call expression instead of a path:

const 
const ctx: Proxied<{
    sceneId: string;
    data: unknown;
}>
ctx
=
createProxy<{
    sceneId: string;
    data: unknown;
}>(path?: string[]): Proxied<{
    sceneId: string;
    data: unknown;
}>
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
<{ sceneId: stringsceneId: string; data: unknowndata: unknown }>();
function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({
label: IntrinsicExpr<string>label: function statesFormat(template: string, ...args: (Ref<unknown> | IntrinsicExpr<unknown>)[]): IntrinsicExpr<string>
Step Functions `States.Format()` intrinsic function. Produces a string by interpolating `{}` placeholders in the template with the provided arguments (refs or other intrinsics). Single quotes in the template are escaped automatically. To include a literal `{` or `}`, escape it yourself as `\\{` / `\\}` per the ASL spec.
@example```ts statesFormat('scene_{}/frame', item.value.id) // → "States.Format('scene_{}/frame', $$.Map.Item.Value.id)" ```
statesFormat
('scene_{}',
const ctx: Proxied<{
    sceneId: string;
    data: unknown;
}>
ctx
.sceneId: Ref<string>sceneId),
dataString: IntrinsicExpr<string>dataString: function statesJsonToString(ref: Ref<unknown> | IntrinsicExpr<unknown>): IntrinsicExpr<string>
Step Functions `States.JsonToString()` intrinsic function. Converts a JSON value to its string representation.
@example```ts statesJsonToString(ctx.extractScenes) // → "States.JsonToString($.extractScenes)" ```
statesJsonToString
(
const ctx: Proxied<{
    sceneId: string;
    data: unknown;
}>
ctx
.data: Ref<unknown>data),
});
{
  "label.$": "States.Format('scene_{}', $.sceneId)",
  "dataString.$": "States.JsonToString($.data)"
}

All of it together

A realistic payload mixes deep refs, statics, and an intrinsic composed from two refs:

type Ctx = {
  
loadFile: {
    fileUpload: {
        id: string;
        organizationId: string;
    };
}
loadFile
: {
fileUpload: {
    id: string;
    organizationId: string;
}
fileUpload
: { id: stringid: string; organizationId: stringorganizationId: string } };
runMediaInfo: {
    mediaInfo: {
        width: number;
        height: number;
    };
}
runMediaInfo
: {
mediaInfo: {
    width: number;
    height: number;
}
mediaInfo
: { width: numberwidth: number; height: numberheight: number } };
}; 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
<Ctx>();
function serializeParameters(obj: Record<string, unknown>): Record<string, unknown>
Recursively serialize a parameters object for ASL. - Ref values → `"key.$": "$.path"` - IntrinsicExpr values → `"key.$": "States.Format(...)"` - Nested objects → recursed - Arrays → each element recursed if it's an object - Primitives → kept as-is
serializeParameters
({
fileId: Ref<string>fileId: const ctx: Proxied<Ctx>ctx.
loadFile: Proxied<{
    fileUpload: {
        id: string;
        organizationId: string;
    };
}>
loadFile
.
fileUpload: Proxied<{
    id: string;
    organizationId: string;
}>
fileUpload
.id: Ref<string>id,
orgId: Ref<string>orgId: const ctx: Proxied<Ctx>ctx.
loadFile: Proxied<{
    fileUpload: {
        id: string;
        organizationId: string;
    };
}>
loadFile
.
fileUpload: Proxied<{
    id: string;
    organizationId: string;
}>
fileUpload
.organizationId: Ref<string>organizationId,
width: Ref<number>width: const ctx: Proxied<Ctx>ctx.
runMediaInfo: Proxied<{
    mediaInfo: {
        width: number;
        height: number;
    };
}>
runMediaInfo
.
mediaInfo: Proxied<{
    width: number;
    height: number;
}>
mediaInfo
.width: Ref<number>width,
outputFormat: stringoutputFormat: 'mp4', label: IntrinsicExpr<string>label: function statesFormat(template: string, ...args: (Ref<unknown> | IntrinsicExpr<unknown>)[]): IntrinsicExpr<string>
Step Functions `States.Format()` intrinsic function. Produces a string by interpolating `{}` placeholders in the template with the provided arguments (refs or other intrinsics). Single quotes in the template are escaped automatically. To include a literal `{` or `}`, escape it yourself as `\\{` / `\\}` per the ASL spec.
@example```ts statesFormat('scene_{}/frame', item.value.id) // → "States.Format('scene_{}/frame', $$.Map.Item.Value.id)" ```
statesFormat
(
'{}_{}', const ctx: Proxied<Ctx>ctx.
loadFile: Proxied<{
    fileUpload: {
        id: string;
        organizationId: string;
    };
}>
loadFile
.
fileUpload: Proxied<{
    id: string;
    organizationId: string;
}>
fileUpload
.id: Ref<string>id,
const ctx: Proxied<Ctx>ctx.
runMediaInfo: Proxied<{
    mediaInfo: {
        width: number;
        height: number;
    };
}>
runMediaInfo
.
mediaInfo: Proxied<{
    width: number;
    height: number;
}>
mediaInfo
.width: Ref<number>width
), });
{
  "fileId.$": "$.loadFile.fileUpload.id",
  "orgId.$": "$.loadFile.fileUpload.organizationId",
  "width.$": "$.runMediaInfo.mediaInfo.width",
  "outputFormat": "mp4",
  "label.$": "States.Format('{}_{}', $.loadFile.fileUpload.id, $.runMediaInfo.mediaInfo.width)"
}

You will rarely call serializeParameters yourself — .task() calls it on your payload. It is worth knowing what it does, because its output is what you read when debugging a state machine in the console.