Documentation / Conception / Référence / JEXL expressions

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.guardconfig.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.switchconfig.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. EgfrResultDecimal) 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).

75 documents15 sectionssource : /docs · généré au build