Expressions (JavaScript)
Expressions live inside {{ ... }} in any string-typed activity input. This
workflow declares expressionLanguage: js, so the runner evaluates them as
JavaScript. You get the standard library — String.prototype,
Array.prototype, Object, Math, JSON, Set — plus the same four
function packages the expr-lang flavour registers, spelled identically.
Syntax shapes
WriteLine(response) # bare variable - passes reference
WriteLine("{{ response.body }}") # property access
WriteLine("{{ items.filter(i => i.active) }}") # full expression
WriteLine("Hi {{ user.name }}, {{ count }} items") # embedded in a string
Rule of thumb: if you're accessing a property, calling a function, doing
arithmetic, or chaining - wrap it in {{ }}. Bare names without braces only
work for single-variable references.
Expression or function body, depending on the field
Condition slots - IfCondition.condition, While.condition,
ForEachLoop.collection - and the numeric slots take a single expression.
A statement there fails to compile rather than evaluating to undefined,
which a boolean slot would otherwise go on to read as false.
String, JSON and object slots also accept a function body, so an awkward transform can use a local and an early return:
{{ const shipped = items.filter(i => i.qty > 0);
return shipped.map(i => i.sku).join(", ") }}
An expression may span lines. In the DSL, which is Python source, a "..."
cannot contain a real newline - so triple-quote the argument, or write the
newline escaped as a backslash followed by n. Editing the same field in the
designer needs neither.
Collection operations
| Expression | Result |
|---|---|
items.filter(i => i.active) | items where predicate is true |
items.map(i => i.name) | list of field values |
[...items].sort((a, b) => a.priority - b.priority) | sorted ascending |
[...items].sort((a, b) => b.priority - a.priority) | sorted descending |
items.find(i => i.id === 42) | first match or undefined |
items.some(i => i.total > 100) | true if any element matches |
items.every(i => i.priority > 0) | true if every element matches |
items.filter(i => i.active).length | count of matches |
items.slice(0, 5) | first N elements |
items.reduce((acc, i) => acc + i.amount, 0) | fold |
[...items].reverse() | reversed copy |
[[1, 2], [3]].flat() | flatten one level |
Spread before sort and reverse: both are in-place methods, and while they
cannot corrupt the workflow variable (see Guarantees), copying reads clearly
and keeps the expression's own intermediate values honest.
Operators
| Operator | Example | Notes |
|---|---|---|
includes | names.includes("abc") | membership; also substring on a string |
startsWith / endsWith | path.endsWith(".pdf") | |
/re/.test(s) | /^[a-z]+$/.test(s) | regex |
?? | x ?? "default" | null-coalesce |
?. | user?.email | optional chaining |
? : | n > 2 ? "many" : "few" | ternary |
`${...}` | `${codes.length} codes` | template literal |
Use ===, not ==.
Data / type helpers
| Expression | Result |
|---|---|
items.length | length |
items[0], items[items.length - 1] | indexing |
items.slice(1, 3) | slicing |
Object.keys(obj) / Object.values(obj) | object keys/values |
Object.entries(obj) | [[key, value], ...] |
{...obj, c: 3} | object literal with spread |
JSON.parse(str) / JSON.stringify(value) | JSON parse / stringify |
Number("42") / String(42) / parseFloat("3.14") | conversions |
[...new Set(codes)] | deduplicate |
const x = expr; return ... | local binding (value slots only) |
An object literal at the start of an expression needs parentheses -
({...obj, c: 3}) - or the leading brace reads as a block.
Registered function packages
Spelled exactly as in the expr-lang flavour, so an expression ported between
the two languages keeps the same calls. Native JavaScript covers most of what
strings.* and math.* do; fuzzy.* and collections.* have no native
equivalent.
strings.* (38 functions)
ToLower, ToUpper, Contains, HasPrefix, HasSuffix, Split, Join,
Replace, ReplaceAll, Trim, TrimPrefix, TrimSuffix, Repeat,
Count, Fields, Cut, CutPrefix, CutSuffix, EqualFold, and more.
Match Go's strings package naming.
The multi-return ones come back as arrays:
{{ strings.Cut("a=b", "=") }} gives ["a", "b", true].
math.* (~30 functions)
Abs, Max, Min, Round, Floor, Ceil, Pow, Sqrt, Log,
Log10, Log2, trig functions, Pi, E, IsNaN, IsInf.
Pi and E are calls: math.Pi().
fuzzy.* (7 functions)
Similarity, IsMatch, Contains, BestMatch, BestMatchWithScore,
FindAllMatches, LookupMatch - for approximate string matching. There is no
JavaScript equivalent; reach for these rather than writing your own.
Example: {{ fuzzy.BestMatch(query, knownCompanies) }}
collections.* (3 functions)
| Function | Purpose |
|---|---|
collections.Fnmatch(name, pattern) | shell-glob match (*, ?, [...]) |
collections.Unique(arr) | first-seen dedup, any element type |
collections.DistinctBy(arr, "field") | first-seen dedup by field name |
[...new Set(arr)] dedups scalars natively; collections.Unique also handles
objects and nested arrays, which Set compares by identity.
path.* (6 functions)
GetFileName, GetExtension, GetDirectoryName,
GetFileNameWithoutExtension, Combine, GetTempPath.
Recipes
Each is the same task as the correspondingly named recipe in the expr-lang
reference, so the two can be compared directly. Every one is covered by a
passing probe in jsenv/capabilities_test.go.
Pick the latest record per unique key
{{ Object.values(resources.reduce((acc, r) => {
if (!acc[r.name] || r.updated_at > acc[r.name].updated_at) acc[r.name] = r;
return acc;
}, {})).map(r => r.id) }}
Deduplicate a list
{{ [...new Set(codes)] }}
{{ collections.DistinctBy(orders, "customer_id") }}
Set difference (what's expected but missing)
{{ expected.filter(n => !available.map(r => r.name).includes(n)) }}
Build a CSV string for a names= filter
{{ codes.map(c => c + ".pdf").join(",") }}
Match a supplier code against a mapping rule table
{{ codes.map(c => {
const hit = mappings.find(m => collections.Fnmatch(c, m.pattern));
return hit ? hit.tech_sheet + ".pdf" : c + ".pdf";
}) }}
Count duplicates by field value
{{ Object.values(resources.reduce((acc, r) => {
(acc[r.name] = acc[r.name] || []).push(r);
return acc;
}, {})).filter(g => g.length > 1).length }}
Conditional routing in an IfCondition
{{ orders.some(o => o.status === "pending" && o.total > 1000) }}
Safe access into nested webhook payloads
{{ webhookBody?.value?.[0]?.resourceData?.id ?? "unknown" }}
Group a list by a field
{{ resources.reduce((acc, r) => {
(acc[r.name] = acc[r.name] || []).push(r);
return acc;
}, {}) }}
Sort and take the top N
{{ [...items].sort((a, b) => b.score - a.score).slice(0, 5).map(i => i.id) }}
Guarantees, and what they rule out
- An expression cannot change a workflow variable.
codes.sort(),items.push(...),items[0].name = "x"anddelete obj.aall work on a copy; the binding is unchanged for the activities that follow. This matches expr-lang, which has no assignment at all. Don't try to use an expression to set a variable - use Assign. - An expression is bounded at 5 seconds. JavaScript has loops, so a runaway expression is possible in a way it never was in expr-lang. One that outruns the bound fails the activity rather than hanging the run.
- There is no host access. No
require, noprocess, nofetch, no timers, no file system. Only the packages above and the workflow's own variables are in scope. - An unknown name inside an expression is an error.
{{ nosuchname.length }}fails. A name on its own does not: the interpolation layer resolves{{ nosuchname }}to nil before any evaluator sees it, and it renders as<nil>. That is the same in both languages, and it is why a misspelt variable can reach a field as the text<nil>rather than failing the activity. A missing property of an object that does exist isundefined, as JavaScript normally has it.
Known limitations (document, don't try)
-
A number stored in a string field becomes a string, and
+then joins rather than adds. Assign writes the resolved text into the slot's declared type, so{{ n + 1 }}into astringslot leaves"1", and the next{{ n + 1 }}gives"11". Awhileon it then ends early with the wrong answer and no error anywhere. expr-lang rejects the same workflow outright withinvalid operation: string + int- JavaScript's+is defined on strings, so nothing here can catch it for you.Declare a counter as
integer, or force the arithmetic:{{ Number(n) + 1 }}. -
Object key order is not guaranteed. A workflow object arrives as a Go map, so
Object.keys(obj)andObject.entries(obj)may come back in any order. Sort them if order matters. -
No
async/await, no promises, no timers. An expression is evaluated synchronously and returns a value. -
new Date()has no clock you should depend on for the run's timing. Use the workflow's own variables for anything a later step compares against.
When RunPython is still the right choice
Use RunPython when you need:
- Multi-page / spatial PDF text extraction
- Stateful loops that mutate across activities (expressions are pure)
- External library calls (pandas, numpy, pillow, pymupdf, etc.)
- Anything you'd describe as "a small program" rather than "a transform"
Everything else - filtering, dedup, sort, group, set ops, pattern matching, string manipulation - is an expression.