Chapter 0

The problem with raw ASL

AWS Step Functions are defined in Amazon States Language — JSON, where references between states are raw JSONPath strings. Here are two Lambda tasks, the second consuming the first's output:

{
  "StartAt": "RunMediaInfo",
  "States": {
    "RunMediaInfo": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "${lambda_arn}",
        "Payload": { "bucket.$": "$.bucket", "key.$": "$.key" }
      },
      "ResultSelector": { "mediaInfo.$": "$.Payload.mediaInfo" },
      "ResultPath": "$.runMediaInfo",
      "Next": "CreateVideo"
    },
    "CreateVideo": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "${lambda_arn}",
        "Payload": {
          "width.$": "$.runMediaInfo.mediaInfo.width",
          "height.$": "$.runMediaInfo.mediaInfo.height"
        }
      },
      "ResultPath": "$.createVideo",
      "End": true
    }
  }
}

"$.runMediaInfo.mediaInfo.width" is the interesting part. It asserts three things — that a state named runMediaInfo ran, that its output has a mediaInfo object, and that the object has a numeric width. Nothing checks any of them.

The silent breakage

Rename the Lambda's output field from mediaInfo to media and every path referencing it is wrong. The JSON is still valid JSON. TypeScript, if you generated the JSON from TypeScript, is still happy:

const 
const brokenPayload: {
    'width.$': string;
    'height.$': string;
}
brokenPayload
= {
'width.$': '$.runMediaInfo.mediaInfo.width', 'height.$': '$.runMediaInfo.mediaInfo.height', };

There is no signal until the state machine runs and the execution fails with JSONPath '$.runMediaInfo.mediaInfo.width' returned no results — in production, on the input that happened to reach that branch.

The same class of mistake covers typos ($.runMediaInf.mediaInfo), a field that never existed, a state renamed but not re-referenced, and a type mismatch where a string lands in a field the Lambda parses as a number.

What we want instead

The information needed to catch all of this already exists: each Lambda has an input and an output shape. If those shapes were in the type system, and paths were built by property access rather than by writing strings, the compiler could check every reference:

builder
  .task('runMediaInfo', { inputSchema, outputSchema, functionArn }, (ctx) => ({
    bucket: ctx.bucket, // autocomplete; typed as Ref<string>
    key: ctx.key,
  }))
  .task('createVideo', { inputSchema, outputSchema, functionArn }, (ctx) => ({
    width: ctx.runMediaInfo.media.width, // error if the field is gone
    height: ctx.runMediaInfo.media.height, // checked as Ref<number>
  }))
  .build();

Getting there needs five pieces, and the rest of this tutorial builds them in order:

  1. A type carrying both a JSONPath and the type it points at — Ref<T>.
  2. A way to turn property access into JSONPath strings — Proxied<T>.
  3. Schemas defining each Lambda's contract — Zod schemas.
  4. A builder that accumulates context types as states are added — SequenceBuilder.
  5. Serialization that turns typed refs back into ASL — serialization.