SimpleSteps compiles TypeScript to ASL (Amazon States Language). ASL is not a general-purpose runtime — it has a fixed set of operations. This page documents what you cannot do in a SimpleSteps workflow and why.
Most limitations come from ASL itself, not the compiler. Where possible, workarounds are listed.
All arithmetic operators work natively:
const product = a * b; // OK — native JSONata *
const quotient = a / b; // OK — native JSONata /
const remainder = a % b; // OK — native JSONata %
const diff = a - b; // OK — native JSONata -
const sum = a + b; // OKASL only provides States.MathAdd. The compiler supports addition, subtraction by literal, and increment/decrement:
const next = count + 1; // OK
count++; // OK
count -= 3; // OKNot supported in JSONPath mode (SS530-SS533):
a * b— noStates.MathMultiplyexists (SS530)a / b— noStates.MathDivideexists (SS531)a % b— noStates.MathModuloexists (SS532)a - bwherebis a variable — only literal subtraction is supported (SS533)
Workaround: Switch to JSONata mode (the default), use compile-time constants (const TIMEOUT = 30 * 1000 is folded at compile time), or delegate the calculation to a Lambda.
Conditions in if, while, and switch statements compile to ASL Choice rules. Only simple comparisons are supported:
// OK — simple comparisons
if (input.status === 'ACTIVE') { ... }
if (input.count > 0) { ... }
if (a > 0 && b < 10) { ... }
if (!input.disabled) { ... }
// NOT OK — method calls in conditions
if (input.items.includes(target)) { ... } // SS510
// NOT OK — function calls in conditions
if (isValid(input)) { ... } // SS510
// NOT OK — bitwise operators
if (flags & MASK) { ... } // SS512Workaround: Compute the condition result in a previous step (e.g., via Lambda), then branch on the result.
The compiler supports two kinds of inlineable functions:
Simple pure functions with a single return statement are inlined as expressions:
// OK — pure function, inlined at compile time
const formatKey = (id: string) => `order-${id}`;Module-scope async functions that make service calls can be inlined at the call site. The compiler splices the substep's body into the main workflow's state machine — no nested executions, no runtime cost.
// OK — async substep, inlined into the caller's state machine
async function provisionWithRollback(id: string, networkId: string) {
try {
return await computeApi.call({ action: 'create', id });
} catch (e) {
await rollbackApi.call({ networkId });
throw new StepException('Provisioning failed');
}
}
export const workflow = Steps.createFunction(async (ctx, input) => {
const network = await networkApi.call({ id: input.id });
const compute = await provisionWithRollback(input.id, network.networkId);
return { instanceId: compute.instanceId };
});Constraints:
- Must be declared at module scope (top-level
async functionorconst fn = async () => { ... }) - Parameters can be simple identifiers, object destructuring (
{ id, name }), or have default values (retries = 3). Rest parameters are not supported. - Must be awaited at the call site (
await mySubstep(...)) - Substeps can call other substeps — the compiler inlines transitively
// OK — nested substeps (inlined transitively)
async function validate(id: string) { await validateFn.call({ id }); }
async function validateAndEnrich(id: string) {
await validate(id); // substep calling another substep
await enrichFn.call({ id });
}Not supported:
// NOT OK — pure function too complex to inline (loops, side effects)
function calculateTotal(items: Item[]) {
let total = 0;
for (const item of items) { total += item.price; }
return total;
}
// NOT OK — rest parameters
async function process(...ids: string[]) { ... } // SS804Workaround for rest parameters: pass an explicit array parameter instead.
Map state iterations have isolated state — each iteration receives only its array element as input. The compiler automatically detects outer-scope variables referenced inside a loop body and projects them into each iteration via ASL's ItemSelector.
All iteration styles support closures — for...of, Steps.map(), Steps.items(), and Steps.sequential() all capture outer await results automatically:
const config = await getConfig.call({ env: input.env });
// All of these capture `config` via ItemSelector:
for (const item of input.items) {
await processor.call({ key: config.prefix, item });
}
await Steps.map(input.items, async (item) => {
await processor.call({ key: config.prefix, item });
});
for (const item of Steps.items(input.items, { maxConcurrency: 5 })) {
await processor.call({ key: config.prefix, item });
}Compile-time constants and service bindings are also accessible in all Map iterations.
These array methods compile to ASL intrinsics or JSONata built-in functions:
| Method | JSONata | JSONPath | Notes |
|---|---|---|---|
arr.includes(v) |
v in arr |
States.ArrayContains |
Both modes |
arr.length |
$count(arr) |
States.ArrayLength |
Both modes |
arr[i] |
Native indexing | States.ArrayGetItem |
Both modes |
arr.join(sep) |
$join(arr, sep) |
SS540 | JSONata only |
arr.reverse() |
$reverse(arr) |
SS540 | JSONata only |
arr.sort() |
$sort(arr) |
SS540 | JSONata only |
arr.sort((a, b) => a.x - b.x) |
$sort(arr, function($a, $b) { $a.x < $b.x }) |
SS540 | JSONata only |
arr.concat(b) |
$append(arr, b) |
SS540 | JSONata only |
In JSONata mode, pure expression callbacks compile directly to JSONata higher-order functions:
// OK in JSONata mode — pure expression callbacks
const names = items.map(item => item.name); // → $map(...)
const active = items.filter(item => item.active); // → $filter(...)
const total = items.reduce((sum, item) => sum + item.price, 0); // → $reduce(...)
const found = items.find(item => item.id === target); // → $filter(...)[0]
const any = items.some(item => item.active); // → $count($filter(...)) > 0
const all = items.every(item => item.valid); // → $count($filter(...)) = $count(arr)The callback must be a pure expression (no await, no multi-statement bodies). For callbacks with service calls, use Steps.map() instead.
For callbacks that contain await or service calls, use Steps.map() or for...of:
// OK — Steps.map() compiles to Map state (parallel, with result capture)
const results = await Steps.map(input.items, async (item) => {
return await processItem.call({ item });
});
// OK — Steps.map() fire-and-forget
await Steps.map(input.items, async (item) => {
await processItem.call({ item });
});
// OK — for...of also compiles to a Map state
for (const item of input.items) {
await processItem.call({ item });
}// NOT OK — .forEach() (side-effect only, no ASL equivalent)
items.forEach(item => console.log(item));
// NOT OK — multi-statement callback body
const processed = items.map(item => {
const x = item.name;
const y = x.toUpperCase();
return y; // Only single-expression or single-return callbacks work
});Promise.all compiles to an ASL Parallel state. There are two supported patterns:
// OK — array literal with service calls
const [order, payment] = await Promise.all([
orderFn.call({ id: input.orderId }),
paymentFn.call({ amount: input.amount }),
]);Each element becomes a parallel branch. Branches can be single service calls or multi-step substeps.
// OK — natural JS pattern: start calls, then await them
const orderPromise = orderFn.call({ id: input.orderId });
const paymentPromise = paymentFn.call({ amount: input.amount });
const order = await orderPromise;
const payment = await paymentPromise;
// OK — also works with Promise.all to collect deferred calls
const orderPromise = orderFn.call({ id: input.orderId });
const paymentPromise = paymentFn.call({ amount: input.amount });
const [order, payment] = await Promise.all([orderPromise, paymentPromise]);The compiler detects non-awaited service calls and batches their awaits into a Parallel state.
// NOT OK — variable reference (not inline literal)
const promises = [orderFn.call({ id: input.orderId })];
await Promise.all(promises); // SS420The compiler must be able to see the branches at compile time.
Named property destructuring is supported — each property is registered as a separate variable:
// OK — each property becomes its own variable
const { name, count } = await myLambda.call(input);
// name → $.result.name (JSONPath) or $states.result.name (JSONata)
// count → $.result.count (JSONPath) or $states.result.count (JSONata)In JSONata mode, the rest element captures remaining properties via $sift():
// OK in JSONata mode — rest element
const { name, ...metadata } = await myLambda.call(input);
// name → $states.result.name
// metadata → $sift($states.result, function($v, $k) { $k != 'name' })Rest patterns emit SS540 in JSONPath mode (no equivalent).
Array destructuring is supported for Promise.all results (see above). Destructured function parameters in substeps also work (e.g., async function process({ id, name }: Input)).
Class instantiation is not supported inside workflow bodies. You cannot create class instances at runtime.
The one exception is error classes extending StepException, which are used as compile-time markers for ASL error names:
class OrderNotFoundError extends StepException {} // OK — error type only
throw new OrderNotFoundError('Not found'); // OK — compiles to Fail stateC-style for loops — for (let i = 0; i < n; i++) is supported but compiles to a while loop with States.MathAdd. Prefer for...of or while for clarity.
for...in loops — not supported. Use for...of with arrays.
Switch fall-through — each case must end with break, return, or throw. Fall-through between cases is not allowed.
Ternary expressions — const label = count > 5 ? 'large' : 'small' is supported. The compiler desugars it into a Choice state with two Pass branches. Compile-time constant conditions are folded away entirely.
Computed property names and dynamic indexing are limited:
// NOT OK — dynamic key lookup
const key = input.fieldName;
const value = data[key];
// NOT OK — computed property name
const obj = { [dynamicKey]: 'value' };Workaround: Use static property access (data.knownField), Object.keys() / Object.values() (JSONata mode), or restructure your data.
In JSONata mode, many JavaScript built-ins compile to native JSONata functions:
| Category | Supported Methods |
|---|---|
| String | toUpperCase, toLowerCase, trim, trimStart*, trimEnd*, substring, startsWith, endsWith, padStart, padEnd, replace, charAt, repeat, split, includes |
| Array | join, reverse, sort, concat, map, filter, reduce, find, some, every |
| Math | Math.floor, Math.ceil, Math.round, Math.abs, Math.pow, Math.sqrt, Math.min, Math.max, Math.random |
| Type | Number(), String(), Boolean(), parseInt(), parseFloat(), typeof, Date.now(), Array.isArray() |
| Object | Object.keys(), Object.values() |
| Other | JSON.parse, JSON.stringify, btoa, atob, crypto.randomUUID() |
* trimStart() and trimEnd() both compile to $trim(), which trims both ends. The directional semantics are lost in JSONata mode.
| API | Workaround |
|---|---|
Date / new Date() |
Date.now() → $millis() in JSONata mode; for new Date() use context.execution.startTime |
console.log() |
Return data for inspection |
setTimeout / setInterval |
Use Steps.delay({ seconds: N }) |
fetch() / HTTP calls |
Use Lambda or Steps.awsSdk() |
RegExp objects |
Use Lambda (string patterns work with str.replace() in JSONata mode) |
Note: Math.floor(), Math.ceil(), etc. are also supported as compile-time constants in both modes (see Constants).
Avoid redeclaring a variable name in a nested scope. ASL uses a flat namespace ($.status in JSONPath, $status in JSONata), so shadowing can cause the outer value to be silently overwritten:
const status = input.status;
if (status === 'pending') {
const status = 'processing'; // Avoid — overwrites $.status
}Object spread in service call parameters is not supported — ASL Parameters requires explicit key-value mappings:
// NOT OK — spread in service call arguments
await myLambda.call({ ...baseParams, extra: 'value' });Workaround: Use Steps.merge() or construct the object with explicit properties.
Object spread is supported in general object literals when all properties are spreads ({ ...a, ...b }), which compiles to States.JsonMerge:
// OK — pure spread compiles to States.JsonMerge
const merged = { ...defaults, ...overrides };Mixed spread + plain properties ({ ...obj, key: value }) is not yet supported.
const declarations at module scope are always folded. let and var with a single assignment are also folded (with a warning suggesting const). Reassigned let/var variables are unresolvable:
const MAX_RETRIES = 3; // OK — folded
let retryCount = 3; // OK — folded (warning SS709: prefer const)
let x = 1; x = 2; // NOT OK — reassigned, not foldableWhen the compiler cannot prove a variable is constant but you know it will be available at runtime, wrap it with Steps.safeVar(). This emits warning SS708 instead of error SS700:
const arn = getArnFromConfig(); // → SS700 error
const svc = Lambda<Req, Res>(Steps.safeVar(arn)); // → SS708 warning (OK)Use this only when you are certain the value will be resolved before the state machine executes (e.g., CDK synth-time values the compiler can't trace).
For AWS services not covered by the built-in bindings:
const obj = await Steps.awsSdk<{ Bucket: string; Key: string }, { Body: string }>(
's3', 'getObject', { Bucket: 'my-bucket', Key: input.key }
);Compiles to Resource: "arn:aws:states:::aws-sdk:s3:getObject".
SimpleSteps supports two ASL query languages:
- JSONata (default) — richer expression language with native arithmetic, string methods, Math functions, type conversions, and object introspection. Most JavaScript patterns "just work."
- JSONPath — the original ASL query language. More limited (no arithmetic operators, no string methods), but well-established.
Switch modes via the queryLanguage compile option:
compile({ sourceFiles: ['workflow.ts'], queryLanguage: 'JSONPath' });Methods only available in JSONata mode produce error SS540 when compiled in JSONPath mode. The error message tells you what mode to switch to.
HTTPS Endpoints (arn:aws:states:::http:invoke) are supported via the HttpEndpoint binding. Authentication requires an EventBridge Connection ARN configured in the AWS Console.
const http = new HttpEndpoint();
const result = await http.invoke({
ApiEndpoint: 'https://api.example.com/data',
Method: 'GET',
Authentication: { ConnectionArn: 'arn:aws:events:...:connection/MyConn' },
});See Services for the full API.
If something would require a JavaScript runtime to evaluate, it probably won't work. ASL executes a fixed set of state transitions and intrinsic functions. The compiler rejects patterns that would silently fail at runtime rather than generating broken state machines.
When in doubt, delegate to a Lambda function — that's a real JavaScript runtime.