typed-asl

Build Amazon States Language state machines in TypeScript, with compile-time proof that every task payload matches its Lambda's Zod schema and every JSONPath ref resolves.

Step Functions are defined in Amazon States Language: JSON, where every reference between states is a raw JSONPath string like "$.runMediaInfo.mediaInfo.width". Those strings assert that a state ran, that its output has a given shape, and that a field has a given type — and nothing checks any of it. A renamed field surfaces as JSONPath '$…' returned no results, at runtime, on whichever input reaches that branch.

typed-asl puts those assertions in the type system. Each task declares Zod schemas for what its Lambda accepts and returns; paths are built by property access rather than written by hand; and the context type widens as states are added, so the next task can only reference things that exist.

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 = {
    bucket: string;
    key: string;
}
Input
>()
.task( 'loadFile', { inputSchema:
const LoadFileInput: z.ZodObject<{
    step: z.ZodLiteral<"load-file">;
    bucket: z.ZodString;
    key: z.ZodString;
}, z.core.$strip>
LoadFileInput
,
outputSchema:
const LoadFileOutput: z.ZodObject<{
    fileUpload: z.ZodObject<{
        id: z.ZodString;
        filename: z.ZodString;
    }, z.core.$strip>;
}, z.core.$strip>
LoadFileOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, (ctx: Proxied<Input>ctx) => ({ step: "load-file"step: 'load-file' as type const = "load-file"const, bucket: Ref<string>bucket: ctx: Proxied<Input>ctx.bucket: Ref<string>bucket, key: Ref<string>key: ctx: Proxied<Input>ctx.key: Ref<string>key, }) ) .task( 'analyze', { inputSchema:
const AnalyzeInput: z.ZodObject<{
    step: z.ZodLiteral<"analyze">;
    fileId: z.ZodString;
}, z.core.$strip>
AnalyzeInput
,
outputSchema:
const AnalyzeOutput: z.ZodObject<{
    width: z.ZodNumber;
    height: z.ZodNumber;
}, z.core.$strip>
AnalyzeOutput
,
LambdaTaskConfig<I extends AnyZodObject, O extends AnyZodObject>.functionArn: stringfunctionArn: const LAMBDA_ARN: "${lambda_arn}"LAMBDA_ARN, }, // `ctx` now carries loadFile's output. Hover it, or misspell a field. (
ctx: Proxied<{
    bucket: string;
    key: string;
    loadFile: {
        fileUpload: {
            id: string;
            filename: string;
        };
    };
}>
ctx
) => ({
step: "analyze"step: 'analyze' as type const = "analyze"const, fileId: Ref<string>fileId:
ctx: Proxied<{
    bucket: string;
    key: string;
    loadFile: {
        fileUpload: {
            id: string;
            filename: string;
        };
    };
}>
ctx
.
loadFile: Proxied<{
    fileUpload: {
        id: string;
        filename: string;
    };
}>
loadFile
.
fileUpload: Proxied<{
    id: string;
    filename: string;
}>
fileUpload
.id: Ref<string>id,
}) ) .build();

.build() emits ordinary ASL — Next pointers threaded, ResultPath set per state, and "fileId.$": "$.loadFile.fileUpload.id" in the payload. There is no runtime component and no deployment story to adopt: the output is JSON you hand to Terraform, CDK, or the console exactly as before.

Install

npm install typed-asl zod

zod is a peer dependency (v4). Node 20 or newer.

What it checks

  • Payloads match their input schema exactly. A missing field is an error; so is an extra one, which is usually a typo that would otherwise be sent and silently ignored.
  • Every path resolves. ctx.loadFile.fileUpload.id only compiles if a loadFile state ran and its output schema has that field at that type.
  • Types line up across the boundary. A Ref<string> will not go where a Ref<number> is expected, even though both serialize to indistinguishable JSONPath strings.
  • Branch outputs stay distinct. parallel results are a tuple, so index 0 and index 1 keep their own shapes rather than collapsing to a union.

Everything is erased at build time. The emitted state machine has no trace of the type machinery.

Tutorial

Eleven chapters, in order. Chapters 0–3 build the type-level primitives; 4 onwards is the public API. Every sample on this site is compiled by tsc when the page is built, so none of it can drift from the published package.