BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

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.

Jun 29, 2026 OrchStep Team 7 minROLE: Backend DeveloperSCALE: Any

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 template

JavaScript 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 textdo: 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>:

orchstep.yml
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: success

Notice 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 }}'
true

That 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

TaskGo templateJavaScript
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 listpainfularr.filter(fn)
sum / dedupepainfulutils.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

Open orchstep eval and paste in the gnarliest condition from your current workflow. If it reads better as JavaScript, it probably is.

#JAVASCRIPT#TRANSFORM#EXPRESSIONS#LOGIC
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY