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

JEXL expressions

JEXL (Javascript Expression Language) is the expression syntax the engine evaluates wherever a workflow needs to test data rather than merely move it: in a node, or in the trigger selector that decides whether a workflow receives an event. 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);
}

expression.ts registers the transforms listed below on the shared jexl instance. No function or operator is added and no stock operator is removed: the grammar available to a workflow author is jexl's default grammar plus those transforms, 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, now } plugins.ts
A workflow trigger trigger.selector (string) { event } workflow-spec.ts
A template in a text config field, including transform.format's template each {…} placeholder the node's inputs, plus context template.ts

A template placeholder is a restricted expression: a path followed by transforms with literal arguments. Operators, ternaries, filters and functions are refused by static validation.

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} }.

At runtime, condition defaults to the literal string "true" when the config field is absent, but static validation refuses a guard whose condition is absent or blank, unless a connection feeds the condition config field. 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. At runtime, a case with no condition defaults to the literal string "false" (never matches); static validation refuses it. 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, now: new Date().toISOString() }) });
Identifier Bound to Notes
value the conversion's input No context, no criteria.
now the instant of the call ISO 8601 UTC string (2026-09-24T08:30:00.000Z), for dating the produced value.

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. That registry starts empty; the host fills it from the plugin manifests. A conversion whose expr fails the static checks (syntax, an identifier other than value or now, an unknown transform or function) is not registered: the host logs conversion ignorée : expression invalide with the plugin, the conversion and the problems, and loads the rest of the manifest.

A conversion runs inside the durable step of the node that calls it. On a replay, the step returns its journaled output, so now keeps the value of the first execution.

Real conversion, from @posos/clinical:

- from: Prescription
  to: Annotation
  label: "Ordonnance → note (résumé)"
  expr: "{ text: 'Ordonnance ' + value.identifier + ' : ' + (value.items|length) + ' ligne(s).', author: 'conversion', time: now }"

Trigger selector — trigger.selector

return Boolean(selection.evaluate(selector, { event: selection.event }));
Identifier Bound to Notes
event the event body as received type, eventId, occurredAt, the context identifiers (patientId, encounterId…) and the event's fields. An event delivered by an EAI connection also carries content (the source message) and connectionId.

The selector reads the body the producer sent, before the trigger runs, never the trigger's outputs:

  • a field of a declared event has the JSON form of its type: Decimal, Integer and Number are numbers, Boolean is a boolean, DateTime an ISO 8601 string, a coded type a code string;
  • a field the contract does not declare is present as received;
  • an absent field is undefined.

The event and the trigger's outputs often differ; see Events and triggers — what a filter reads.

The payload is reachable only through event. analyte == "creatinine" reads a root other than event and no field of the event: the console check and publication refuse it (« analyte » inconnu ici (disponibles : event)., then le sélecteur ne lit aucun champ de l'événement (event.<champ>).). At run time analyte would resolve to undefined. Write event.analyte == "creatinine".

selectorMatches in workflow-spec.ts is the only place a selector is evaluated. evaluate is passed in by the API host (libs/shared does not depend on jexl): the same jexl evaluation as evalExpression, through a compilation cache (compiledExpression in expression.ts, bounded to 1,000 texts), so a selector is not parsed again on every event. Two paths call it, with the same semantics — the selector filters its own workflow:

  • Event routing (routeEvent): every published workflow whose trigger receives the event type starts if it has no selector, or if its selector is truthy. A selector restricts its workflow and nothing else.
  • EAI delivery (acceptsPinnedEvent in event-target.ts): every published workflow bound to the connection is a target, and its selector filters it. A target without a selector receives every message.

Example, from Choose which events start a workflow:

trigger:
  type: trigger.bio-result-received
  selector: event.analyte == "creatinine" && event.value > 150

Grammar

jexl's grammar is defined in node_modules/jexl/dist/grammar.js. The only additions are the transforms, one addTransform call each in expression.ts; no 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; a backslash before the string's own quote character is that quote (\" inside "…", \' inside '…'), and a doubled backslash \\ reads as one backslash only at its first occurrence in a literal (jexl 2.3.0): "a\\b\\c" is a\b\\c. No other escape sequences (e.g. \n) are defined. The guided filter mode refuses text values with a backslash.
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.<kind>.id Same, on the context binding, e.g. context.patient.id for the kind patient declared by @posos/common.
value["se" + "verity"] Dynamic key access via brackets (any expression inside).
items[0] Numeric index into an array.

A . step on an array addresses its first element, not the array: items.code reads items[0].code, and on an empty list it raises a TypeError. items.length is therefore not the size of the list — it reads items[0].length. The size of a list is items|length (see Transforms and functions).

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.
items[.severity == "high"]|length The number of matching elements.

Transforms and functions

The grammar has a pipe-transform syntax, subject|name(args), and a top-level function syntax, name(args). These transforms are registered, in expression.ts, and are available in conditions, selectors, conversions and templates alike:

Transform Subject Result
length a list or a string Its number of elements or characters.
trim, upper, lower any value, as text The text without surrounding spaces, in upper case, in lower case.
default(fallback) any value fallback when the subject is absent or "", else the subject.
truncate(n) any value, as text The first n characters followed by … when longer.
replace(search, replacement) any value, as text Every occurrence of search replaced — literal text, not a regular expression.
join(separator) a list Its elements as text, joined by separator (default , ).
json any value Its JSON serialization.
urlencode any value, as text The text encoded for a URL component.
round(digits) a number The number rounded to digits decimals (0 to 10, default 0).

"As text" follows the template rule: a quantity reads 3.2 mg, a list is joined with , , an object is JSON. Except default, a transform keeps an absent subject absent. All transforms are pure: their result depends only on their arguments.

An absent subject (undefined or null) gives undefined, not 0: an unknown list is not an empty one. x|length == 0 is therefore false when x is absent, so a guard meaning "no alert" does not pass on a list that was never provided. Write (x|length) ?: 0 to count an absent list as empty.

For a subject of any other type, length raises |length : liste ou texte attendu, reçu <type>. A transform binds tighter than the binary operators: 'n=' + value.items|length and value.items|length > 0 need no parentheses. length is pure: its result depends only on its subject.

An unregistered transform fails at evaluation time (Transform <name> is not defined.), and so does every function call, since no function is registered (Jexl Function <name> is not defined.). Static validation refuses both before the workflow runs. The length transform is used by the Prescription → Annotation conversion of @posos/clinical.

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

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. The call site decides what the error does:

  • In a node, neither the node's caller in catalog.ts nor the interpreter catches it: the throw propagates out of the node's run and fails that step of the execution.
  • In a trigger selector, selectorMatches catches it and the selector counts as false: that workflow does not start for that event, and the proxy logs a warning with the file, the event type, the event id and the jexl message (Filtre en erreur — « <file> » ne reçoit pas l'événement <type>.), counts it (meridian.selector.errors) and records a selector-error entry in the live traffic. The other workflows that receive the event start as usual. EAI delivery continues with the other targets and the connection's bindings, then marks the message delivered. A selector error has the same effect as a false selector: it does not fail the ingestion and does not hold the message for retry.

Static validation

expression-check.ts checks an expression without evaluating it. expressionProblems(expression, kind) compiles it with expressionAst (jexl.compile, in expression.ts), then walks the syntax tree. The same function serves validateSpec (validate.ts), the plugin loader (conversions) and the console's live check.

Checks, in order:

  1. Empty: a blank expression is expression vide.

  2. Syntax: the first compile error, with the column of the offending token when jexl reports it, and a hint for three common mistakes:

    Written Message
    criteria.value.value >= 6,0 « , » inattendu. Séparateur décimal : le point (6.0). (column 26)
    criteria ET value « ET » inattendu. Opérateurs logiques : && et ||.
    criteria = 1 caractère « = » invalide. Égalité : ==.
    criteria >= expression incomplète.
  3. Tree: every unknown transform (transformation « |lenght » inconnue (disponibles : default, join, json, length, …).), every function call (none is registered), and every root identifier outside the scope of the call site:

    Site (kind) Allowed roots
    trigger.selector (selector) event
    flow.guard condition (guard) criteria, value, context
    flow.switch case condition (switch) value, context
    plugin conversion (conversion) value, now

    null and undefined are always allowed, and so are the relative identifiers of a collection filter (criteria.items[.code == "x"]).

  4. Constant selector: a selector that reads no event field (true, 1 == 1) is refused: it would match every event.

validateSpec reports these problems as spec errors, so saving the workflow directly, publishing a draft, or starting a draft run fails:

  • trigger.selector: Sélecteur du déclencheur invalide, colonne <n> : <message>. An empty selector is refused with Retirer « selector » pour recevoir tous les événements du type.: a workflow without a filter has no selector key at all.
  • flow.guard: Nœud « <id> » : condition, colonne <n> : <message>. An absent or blank condition is refused (condition vide.).
  • flow.switch: Nœud « <id> » : cas « <name> », condition, colonne <n> : <message>. Each case also needs a name made of letters, digits, _ and -, unique in the node, other than default and done, and a condition.

Template placeholders in text config fields are checked by templateErrors (template.ts): the syntax, the restricted form (a path followed by registered transforms with literal arguments), the transform names, and {context} without a kind. The syntax and unknown-transform messages are the ones above, without a column: the error names the node, the field and the placeholder, Nœud « <id> » : « <field> » : « {<placeholder>} » : <message>. No field is checked both ways: flow.guard's condition is not a template, and the switch cases and the trigger selector are not text fields of a node.

A condition or a cases list fed by a connection is not checked: its value exists only at run time. The checks prove the syntax and the identifiers, not the evaluation: a path that does not exist on the wired type (criteria.valeur for criteria.value) passes, and evaluates to undefined. The same holds for a selector: only the root event is checked, so a path the event's contract does not declare (event.analyt) passes and evaluates to undefined on every event that lacks it; for a declared event, the console's filter check reports it as a note (below). At startup, a stored definition that fails validateSpec is loaded and reported with a warning; it cannot be published again until it is fixed.

In the console, the trigger filter panel and the guard and switch condition fields call POST /api/expressions/check while the user types. The route returns the same problems as validateSpec, with the column as a separate field. A selector check names the trigger instance it filters, trigger: { type, config? } (the workflow's trigger type and config; config must be an object), and the route resolves that instance's facts once (triggerFacts). For a selector, it also returns the event fields, the guided rows (selectorBuilder), the result on the example event, the published workflows that would receive the example event, the other published workflows that receive the same event type, with their effects, and its notes. The event fields feed the filter's guided Field list: the contract's fields of a declared event, the context identifiers, the example's other keys, then eventId and occurredAt. A note unknown-event-field names each event.<field> the selector reads outside the contract of the trigger's declared event: neither a declared field, nor the id field of one of the event's context kinds, nor type, eventId, occurredAt or connectionId. A note does not make the selector invalid, since the contract is open. A type without a contract, a blank expression or an invalid one gets no note. See Choose which events start a workflow.

The syntax tree is read from a jexl 2.3.0 internal (_ast) by expressionAst, the one helper that templates and expression checks share. The transform names come from TRANSFORM_NAMES in the same module; expression-check.test.ts checks that this list equals the jexl registry.

Editor field-path autocomplete

The visual editor suggests field paths for a guard or switch 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. The trigger filter has its own guided Field list, built from the trigger's event (see Edit the filter in the console). 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).

97 documents17 sectionssource : /docs · généré au build