If you've worked with Step Functions at any real scale, you know the concept is great — orchestrate services, handle retries, build resilient workflows. The problem has always been the authoring experience.
You'd start with a CDK stack. Chain some constructs together with .next().next().next(). Scatter raw JSONPath strings everywhere. Hope that $.orderResult.Payload.body.items[0].price actually resolves to something when it hits production. When it doesn't? Enjoy your States.Runtime error in CloudWatch at 2am with zero useful context about which path expression is wrong.
AWS introduced JSONata last year to replace JSONPath, and it's a massive improvement. But even with JSONata, you're still writing ASL by hand — still managing Arguments, Assign, Output fields, still wiring {% $states.input.whatever %} expressions throughout a JSON document that grows to hundreds of lines for any non-trivial workflow.
I kept thinking: this is just a program. Control flow, data transformation, error handling. Why am I writing it in JSON?
So I built SimpleSteps — a TypeScript-to-ASL compiler. You write workflows as typed async functions, and the compiler handles the rest.
What This Looks Like in Practice
Here's an order validation workflow:
const machine = new SimpleStepsStateMachine(this, 'OrderWorkflow', {
workflow: Steps.createFunction(
async (context: SimpleStepContext, input: { orderId: string; customerId: string }) => {
const order = await validateOrder.call({ orderId: input.orderId });
if (!order.valid) {
throw new Error('Invalid order');
}
await orders.putItem({
Item: {
id: { S: input.orderId },
customerId: { S: input.customerId },
total: { N: String(order.total) },
status: { S: 'CONFIRMED' },
},
});
return { orderId: input.orderId, status: 'CONFIRMED' };
},
),
});
That compiles to a full JSONata-mode ASL state machine. The compiler infers all the Arguments, Assign, and Output fields from how you use variables. You never write a {% %} expression. You never think about $states.input vs $states.result. You just write TypeScript.
For comparison, here's the kind of ASL that gets generated from a simple Lambda call:
{
"QueryLanguage": "JSONata",
"StartAt": "Invoke_helloFn",
"States": {
"Invoke_helloFn": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:Hello",
"Arguments": { "name": "{% $states.input.name %}" },
"Assign": { "result": "{% $states.result %}" },
"Next": "Return_Result"
},
"Return_Result": {
"Type": "Pass",
"Output": { "greeting": "{% $result.greeting %}" },
"End": true
}
}
}
You could write that by hand. Or you could write this:
const result = await helloFn.call({ name: input.name });
return { greeting: result.greeting };
Multiply that difference across a real workflow with branching, loops, parallel execution, error handling, and retries — and the gap between "readable" and "not" gets enormous.
Why This Matters
Type safety that actually catches things. The validateOrder binding is typed as Lambda<{ orderId: string }, { valid: boolean; total: number }>. Pass it the wrong shape and TypeScript tells you at compile time. In raw ASL, you find out when your workflow fails in production because you fat-fingered a JSONata variable name inside a {% %} block.
Standard JS methods compile to JSONata. This is where it gets fun. The compiler maps 64+ JavaScript methods directly to JSONata built-ins. Math.round(), str.toUpperCase(), arr.filter(), arr.map() — they all compile to their JSONata equivalents. You can even write lambda expressions in .map(), .filter(), and .reduce() and they'll auto-compile to JSONata higher-order functions. You're writing TypeScript that looks and feels like TypeScript, but it becomes a state machine.
It's actually testable. @simplesteps/local ships a local execution engine that runs your compiled state machines with mocked services. No Docker, no AWS credentials, no deploying anything. You write Jest tests like this:
import { compile, AslSerializer } from '@simplesteps/core';
import { LocalRunner, StateMachineError } from '@simplesteps/local';
// Compile your workflow to ASL
const result = compile({ tsconfigPath: './tsconfig.workflows.json' });
const asl = JSON.parse(AslSerializer.serialize(result.stateMachines[0].definition));
// Mock services by ARN — just functions that return data
const services = {
'arn:aws:lambda:...ValidateOrder': () => ({ valid: true }),
'arn:aws:lambda:...ChargePayment': () => ({ chargeId: 'ch_123' }),
};
// Execute locally and assert on the output
const runner = new LocalRunner(asl, { services });
const output = await runner.execute({ orderId: 'ORD-1', total: 99.99 });
expect(output.status).toBe('completed');
You can test every branch by swapping mocks (return { valid: false } and verify the rejection path runs), test error handling by throwing StateMachineError from a mock, and inspect the full execution trace with executeWithTrace() to verify state ordering, step counts, and that specific services were or weren't called. Compare that to your current Step Functions testing strategy, which is probably "deploy and pray."
It's readable. Hand a new team member a 20-line async function versus a 150-line ASL document full of JSONata expressions and Assign blocks. I know which one I'd rather onboard someone with.
The Compiler Goes Deep
This isn't a thin wrapper that handles happy-path await = Task and punts on everything else. The full TypeScript control flow maps to ASL:
if/else and switch/case become Choice states. while and do...while become loops. for...of compiles to Map states for parallel iteration. Promise.all() becomes a Parallel state. try/catch generates Catch rules. throw becomes a Fail state. Retry policies are just an options argument on .call().
Under the hood, the compiler does whole-program data flow analysis with constant propagation, cross-file import resolution with cycle detection, and pure function inlining. When something goes wrong, you get real diagnostics — 50+ error codes with root-cause attribution and poisoned-value chain tracking. Not a generic "invalid state machine definition" from CloudFormation twenty minutes into a deploy.
64 Typed AWS Service Bindings
16 services have optimized direct integrations — Lambda, DynamoDB, SQS, SNS, EventBridge, S3, Secrets Manager, SSM, ECS, Bedrock, Glue, CodeBuild, Athena, Batch, StepFunction, and HttpEndpoint. On top of that, there are 48 SDK-generated bindings with full type signatures, plus Steps.awsSdk() as an escape hatch for anything else. All of them are typed. All of them participate in data flow analysis.
CDK Integration
You don't have to abandon your existing stack. SimpleStepsStateMachine is a CDK construct that drops right in:
import { SimpleStepsStateMachine } from '@simplesteps/cdk';
const machine = new SimpleStepsStateMachine(this, 'MyWorkflow', {
workflow: Steps.createFunction(async (context, input) => {
// your workflow
}),
});
myLambda.grantInvoke(machine);
myTable.grantWriteData(machine);
CDK tokens (Ref, Fn::GetAtt) propagate through cleanly — the compiler understands CloudFormation intrinsics and handles substitution at synth time. You can also define workflows in separate files using sourceFile + bindings if you prefer to keep your workflow logic out of your stack definitions.
There's also a CLI if you just want raw ASL output:
npx simplesteps compile workflow.ts -o output/
# JSONata is the default; use --query-language jsonpath if you need it
npx simplesteps compile workflow.ts -o output/ --query-language jsonpath
Try It
npm install @simplesteps/core @simplesteps/cdk @simplesteps/local
There's a playground where you can paste TypeScript and see compiled ASL in real time, a testing starter with a full Jest setup you can clone and run immediately, plus 29 showcase examples covering every language feature.
If you've ever stared at a 200-line ASL document wondering which {% $states.result %} is the one that's wrong, or you've lost an afternoon debugging a CDK .next() chain, give it a shot. I'd love to hear what you think.
GitHub · Playground · Docs
Top comments (0)