Skip to main content

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

ExpressionResult
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).lengthcount 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

OperatorExampleNotes
includesnames.includes("abc")membership; also substring on a string
startsWith / endsWithpath.endsWith(".pdf")
/re/.test(s)/^[a-z]+$/.test(s)regex
??x ?? "default"null-coalesce
?.user?.emailoptional chaining
? :n > 2 ? "many" : "few"ternary
`${...}``${codes.length} codes`template literal

Use ===, not ==.

Data / type helpers

ExpressionResult
items.lengthlength
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)

FunctionPurpose
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" and delete obj.a all 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, no process, no fetch, 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 is undefined, 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 a string slot leaves "1", and the next {{ n + 1 }} gives "11". A while on it then ends early with the wrong answer and no error anywhere. expr-lang rejects the same workflow outright with invalid 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) and Object.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.