typed-asl
v0.4.1
Published
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.
Maintainers
Readme
typed-asl
Generates Amazon States Language JSON from TypeScript, with compile-time proof that payload mappings match the Lambdas' Zod schemas and that every JSONPath ref resolves to a real upstream output of the right type.
npm install typed-asl zodWhy
Hand-written ASL and Lambda handler schemas are disconnected: misaligned payloads, dangling JSONPath references and type mismatches surface only at runtime, mid-execution. If a machine built here typechecks, its payloads are correct.
Caught at compile time: missing or extra payload fields, refs to a nonexistent state (ctx.doesNotExist.foo) or field, ref type mismatches, cross-branch parallel access (ctx.par[1].stateFromBranch0), out-of-range parallel indices (ctx.par[2] on a two-branch parallel), choice conditions whose variable or operand type disagrees with the operator, map item selectors that are typos or not arrays, customTask refs that point where the result doesn't live, and optional output fields without an explicit resultSelector. Known ASL error names (States.Timeout, Lambda.TooManyRequestsException, …) autocomplete in retry/catch configs. Every guarantee here has a matching negative test (src/lib/type-guarantees.test.ts) that fails the build if the API stops rejecting the bad code.
import { SequenceBuilder } from 'typed-asl';
const machine = 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();Each method appends a state and returns the builder widened with that state's output, so ctx at every step is a Proxied<Ctx> whose property accesses record JSONPath segments ($.runMediaInfo.mediaInfo) while carrying the schema's type.
build() returns a plain ASL object. Write it to a file, feed it to Terraform or CDK, or hand it to CreateStateMachine — the library has no opinion about deployment.
Learn it
tutorial/ is the documentation: eleven numbered files that build the library's ideas from scratch, each one a runnable test. Start at 00-the-problem.test.ts and read in order. Because they are tests, they cannot drift from the implementation.
The same eleven chapters are published at solidlabs.com/docs/typed-asl if you would rather read them in a browser.
Type machinery worth knowing
Parallel branches are a tuple, not an array. A Parallel state's ASL output is positional, and a plain Array<Union> would lose that. BranchOutputTuple is a mapped tuple ({ [I in keyof Branches]: Omit<Full, keyof Base> }), which preserves per-index types, so ctx.process[0] is branch 0's own output. Two things keep it working: the [...Branches] variadic tuple parameter (without it TypeScript infers SequenceBuilder<any>[]) and each builder's declare readonly _ctx: Ctx phantom field, which is what infer extracts.
The context accumulates as a tuple, not as nested Omits. A builder carries three type parameters — SequenceBuilder<Ctx, Base, E> — but you write one. Base is where the chain started, E is a flat tuple of the [key, output] pairs each state contributed, and Ctx is always ContextOf<Base, E>: the two of them materialized into the object you actually read. Each chained call appends to E rather than wrapping the previous context, which is what keeps long chains cheap. Writing it the obvious way — Omit<Ctx, Name> & Record<Name, Out>, folded in per call — makes the context at step N a mapped type wrapping step N-1's, and resolving that stack costs 2^N: chains failed to compile at 17 states with TS2589 (#14).
Base and E are inference state, not part of a builder's identity — they appear on no property, so builders compare on their context alone. SequenceBuilder<SomeCtx> still works as an annotation, and single-type-parameter helpers (<C>(b: SequenceBuilder<C>) => …) still match a chain that has accumulated states. The one pattern that changes is matching the type directly: T extends SequenceBuilder<infer C> ? C : never now yields never — use the exported InferContext<T> instead.
The context type hovers as a plain object. ContextOf is wrapped in Simplify<T> = { [K in keyof T]: T[K] } & {}, so your editor prints { bucket: string; key: string; loadFile: … } rather than the machinery that produced it, no matter how long the chain. Assignability is unchanged; only the rendering is. One consequence: keys appear in the mapped type's iteration order, not declaration order. A state whose key collides with an earlier one — or with a field of the starting context — replaces it rather than intersecting, matching what actually lands in the state data.
choice leaves the context type unchanged — the builder can't know which branch ran. Non-terminal branches converge on the next chained state, fail states stay terminal, empty branches skip straight to convergence, and choices nest.
pipe(fn) keeps reusable task groups in a flat chain. Declare the function generic over Ctx extends { … } and the constraint becomes its documented requirement on upstream outputs:
const addCreateAtlas = <
Ctx extends { extractFrames: { frameStorageRefs: StorageRef[] } },
>(
b: SequenceBuilder<Ctx>
) =>
b.task('createAtlas', createAtlasConfig, (ctx) => ({
frameStorageRefs: ctx.extractFrames.frameStorageRefs,
outputFilename: 'atlas.webp',
}));
new SequenceBuilder<Input>()
.task('extractFrames', …)
.pipe(addCreateAtlas)
.task('finalize', …)
.build();How this is verified
Each claim above is pinned by something that fails the build when it stops being true.
- 251 tests across the library and the tutorial, run on Node 20, 22 and 24.
- Every
build()in the suite is validated against the ASL spec withasl-validator, through a setup hook rather than per-call-site opt-in — so a fixture AWS would reject cannot pass CI. - Negative type tests (
src/lib/type-guarantees.test.ts) pin the compile-time contract as@ts-expect-errorcases.tscchecks those in both directions, so they still fail when inference quietly degrades toany— which no passing runtime test would catch. - Three compilers: the pinned
~5.7, the newest 5.x, andtypescript@latest. Type-level behavior is this library's API surface, so a compiler upgrade can be a breaking change. - Coverage is a ratchet — the floor sits at 95% statements / 91% branches, and CI fails if it drops. Thresholds only move up, with one documented exception: re-baselining when a coverage-tool major bump shifts attribution (vitest 3 → 4 measured about a point lower on identical code). The badge reads lower than those figures because Codecov counts a partially-covered line as a miss where vitest counts it as hit — the same report is 91% there and 96% lines here.
- Releases are signed. Publishing runs from the tagged workflow through npm trusted publishing (OIDC, no long-lived token), so the tarball on npm carries provenance back to this repo and commit.
Scope
Supported states: Task (Lambda, plus a customTask escape hatch for any service integration ARN), Parallel, Map, Choice, Pass, Wait, Fail, Succeed. Retry and Catch work on Task, customTask, Map, and Parallel; TimeoutSeconds/HeartbeatSeconds (and their ...Path variants) on Task and customTask. Choice supports every JSONPath-mode comparison operator, *Path variants included (typed refs on both sides). Intrinsics: the full JSONPath-mode set — Format, JsonToString, StringToJson, JsonMerge, Array, ArrayLength, ArrayGetItem, ArrayContains, ArrayRange, ArrayUnique, ArrayPartition, MathAdd, MathRandom, StringSplit, Base64Encode/Decode, Hash, UUID.
Not yet supported, and worth knowing before you adopt:
- JSONPath mode only. The newer JSONata query language and
Assign/variables are not implemented; the ref machinery assumes JSONPath. - No Distributed Map (
ItemReader/ResultWriter/ItemBatcher). - Zod only. Output schemas drive
ResultSelectorgeneration, so other validators aren't pluggable today. - Optional output fields require an explicit
resultSelector. The auto-generated selector maps every output schema key from$.Payload.{key}, and ASL errors at runtime when a referenced key is absent — so a schema with.optional()fields is rejected at compile time (and at build time) unless you pass aresultSelectorselecting only what the Lambda always returns. build()does not validate against the ASL spec. It guarantees your mappings and refs, not that AWS will accept every machine you can express. (The library's own test fixtures are all checked against the spec withasl-validatorin CI — your machines at build time are not.)
This came out of a production monorepo, where it builds real state machines — but it has been shaped by a small number of them. Expect rough edges on shapes we haven't hit. Issues and PRs welcome.
License
MIT
