JavaScript expressions: when Go templates aren't enough
Go templates are great for interpolation and clumsy for logic. OrchStep also evaluates plain JavaScript in conditions and transform steps, so real computation reads the way you think it.
Go templates are a fine string language. {{ vars.app }}-{{ vars.target }} is exactly the right tool for building a string. But the moment you need logic — filter this list, sum these numbers, decide a strategy from a load average — the template spelling fights you:
{{ and (gt (atoi vars.count) 5) (eq vars.mode "fast") }}You can read it. You'd just rather not. OrchStep evaluates expressions in two languages, and for logic the other one wins: plain JavaScript (ES5+, via goja).
if: 'Number(vars.count) > 5 && vars.mode === "fast"'Same result, reads like code because it is.
How the engine picks a language
One rule: if the expression is wrapped in {{ }} it's a Go template; otherwise it's JavaScript.
if: 'vars.score > 80' # JavaScript
if: '{{ gt vars.score 80 }}' # Go templateJavaScript is available everywhere you express a decision: if: / elif: conditions, assert conditions, loop.until, retry.when, and the body of a transform step. What stays template territory is anything you interpolate into text — do: shell commands, outputs: values, rendered config — because there you're building a string, and templates are built for that.
The one rule that bites everyone: numbers are strings
Values that travelled through a step output, a module parameter, or an env file arrive as strings. "0" === 0 is false in JavaScript, so wrap numeric comparisons in Number():
condition: 'Number(steps.tally.errors) === 0' # right
condition: 'steps.tally.errors === 0' # wrong: "0" !== 0(Go templates have the mirror-image trap: gt errors out on mixed string/number types. There's no free lunch; pick the language whose failure mode you can see coming.)
transform: a full JavaScript program as a step
func: transform runs JavaScript in a sandboxed VM and turns its return value into step outputs — no process spawn, no shell quoting, no jq. Return an object and each field becomes steps.<name>.<field>:
name: pricing
defaults:
orders: 3
min: 5
tasks:
calc:
steps:
- name: aggregate
func: transform
do: |
const orders = [12, 7, 3, 22];
const big = orders.filter(function (o) { return o > 10; });
return {
count: orders.length,
total: utils.sum(orders),
big_count: big.length,
avg: utils.avg(orders),
};
- name: report
func: shell
do: echo "count={{ steps.aggregate.count }} total={{ steps.aggregate.total }} big={{ steps.aggregate.big_count }} avg={{ steps.aggregate.avg }}"
- name: gate
if: 'Number(steps.aggregate.total) > 40'
then:
- name: high
func: shell
do: echo "high volume"
else:
- name: low
func: shell
do: echo "low volume"Run it and the transform's fields flow straight into the next steps:
$ orchstep run calc
Step: aggregate
[ok] aggregate
Step: report
count=4 total=44 big=2 avg=11
[ok] report
Step: gate
high volume
[ok] gate
Result: successNotice the division of labour: the transform does the computation in JavaScript, the report step does the interpolation in a Go template, and the gate does the decision back in JavaScript. Each language doing the thing it's good at.
The VM ships with helpers so the common cases don't need a library: utils.sum, utils.avg, utils.unique, utils.flatten, and utils.parseJSON / utils.stringifyJSON for chewing through an API response without jq in the loop.
Try any expression instantly with eval
The fastest way to learn the rules is orchstep eval. It runs an expression against a real workflow context through the exact same engine code paths — no steps executed:
$ orchstep eval 'Number(vars.orders) >= Number(vars.min)'
false
$ orchstep eval '"healthy".includes("health")'
true
$ orchstep eval --var orders=99 'Number(vars.orders) * 2'
198
$ orchstep eval '{{ gt vars.orders 1 }}'
trueThat last line is the Go template, evaluated the same way. eval with no argument drops you into an interactive REPL. It's the difference between guessing whether Number() is needed and knowing in under a second.
What you gained
| Task | Go template | JavaScript |
|---|---|---|
| and / or / not | {{ and a b }}, {{ not a }} | a && b, !a |
| numeric compare | {{ gt vars.n 5 }} | Number(vars.n) > 5 |
| substring | {{ contains "ok" vars.msg }} | vars.msg.includes("ok") |
| filter a list | painful | arr.filter(fn) |
| sum / dedupe | painful | utils.sum, utils.unique |
| build a string | {{ vars.a }}-{{ vars.b }} | (use a template) |
Where templates still win
Don't reach for JavaScript to build text. Interpolating into a do: command, producing an outputs: value, piping a regex match out of step output ({{ result.output | regexFind ... }}) — that's all cleaner as a Go template, and transform bodies aren't template-rendered anyway, so a literal {{ }} inside one stays literal. The rule of thumb: logic in JavaScript, strings in templates. And remember the sandbox is pure computation — no require, no filesystem, no network. I/O belongs in a shell or http step.
Where to go next
- JavaScript Expressions — the full JS-first guide
- transform — function reference and the
utilshelpers - Templates & Expressions — the Go-template side
Open orchstep eval and paste in the gnarliest condition from your current workflow. If it reads better as JavaScript, it probably is.
curl -fsSL https://orchstep.dev/install.sh | sh