JEXL expressions
JEXL (Javascript Expression Language) is the expression syntax the engine
evaluates wherever a workflow node needs to test data rather than merely move
it. It is evaluated by the jexl
package (pinned to ^2.3.0 in
libs/engine-core/package.json),
through a single wrapper,
evalExpression:
export function evalExpression(expression: string, scope: Record<string, unknown>): unknown {
return jexl.evalSync(expression, scope);
}
No custom transform, function, or operator is registered on the shared jexl
instance anywhere in this repository, and no stock operator is removed — the
grammar available to a workflow author is exactly jexl's default grammar,
described in full below.
Where JEXL expressions are evaluated
| Site | Config field | Scope | Source |
|---|---|---|---|
flow.guard |
condition (string) |
{ criteria, value, context } |
catalog.ts |
flow.switch |
cases[].condition (string, one per case) |
{ value, context } |
catalog.ts |
| A plugin-contributed type conversion | expr (string) |
{ value } |
plugins.ts |
transform.format is not a JEXL site: its template config is a
{variable}-substitution string (each {name} becomes an input port,
replaced verbatim by that port's value), a distinct mechanism handled
in catalog.ts's "transform.format" behavior, not by evalExpression. See
Core primitives for that mechanism.
Both flow.guard's condition and flow.switch's cases are ordinary,
non-typeHint config fields (see
catalog-metadata.ts),
so — like any non-typeHint config field — either may be set statically in
YAML or overridden at runtime by a connection targeting <node>.<field>
(an exposed config field). The interpreter's
effectiveConfig helper in
interpreter.ts
resolves the wired value before the node runs, falling back to the static
value if the wire is unfed. For flow.guard.condition this is demonstrated
in Workflow DSL — expose: a transform.format
node builds the condition string, wired to garde.condition. Either way, the
string reaching evalExpression is indistinguishable from a literal one
written directly in the YAML. flow.switch.cases is overridable by the same
mechanism, but since its value is a structured list rather than a single
scalar, no shipped workflow wires it dynamically.
Evaluation scope
flow.guard — config.condition
const ok = evalExpression(condition, { criteria: inputs.criteria, value: inputs.value, context });
| Identifier | Bound to | Notes |
|---|---|---|
criteria |
the criteria input port |
Required port — absence propagation skips the node before the condition ever runs. |
value |
the value input port |
Optional port; undefined if not wired. |
context |
the ambient ContextEnvelope |
{ patient?: {id}, encounter?: {id}, order?: {id}, document?: {id} }. |
condition defaults to the literal string "true" when the config field is
absent (unconditional pass). When the condition is truthy, the node emits
pass with value if that port is fed, otherwise criteria; when falsy, it
returns {} — no output, pass never fires, and (per the done rule in
Workflow DSL — reserved identifiers)
done does not fire either.
Real condition, from
bio-result.workflow.yaml:
guard_creatinine:
type: flow.guard
config:
condition: criteria.code.code == "creatinine"
flow.switch — config.cases[].condition
for (const c of cases) {
if (c?.name && Boolean(evalExpression(String(c.condition ?? "false"), { value: inputs.value, context }))) {
return { [c.name]: inputs.value };
}
}
return { default: inputs.value };
| Identifier | Bound to | Notes |
|---|---|---|
value |
the value input port |
Required port. |
context |
the ambient ContextEnvelope |
Same shape as for flow.guard. |
criteria is not in scope here — flow.switch only has a value input
port. config.cases is evaluated in array order; the first case whose
condition is truthy wins and its name becomes the output port carrying
value. A case with no condition defaults to the literal string "false"
(never matches). If no case matches, the node emits on default instead.
Real condition, from
analyse-posos.workflow.yaml:
check_alerts:
type: flow.switch
config:
type: Integer
cases:
- name: critical
condition: value > 0
Plugin-contributed conversions — contributes.conversions[].expr
registerConversion({ from: conv.from, to: conv.to, label: conv.label, convert: (value) => evalExpression(conv.expr, { value }) });
| Identifier | Bound to | Notes |
|---|---|---|
value |
the conversion's input | The only binding — no context, no criteria. |
This is a plugin manifest contribution (schema and a runnable example at
Plugin manifest — contributes.conversions),
reached at runtime through the transform.convert node's { from, to }
config, via findConversion in
conversions.ts. Note
that the built-in conversions registered directly in that file (e.g.
EgfrResult → Decimal) are hand-written TypeScript functions, not JEXL —
expr and evalExpression apply only to conversions a plugin declares
through its manifest.
Grammar
jexl's grammar is defined in
node_modules/jexl/dist/grammar.js and
consumed unmodified (no addTransform/addFunction/addBinaryOp/removeOp
call exists in this repository).
Literals
| Type | Syntax | Notes |
|---|---|---|
| Boolean | true, false |
Lowercase, exact match only. |
| Number | 6, -7.2, .5 |
Optional leading -; a decimal point requires at least one digit after it (5. is invalid); no exponential notation. |
| String | "Hello \"user\"", 'Hey there!' |
Single or double quotes; the only recognized escape is a backslash before the string's own quote character (\" inside "…", \' inside '…'). No other escape sequences (e.g. \n) are defined. |
| Array | ['a', 'b'] |
Comma-separated element list. |
| Object | {a: 1, b: 'x'} |
Comma-separated key: value pairs. |
There is no null/undefined literal. An identifier that has no matching
key in the scope object evaluates to undefined rather than raising an
error — see Sandbox below.
Identifiers and path navigation
An identifier resolves a key in the scope object passed to evalExpression;
. and [...] navigate into nested objects and arrays.
| Expression | Meaning |
|---|---|
criteria |
The criteria key of the scope. |
criteria.code.code |
Nested field access via dot notation. |
context.patient.id |
Same, on the context binding. |
value["se" + "verity"] |
Dynamic key access via brackets (any expression inside). |
items[0] |
Numeric index into an array. |
Operators
| Precedence tier | Operators | Evaluates as |
|---|---|---|
| 10 (lowest) | &&, || |
Logical AND / OR, short-circuiting. |
| 20 | ==, !=, >, >=, <, <=, in |
Equality/ordering comparisons (JS ==/!= semantics — not ===); in tests substring (string haystack) or membership (array haystack, by ===). |
| 30 | +, - |
Addition/string concatenation, subtraction. |
| 40 | *, /, // |
Multiplication, division, floored (integer) division. |
| 50 (highest binary) | %, ^ |
Modulus, power. |
| unary | ! |
Logical negation; binds tighter than every binary operator. |
Parentheses ( … ) group sub-expressions and override precedence, as usual.
Ternary and elvis default
| Expression | Result |
|---|---|
test ? consequent : alternate |
consequent if test is truthy, else alternate. |
test ?: alternate (consequent omitted) |
test itself if truthy, else alternate — an "elvis" default. |
Example from the grammar: value.severity == "high" && value.detail != null
is a valid full boolean expression; context.patient.id ?: "unknown" is a
valid elvis default.
Collection filters
An array of objects can be filtered by a bracketed predicate whose leading dot addresses each element's fields:
| Expression | Meaning |
|---|---|
items[.severity == "high"] |
The subset of items for which the predicate is truthy. |
items[.severity == "high"].code |
Same, then projected to the .code field of the (first) match. |
Transforms and functions
The grammar reserves value \| name(args) (pipe transform) and
name(args) (top-level function) syntax, but the shared jexl instance in
this codebase has no transform and no function registered — every such
expression fails at evaluation time with Transform <name> is not defined.
or Jexl Function <name> is not defined. respectively. Neither construct is
used in any shipped workflow, plugin conversion, or core node.
Sandbox and errors
evalExpression does not sandbox by filtering the expression string — it
sandboxes by construction: jexl never calls the host's eval, exposes no
JavaScript global, and resolves every identifier strictly against the
scope object handed to it at the call site. An expression referencing
process or globalThis resolves to undefined, exactly like any other
unbound identifier — verified in
expression.test.ts.
The only data an expression can observe is what its call site passes in (see
Evaluation scope above).
Static validation
(validate.ts) does
not parse, type-check, or otherwise inspect condition/cases[].condition/
expr strings — it validates the dataflow graph (ports, connection types,
ambient context, required inputs), not expression syntax or the field paths
inside it. jexl.evalSync throws synchronously on a malformed expression or
on a runtime error (e.g. calling a method on undefined); evalExpression
does not catch it, and neither does the node's caller in catalog.ts or the
interpreter — the throw propagates out of the node's run and fails that
step of the execution.
Editor field-path autocomplete
The visual editor suggests field paths for a condition based on the type
wired to the tested port, via fieldSuggestions in
graph.ts: given a root
identifier (criteria or value) and its wired object type, it lists
root.<field> for every field of that type, and root.<field>.<subfield>
one level deeper for fields that are themselves objects. It is a suggestion
list only — any JEXL expression against the actual runtime scope is still
accepted, whether or not it appears in the suggestions. See
Use the visual editor and
Use the AI assistant (which surfaces the
same JEXL syntax when drafting flow.guard/flow.switch conditions, per
workflow-assist.ts).
Related
- Workflow DSL — the full node/connection grammar these conditions live inside.
- Core primitives —
flow.guard,flow.switch,flow.map, andtransform.format's{variable}templating. - Branch with guards and switch — task-oriented recipe for wiring these two nodes.
- Branching and absence — why
there is no
ifnode and how absence propagation replaces it. - Binding map expressions (MapExpr) — the unrelated, non-JEXL projection grammar used by event source bindings.
- Plugin manifest — the
contributes.conversionsschema (exprfield). - Validate a workflow — what static validation does and does not check.