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.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-isserializeParameters({
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-isserializeParameters({
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.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-isserializeParameters({
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.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-isserializeParameters({
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.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-isserializeParameters({ 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.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-isserializeParameters({ 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 `.$`.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.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-isserializeParameters({
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.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.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.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-isserializeParameters({
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.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.