Skip to content

Commit 192df90

Browse files
committed
- add ternary expression support (Choice + Pass states)
- support destructured params and default values in substeps - support nested/transitive substep inlining - resolve Lambda ARN from pure function calls - rename helper → substep in user-facing docs/examples - update limitations.md for accuracy (5 fixes)
1 parent b811984 commit 192df90

26 files changed

Lines changed: 1208 additions & 183 deletions

docs/constants.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const workflow = Steps.createFunction(
8787
- **`let` and `var` declarations** -- only `const` is folded
8888
- **Runtime values** -- function parameters, service call results, `context.*`
8989
- **String methods** -- `'hello'.toUpperCase()` is not folded
90-
- **User-defined functions** -- `myHelper(42)` is not folded
90+
- **User-defined functions** -- `myFunction(42)` is not folded
9191
- **Non-Math pure functions** -- only `Math.floor/ceil/round/abs/min/max/pow` are supported
9292
- **Constants declared inside `Steps.createFunction()`** -- only module-level constants are folded
9393

docs/error-handling.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,3 @@ try {
136136
}
137137
```
138138

139-
For the full specification, see [`spec/error-handling.md`](../spec/error-handling.md).

docs/intrinsic-functions.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,3 @@ const id = Steps.uuid(); // States.UUID()
105105
const id = crypto.randomUUID(); // States.UUID()
106106
```
107107

108-
For the full specification with ASL examples, see [`spec/intrinsic-functions.md`](../spec/intrinsic-functions.md).

docs/language-reference.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ if (!order.valid) {
142142

143143
`return` in a branch produces an End state at that point.
144144

145-
## Helper Functions
145+
## Substeps
146146

147147
Extract parts of a workflow into named async functions. The compiler inlines them at compile time — no nested executions, no runtime cost.
148148

@@ -154,9 +154,9 @@ Simple expression functions are inlined as values:
154154
const formatKey = (id: string) => `order-${id}`;
155155
```
156156

157-
### Async helpers
157+
### Async substeps
158158

159-
Module-scope `async` functions that make service calls are inlined at the CFG level. The helper's body is spliced into the caller's state machine:
159+
Module-scope `async` functions that make service calls are inlined at the CFG level. The substep's body is spliced into the caller's state machine:
160160

161161
```typescript
162162
async function provisionWithRollback(id: string, networkId: string) {
@@ -175,9 +175,9 @@ export const workflow = Steps.createFunction(async (ctx, input) => {
175175
});
176176
```
177177

178-
Helpers can contain any supported control flow: `if/else`, `try/catch`, loops, `Promise.all`. Parameters can be input references, service call results, or constants.
178+
Substeps can contain any supported control flow: `if/else`, `try/catch`, loops, `Promise.all`. Substeps can also call other substeps — the compiler inlines them transitively. Parameters can be input references, service call results, or constants.
179179

180-
See [Limitations](./limitations.md#helper-functions) for v1 constraints.
180+
See [Limitations](./limitations.md#substeps) for constraints.
181181

182182
## Automatic Data Flow (No JSONPath)
183183

@@ -224,7 +224,6 @@ const workflow = Steps.createFunction(async (context, input) => {
224224
| `context.stateMachine.id` | `$$.StateMachine.Id` | State machine ARN |
225225

226226
The context object is optional — if your workflow doesn't need execution metadata, you can omit it and use only the input parameter.
227-
```
228227

229228
## Comparison Operators
230229

@@ -248,4 +247,3 @@ See [Error Handling](./error-handling.md) for try/catch, retry, and custom error
248247

249248
See [Constants](./constants.md) for compile-time constant folding.
250249

251-
For the exhaustive ASL/CDK/SimpleSteps cross-reference, see [`spec/feature-mapping.md`](../spec/feature-mapping.md).

docs/library-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,4 @@ const obj = AslSerializer.serializeToObject(definition);
118118
| `@simplesteps/core/runtime/services` | `Lambda`, `DynamoDB`, `S3`, `SQS`, `SNS`, `StepFunction`, `EventBridge`, `SecretsManager`, `SSM` |
119119
| `@simplesteps/core/asl` | `AslSerializer`, `AslParser`, `AslValidator`, ASL type definitions |
120120

121-
For full type signatures, see [`spec/public-api.md`](../spec/public-api.md).
121+
Full type signatures are available in the published TypeScript declarations (`@simplesteps/core`).

docs/limitations.md

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ count -= 3; // OK
2323

2424
Workaround: Use compile-time constants (`const TIMEOUT = 30 * 1000` is folded at compile time), or delegate the calculation to a Lambda.
2525

26-
## Helper Functions
26+
## Substeps
2727

28-
The compiler supports two kinds of inlineable helper functions:
28+
The compiler supports two kinds of inlineable functions:
2929

3030
### Pure functions (expression inlining)
3131

@@ -36,12 +36,12 @@ Simple pure functions with a single `return` statement are inlined as expression
3636
const formatKey = (id: string) => `order-${id}`;
3737
```
3838

39-
### Async helpers (CFG-level inlining)
39+
### Async substeps (CFG-level inlining)
4040

41-
Module-scope `async` functions that make service calls can be inlined at the call site. The compiler splices the helper's body into the main workflow's state machine — no nested executions, no runtime cost.
41+
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.
4242

4343
```typescript
44-
// OK — async helper, inlined into the caller's state machine
44+
// OK — async substep, inlined into the caller's state machine
4545
async function provisionWithRollback(id: string, networkId: string) {
4646
try {
4747
return await computeApi.call({ action: 'create', id });
@@ -58,12 +58,21 @@ export const workflow = Steps.createFunction(async (ctx, input) => {
5858
});
5959
```
6060

61-
**v1 constraints:**
61+
**Constraints:**
6262

6363
- Must be declared at **module scope** (top-level `async function` or `const fn = async () => { ... }`)
64-
- Parameters must be **simple identifiers** (no destructuring, rest, or defaults)
65-
- **Single-level only**: helpers can call services but not other async helpers
66-
- Must be **awaited** at the call site (`await helper(...)`)
64+
- Parameters can be **simple identifiers**, **object destructuring** (`{ id, name }`), or have **default values** (`retries = 3`). Rest parameters are not supported.
65+
- Must be **awaited** at the call site (`await mySubstep(...)`)
66+
- Substeps can call other substeps — the compiler inlines transitively
67+
68+
```typescript
69+
// OK — nested substeps (inlined transitively)
70+
async function validate(id: string) { await validateFn.call({ id }); }
71+
async function validateAndEnrich(id: string) {
72+
await validate(id); // substep calling another substep
73+
await enrichFn.call({ id });
74+
}
75+
```
6776

6877
**Not supported:**
6978

@@ -75,40 +84,48 @@ function calculateTotal(items: Item[]) {
7584
return total;
7685
}
7786

78-
// NOT OK — nested helpers (helper calls another helper)
79-
async function innerHelper() { await svc.call({}); }
80-
async function outerHelper() { await innerHelper(); } // SS803
81-
82-
// NOT OK — destructured parameters
83-
async function process({ id }: { id: string }) { ... } // SS804
87+
// NOT OK — rest parameters
88+
async function process(...ids: string[]) { ... } // SS804
8489
```
8590

86-
Workaround for unsupported patterns: Move the logic into a Lambda function.
91+
Workaround for rest parameters: pass an explicit array parameter instead.
8792

8893
## Closures and Variable Capture
8994

90-
Parallel `for...of` loops compile to ASL Map states, which have isolated state. Each iteration cannot reference variables from the outer scope:
95+
Parallel `for...of` loops compile to ASL Map states, which have isolated state. Each iteration cannot reference **runtime variables** from the outer scope (service call results, input-derived values):
9196

9297
```typescript
93-
const prefix = input.prefix;
98+
const result = await lookupFn.call({ id: input.id });
9499

95-
// NOT OK — Map state can't access `prefix`
100+
// NOT OK — Map state can't access `result` (runtime variable)
96101
for (const item of input.items) {
97-
await processor.call({ key: prefix + item.id });
102+
await processor.call({ key: result.prefix + item.id });
98103
}
99104
```
100105

106+
Compile-time constants and service bindings **are** accessible inside Map state iterations. Only runtime variables are isolated.
107+
101108
Workaround: Use `Steps.sequential()` for sequential iteration (which does allow outer variable access), or restructure the data so each item carries what it needs.
102109

103110
## Array Methods
104111

105-
`Array.map()`, `Array.filter()`, `Array.reduce()`, and `Array.forEach()` are not supported. These are JavaScript runtime methods with no ASL equivalent.
112+
Synchronous array methods (`Array.filter()`, `Array.reduce()`) and synchronous `.map()` are not supported — these are JavaScript runtime operations with no ASL equivalent.
106113

107114
```typescript
108-
// NOT OK
115+
// NOT OK — synchronous .map() cannot be compiled
109116
const names = items.map(i => i.name);
110117

111-
// OK — use for...of instead
118+
// OK — async .map() compiles to a Map state (parallel)
119+
const results = await items.map(async (item) => {
120+
return await processItem.call(item);
121+
});
122+
123+
// OK — async .forEach() compiles to a Map state (discard results)
124+
await items.forEach(async (item) => {
125+
await processItem.call(item);
126+
});
127+
128+
// OK — for...of also compiles to a Map state
112129
for (const item of items) {
113130
await processItem.call(item);
114131
}
@@ -135,6 +152,8 @@ throw new OrderNotFoundError('Not found'); // OK — compiles to Fail s
135152

136153
**Switch fall-through** — each `case` must end with `break`, `return`, or `throw`. Fall-through between cases is not allowed.
137154

155+
**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.
156+
138157
## Dynamic Property Access
139158

140159
Computed property names and dynamic indexing cannot be expressed in JSONPath:
@@ -167,26 +186,43 @@ Note: `Math.floor()`, `Math.ceil()`, etc. **are** supported as compile-time cons
167186

168187
## Variable Shadowing
169188

170-
You cannot redeclare a variable name in a nested scope if it already exists in an outer scope:
189+
Avoid redeclaring a variable name in a nested scope. ASL uses a flat JSONPath namespace (`$.status`), so shadowing can cause the outer value to be silently overwritten:
171190

172191
```typescript
173192
const status = input.status;
174193
if (status === 'pending') {
175-
const status = 'processing'; // NOT OK — shadows outer `status`
194+
const status = 'processing'; // Avoid — overwrites $.status
176195
}
177196
```
178197

179-
## Spread in Service Call Arguments
198+
## Spread Operators
199+
200+
Object spread in **service call parameters** is not supported — ASL `Parameters` requires explicit key-value mappings:
201+
202+
```typescript
203+
// NOT OK — spread in service call arguments
204+
await myLambda.call({ ...baseParams, extra: 'value' });
205+
```
206+
207+
Workaround: Use `Steps.merge()` or construct the object with explicit properties.
208+
209+
Object spread **is** supported in general object literals when all properties are spreads (`{ ...a, ...b }`), which compiles to `States.JsonMerge`:
210+
211+
```typescript
212+
// OK — pure spread compiles to States.JsonMerge
213+
const merged = { ...defaults, ...overrides };
214+
```
180215

181-
Object spread (`{ ...obj }`) is not supported in service call parameters. Use `Steps.merge()` or construct the object with explicit properties.
216+
Mixed spread + plain properties (`{ ...obj, key: value }`) is not yet supported.
182217

183218
## Module-Level `let`
184219

185-
Only `const` declarations at module scope are folded. `let` and `var` declarations may be reassigned, making their values unresolvable at compile time:
220+
`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:
186221

187222
```typescript
188223
const MAX_RETRIES = 3; // OK — folded
189-
let retryCount = 3; // NOT OK — mutable, not folded
224+
let retryCount = 3; // OK — folded (warning SS709: prefer const)
225+
let x = 1; x = 2; // NOT OK — reassigned, not foldable
190226
```
191227

192228
## General Rule

docs/services.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,3 @@ interface RetryPolicy {
228228
}
229229
```
230230

231-
For the full service binding specification, see [`spec/services.md`](../spec/services.md).
Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
// Helper Functions — Async Inlining
1+
// Substeps — Async Inlining
22
//
3-
// Extract parts of a workflow into named async helper functions.
3+
// Extract parts of a workflow into named async functions (substeps).
44
// The compiler inlines them at compile time, producing a flat state
55
// machine. No runtime cost, no nested executions — just cleaner code.
66
//
77
// This example shows a resource provisioning saga with rollback.
8-
// Without helpers, this would be 4 levels of nested try/catch.
9-
// With helpers, each concern reads independently.
8+
// Without substeps, this would be 4 levels of nested try/catch.
9+
// With substeps, each concern reads independently.
1010
//
11-
// Constraints (v1):
12-
// - Helpers must be module-scope async functions
11+
// Constraints:
12+
// - Substeps must be module-scope async functions
1313
// - Simple identifier parameters only (no destructuring)
14-
// - Single-level: helpers can call services, but not other helpers
1514
// - Must be awaited at the call site
15+
// - Substeps can call other substeps (the compiler inlines transitively)
1616
//
1717
// ASL output:
1818
// Invoke_validateRequest (Task)
@@ -62,7 +62,7 @@ const computeRollback = Lambda<{ instanceId: string }, void>(
6262
'arn:aws:lambda:us-east-1:123:function:ComputeRollback',
6363
);
6464

65-
// ── Helper functions (inlined by the compiler) ──────────────────────
65+
// ── Substeps (inlined by the compiler) ──────────────────────────────
6666

6767
// Provision security with rollback on failure
6868
async function provisionSecurity(requestId: string, networkId: string) {
@@ -131,7 +131,7 @@ export const provisionResources = Steps.createFunction(
131131
{ retry: { maxAttempts: 2, intervalSeconds: 5, backoffRate: 2 } },
132132
);
133133

134-
// Each step handles its own rollback via helper functions
134+
// Each step handles its own rollback via substeps
135135
const security = await provisionSecurity(input.requestId, network.networkId);
136136
const compute = await provisionCompute(input.requestId, security.roleArn, network.networkId);
137137
await configureWithRollback(input.requestId, network.networkId, security.roleArn, compute.instanceId);

0 commit comments

Comments
 (0)