Binding map expressions (MapExpr)
MapExpr is the projection grammar an event binding
uses to turn one field of a raw record (a FHIR resource, an HL7 message, any
JSON-shaped payload from an event source)
into one field of a domain event, or into one of the run / decision / by
values used to resolve a suspended human validation. It is pure, declarative
data — no code, no URL — so a binding travels between plugin and instance, and
between instances, unchanged.
The grammar is defined once, browser-safe, in
libs/shared/src/plugin/mapping.ts
as the zod schema MapExprSchema (exported type MapExpr). It is consumed by
EventBindingContribSchema in
libs/shared/src/plugin/manifest.ts.
Evaluation is a separate concern that lives on the API host, in
libs/engine-core/src/engine/event-mapping.ts
(applyMapping, evalExpr) — the schema never imports an evaluator, and the
evaluator never imports zod.
This page documents the grammar and its evaluation semantics exhaustively. For
the surrounding binding schema (event, resolve, query…) see the
plugin manifest reference; for
the authoring workflow see
Contribute an event source and binding.
Where a MapExpr is used
| Location | Shape | Required |
|---|---|---|
eventBindings[].map |
Record<string, MapExpr> |
no (defaults to {}) |
eventBindings[].resolve.run |
MapExpr |
yes, when resolve is present |
eventBindings[].resolve.decision |
MapExpr |
yes, when resolve is present |
eventBindings[].resolve.by |
MapExpr |
no |
A binding declares exactly one of event (with map) or resolve — see
Event bindings.
Grammar
MapExprSchema is a zod union of four forms, checked in this order:
| Form | Zod shape | Meaning |
|---|---|---|
| Dotted path (shorthand) | z.string() |
Property access by .-separated segments; a numeric segment indexes an array. |
| Path object | z.object({ path: z.string(), ref: z.boolean().optional() }) |
Same access as the string form, plus optional FHIR-reference stripping. |
| JSONPath object | z.object({ jsonpath: z.string(), all: z.boolean().optional(), ref: z.boolean().optional() }) |
A jsonpath-plus expression (filters, wildcards, recursive descent). |
| Literal | z.object({ const: z.unknown() }) |
A fixed value, independent of the record. |
Source: MapExprSchema in
mapping.ts.
Dotted path — "a.b.0.c"
The shorthand form. Evaluated by getPath:
function getPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const seg of path.split(".")) {
if (cur == null) return undefined;
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}
- The path is split on
.; each segment is a plain property lookup. - A numeric segment (e.g.
"0") indexes an array the same way — array elements have no separate syntax. - If any intermediate value is
nullorundefined, evaluation short-circuits and returnsundefined;getPathnever throws.
Example (from external-plugins/fhir/plugin.yaml's fhir-creatinine binding):
value: "valueQuantity.value", occurredAt: "effectiveDateTime".
Path object — { path, ref?: true }
Same traversal as the string form (getPath(record, expr.path)), packaged so
ref can be attached.
| Key | Type | Required | Meaning |
|---|---|---|---|
path |
string | yes | Dotted path, identical semantics to the shorthand. |
ref |
boolean | no | When true, strip a FHIR-style reference prefix from a string result. |
ref calls stripRef:
function stripRef(v: unknown): unknown {
return typeof v === "string" && v.includes("/") ? v.slice(v.indexOf("/") + 1) : v;
}
It takes the substring after the first / — not specifically the FHIR
ResourceType/id pattern, any string containing a / is cut there
("Patient/patient-42" → "patient-42"; "a/b/c" → "b/c"). A non-string
value, or a string without /, passes through unchanged. ref is silently a
no-op when the underlying value isn't a string.
Example: patientId: { path: subject.reference, ref: true } on
{ subject: { reference: "Patient/patient-42" } } → "patient-42".
JSONPath object — { jsonpath, all?: true, ref?: true }
For cases the dotted path can't express: filters, wildcards, recursive
descent. Evaluated with the jsonpath-plus
library:
function evalJsonPath(record: unknown, path: string, all?: boolean): unknown {
const res = JSONPath({ path, json: record as object, wrap: !!all });
if (all) return res; // always an array
return Array.isArray(res) ? res[0] : res; // filter → array: take the first
}
| Key | Type | Required | Meaning |
|---|---|---|---|
jsonpath |
string | yes | A JSONPath expression, evaluated against the raw record as $. |
all |
boolean | no | false/absent: return only the first match. true: return every match, always as an array (wrap: true). |
ref |
boolean | no | Same stripRef post-processing as the path object — applied to whatever evalJsonPath returns. |
With all absent (wrap: false, jsonpath-plus's own semantics): zero
matches → undefined; exactly one match → that value, unwrapped; more than
one match → an array (of which only the code path documented above still
takes index 0 — effectively "first match" in every case).
A malformed JSONPath expression, or an expression evaluated against a record shape it doesn't expect, can throw — see Errors and omission.
Examples (from apps/api/tests/test-events.ts), evaluated against
{ code: { coding: [{ system: "...snomed...", code: "X" }, { system: "http://loinc.org", code: "2160-0" }] }, identifier: [{ value: "lab-1" }, { value: "lab-2" }], subject: { reference: "Patient/p9" } }:
| Expression | Result |
|---|---|
{ jsonpath: "$.code.coding[?(@.system=='http://loinc.org')].code" } |
"2160-0" |
{ jsonpath: "$.subject.reference", ref: true } |
"p9" |
{ jsonpath: "$.identifier[*].value", all: true } |
["lab-1", "lab-2"] |
Edge case — ref with all: true: stripRef only acts on a string
value; with all: true the result is an array, so ref has no effect on it
(each element is not stripped individually). Combining ref: true with
all: true is therefore meaningless as currently implemented.
Literal — { const: <any> }
if ("const" in expr) return expr.const;
Returns the configured value verbatim, ignoring the record entirely. const
is z.unknown(): any JSON-compatible value (string, number, boolean, object,
array, null) is accepted, not only strings.
Example: analyte: { const: creatinine }.
Evaluation — evalExpr and applyMapping
Dispatch, in evalExpr (event-mapping.ts):
| Check | Branch |
|---|---|
typeof expr === "string" |
Dotted path via getPath. |
"const" in expr |
Literal. |
"jsonpath" in expr |
JSONPath via evalJsonPath, then ref if set. |
| (else) | Path object: getPath(record, expr.path), then ref if set. |
A whole mapping table is applied with applyMapping:
export function applyMapping(record: unknown, map: Record<string, MapExpr>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [field, expr] of Object.entries(map)) {
const v = evalExpr(record, expr);
if (v !== undefined) out[field] = v;
}
return out;
}
- Every
field: MapExprentry inmap(or inresolve) is evaluated independently against the same rawrecord. - A result of
undefinedomits the key from the output object — it is never present with the valueundefined. A partial raw record therefore yields a partial event, not one full ofundefinedfields.
Errors and omission
getPath and the literal/path branches never throw. evalJsonPath (via
jsonpath-plus) can throw — a malformed JSONPath string, or an expression
applied to a record shape it cannot traverse. applyMapping itself does not
catch that: the throw propagates to the caller.
The host (apps/api/src/server/api.ts, the event-source ingestion loop) wraps
every applyMapping call in a try/catch per emitted record:
- On the event/map path, a throw is logged and the generation is recorded
with outcome
rejected; the record that failed to map does not stop the source or affect other records. - On the resolve path, a throw is likewise logged and recorded as
rejected; an empty/missingrunvalue (m.runfalsy after mapping) is also treated asrejectedwithout calling the resolution path.
See
apps/api/src/server/api.ts
(startEventSources, the emit closure).
Host application
Starting a run (event + map)
The host builds the domain event object as:
event = {
...applyMapping(data, binding.map),
type: binding.event,
eventId: `${sourceName}:${feed}:${recordId}`,
};
eventId is derived from the source mechanism name, the feed (= binding)
name, and the record id the source mechanism supplied — stable across
re-emissions of the same logical record, which is what makes ingestion
idempotent downstream. The mapped fields never include type or eventId
themselves; those two are added after applyMapping runs, so a map entry
named type or eventId would be silently overwritten.
The resulting outcome, recorded per source/feed/event, is one of accepted
(a new run started), duplicate (the eventId was already seen), or
rejected (mapping threw, or the ingestion rejected the event — e.g. unknown
type).
Resolving a validation (resolve)
const m = applyMapping(data, binding.resolve);
const runId = m.run != null ? String(m.run) : "";
// … opResolve(runId, { decision: m.decision != null ? String(m.decision) : undefined, by: … })
m.run, m.decision, m.by are read back as plain values (coerced with
String(...) when present) and passed to the host's run-resolution path. The
decision is normalized there: any value other than the literal string
"rejected" is treated as "accepted" — so, for example, a FHIR Task
status of completed maps to an accepted decision. Resolution is idempotent:
re-observing an already-applied decision is a benign no-op (duplicate
outcome), not an error.
See Add human validation and
Run and resolve for the run-side
lifecycle that resolve bindings feed into.
Worked example
The fhir-creatinine binding's map (see
Contribute an event source and binding),
applied to a raw Observation:
map:
patientId: { path: subject.reference, ref: true }
encounterId: { path: encounter.reference, ref: true }
analyte: { const: creatinine }
value: valueQuantity.value
unit: valueQuantity.unit
occurredAt: effectiveDateTime
{
"subject": { "reference": "Patient/patient-42" },
"encounter": { "reference": "Encounter/enc-7" },
"valueQuantity": { "value": 160, "unit": "µmol/L" },
"effectiveDateTime": "2026-06-10T09:30:00Z"
}
produces:
{
"patientId": "patient-42",
"encounterId": "enc-7",
"analyte": "creatinine",
"value": 160,
"unit": "µmol/L",
"occurredAt": "2026-06-10T09:30:00Z"
}
If the raw record only has subject.reference (no valueQuantity, no
effectiveDateTime), the produced object is { "patientId": "patient-42", "analyte": "creatinine" } — the other keys are omitted, not set to null or
undefined.
The corresponding resolve variant (fhir-task-decisions, a FHIR Task
resource):
resolve:
run: { jsonpath: "$.basedOn[0].reference", ref: true }
decision: status
by: { path: owner.reference, ref: true }
These examples are exercised as executable assertions in
apps/api/tests/test-events.ts.
Related references
- Plugin manifest — contributes.eventBindings —
the full
eventBindingsschema (name,source,query,event/resolve). - Plugin manifest — contributes.eventSources —
the mechanism a binding's
sourcenames. - Contribute an event source and binding — the authoring recipe for both sides.
- Connect an event source and activate bindings — the operator side (connection config, allow-list).
- The open event model — why source, binding, and connection are three separate concerns.
- FHIR mapping reference (
apps/ehr-lab/docs/resources/reference/fhir-mapping.md) — FHIR-specific conventions for writing amap. - Add human validation and
Run and resolve — the workflow
side that
resolvebindings resume.