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,IntegerandNumberare numbers,Booleanis a boolean,DateTimean 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 (
acceptsPinnedEventinevent-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.tsnor the interpreter catches it: the throw propagates out of the node'srunand fails that step of the execution. - In a trigger selector,
selectorMatchescatches 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 thejexlmessage (Filtre en erreur — « <file> » ne reçoit pas l'événement <type>.), counts it (meridian.selector.errors) and records aselector-errorentry 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:
Empty: a blank expression is
expression vide.Syntax: the first compile error, with the column of the offending token when
jexlreports 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 = 1caractère « = » invalide. Égalité : ==.criteria >=expression incomplète.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)eventflow.guardcondition (guard)criteria,value,contextflow.switchcase condition (switch)value,contextplugin conversion ( conversion)value,nownullandundefinedare always allowed, and so are the relative identifiers of a collection filter (criteria.items[.code == "x"]).Constant selector: a selector that reads no
eventfield (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 withRetirer « selector » pour recevoir tous les événements du type.: a workflow without a filter has noselectorkey 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 thandefaultanddone, 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).
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. - Choose which events start a workflow — trigger filters, fan-out, and the routing table.
- Connecter un EAI — workflows bound to an EAI connection, filtered by their selector.
- 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.