Skip to main content

Control Flow

FlowForger control flow is written as native TypeScript — if/else, for...of, switch, while, and do...while. The transformer reads the AST of your action method and maps these constructs directly to IR nodes (if, foreach, switch, dountil). There is no ctx.if(), ctx.foreach(), or ctx.scope() API — those methods do not exist on FlowContext.

Action names for control-flow nodes are auto-generated (e.g. Check_status, ForEach_item, Switch, DoUntil; see generated names), but you can override them — and configure @runAfter, @runtimeConfig, @limit and the other annotations — with a JSDoc comment directly above the statement:

/** @action CheckStatus */
if (...) { ... }

Name any block whose result you read later: generated names are generic, and a second if with the same shape gets _2 appended.

If / Else​

A plain TypeScript if statement becomes an IfNode. The condition can be a comparison against ctx.* calls (transformed to a Power Automate expression) or a raw expression string passed to ctx.eval():

@Action()
async run(ctx: FlowContext) {
/** @action CheckX */
if (ctx.eval(`@equals(variables('x'), 1)`)) {
await ctx.http('ThenA', { method: 'GET', url: 'https://api.contoso.com/a' });
} else {
await ctx.http('ElseA', { method: 'GET', url: 'https://api.contoso.com/b' });
}
}

Comparisons against action outputs also work directly, without ctx.eval():

if (ctx.body('GetItems')?.['value']?.length > 0) {
// then
}

else if chains and boolean combinations (&&, ||, !, ctx.not(), ctx.greater(), etc.) are supported; the transformer folds them into a single Power Automate condition. === compiles to the type-strict equals(), so 1 === '1' is false in the cloud too (see Cloud Semantics). By default the condition is written in the designer's object format; see @conditionFormat.

An action inside one branch cannot be read from the other branch or from the condition itself (DSL048). Read it after the if.

ForEach​

A for...of loop over a collection becomes a ForeachNode (Apply to each). Inside the body, the loop variable is the current item: order?.['id'] compiles to items('Loop_name')?['id']. ctx.item() works too.

/** @action Loop_orders */
for (const order of ctx.body('Get_orders')?.['value'] ?? []) {
await ctx.compose('Order_id', order?.['id']);
}

Any expression that evaluates to an array works as the loop source: ctx.outputs('GetItems'), ctx.triggerBody()?.['items'], a variable, etc. ?? [] guards against a missing array (it compiles to coalesce(..., json('[]'))). Loops can be nested — the inner loop's items expression can reference the outer loop's current item.

Loops run in parallel in the cloud​

An Apply to each runs up to 20 iterations at the same time in Power Automate, unless you limit its concurrency. A local run of a loop without a concurrency setting takes one item at a time. A loop that changes a variable (=, .push(), or += on a string) can therefore give a different result after deploying, and the editor warns with DSL047. Run such a loop one item at a time:

/** @action Loop_orders @runtimeConfig {"concurrency":{"repetitions":1}} */
for (const order of ctx.body('Get_orders')?.['value'] ?? []) {
lines.push(order?.['id']);
}

Or build the result without a variable, with ctx.select() or ctx.filterArray(). Increments and decrements (count += 1) are safe either way.

A foreach keeps going after an iteration fails, and ends Failed once all items are done. ctx.response() and ctx.terminate() are not allowed inside a loop (DSL037).

Switch​

A native switch statement becomes a SwitchNode. Each case maps to a Power Automate case branch. Cases never fall through, so break is not needed; FlowForger ignores it (the editor notes it as DSL020). Name each case with @action in a JSDoc comment directly above it; otherwise it is named Case_<value>:

/** @action RouteByRegion */
switch (ctx.triggerBody()?.['region']) {
/** @action CaseUS */
case 'US':
await ctx.compose('TaxRate_US', 0.08);
break;
/** @action CaseEU */
case 'EU':
await ctx.compose('TaxRate_EU', 0.20);
break;
default:
await ctx.compose('TaxRate_Other', 0.10);
}

Rules:

  • At most 25 cases (DSL041).
  • Case values are constants (strings or integers).
  • The switch value must be a string or an integer when the flow runs, and each case must have the same type as the value: case '2' does not match the number 2, and the mismatch fails the switch's actions.

Loops: While / Do...While (Do Until)​

Power Automate only has a "Do Until" loop, so both while and do...while compile to a DoUntilNode — the transformer negates the condition for you (while (x) becomes "until not x"):

let retryCount: number = 0;

/** @action RetryFailedOrders */
do {
retryCount += 1;
// ...
} while (ctx.variables('retryCount') < 3);

A Do Until checks its condition after each iteration, so the body always runs at least once — also for a while loop whose condition is already false.

Use a JSDoc @limit to set the iteration count and/or timeout (Power Automate defaults to 60 iterations / 1 hour):

/** @action Poll @limit { "count": 20, "timeout": "PT30M" } */
while (ctx.variables('attempts') < 20) {
// ...
}

The loop continues past a failed iteration and ends with the status of its last iteration; reaching the count limit ends it Succeeded. The condition may read actions inside the loop body.

Scope​

Unlike if/for/switch, a bare { ... } block is ambiguous in TypeScript (it's also just a lexical scope), so grouping actions into a Logic Apps Scope requires an explicit @type scope tag in the JSDoc comment immediately above the block:

/** @action ScopeOne @type scope */
{
await ctx.http('InnerA', { method: 'GET', url: 'https://api.contoso.com/a' });
await ctx.http('InnerB', { method: 'GET', url: 'https://api.contoso.com/b' });
}

Without @type scope, a bare block is just flattened — its statements are hoisted into the surrounding action list and no Scope node is created.

Try / Catch / Finally pattern​

Power Automate has no native try/catch (and FlowForger ignores TypeScript's try/catch, DSL020). The standard pattern is sibling scopes, where the later scopes use @runAfter to depend on the earlier ones' results:

/** @action Try @type scope */
{
await ctx.http('Submit_order', { method: 'POST', url: 'https://api.contoso.com/orders', body: ctx.triggerBody() });
}

/** @action Catch @type scope @runAfter Try: Failed, TimedOut */
{
await ctx.compose('Error_details', ctx.result('Try'));
}

/** @action Finally @type scope @runAfter Catch: Succeeded, Failed, Skipped */
{
await ctx.compose('Cleanup', true);
}

await ctx.response('Done', 200);

How it runs:

  • When Try succeeds, Catch is Skipped, Finally runs (it accepts Skipped), and so does everything after it.
  • When Try fails, Catch runs, then Finally. Inside Catch, ctx.result('Try') gives the records of the actions in Try, including their status and error.
  • A failure that a later action handles (here, Catch accepts Failed) does not fail the run.

Watch the statement right after Catch. Without its own @runAfter, a statement runs after the previous one when it succeeded, and on the success path Catch is Skipped, so that statement and everything after it are Skipped too. The validator reports a Response caught this way as RUNAFTER_SKIPPED_ON_SUCCESS. Put a Finally scope that accepts every status after Catch, as above, or give the next statement @runAfter Catch: Succeeded, Skipped.

Several @runAfter tags on one statement are ANDed, which makes it easy to write a condition that can never hold. For example, @runAfter Catch: Succeeded, Skipped together with @runAfter Try: Succeeded never runs on the catch path: when Catch succeeded, Try failed. The validator reports such conditions as RUNAFTER_DEAD_STATUS or RUNAFTER_UNSATISFIABLE. See @runAfter for the syntax, including @runAfter trigger and @runAfter first for parallel branches.

Nesting​

Control flow constructs can be nested — if inside foreach inside foreach, switch inside if, scopes inside loops, and so on. Each nested statement is processed recursively and produces its own nested IR node with its own actions array.

Power Automate allows nesting up to level 8, where top-level actions are level 0 (NESTING_DEPTH). Variables are declared at the top level only (DSL014): declare them at the start of the @Action method and assign them inside blocks.