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.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.idonly compiles if aloadFilestate ran and its output schema has that field at that type. - Types line up across the boundary. A
Ref<string>will not go where aRef<number>is expected, even though both serialize to indistinguishable JSONPath strings. - Branch outputs stay distinct.
parallelresults 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.