Documentation / Développement / Référence / Binding map expressions (MapExpr)

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 null or undefined, evaluation short-circuits and returns undefined; getPath never 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: MapExpr entry in map (or in resolve) is evaluated independently against the same raw record.
  • A result of undefined omits the key from the output object — it is never present with the value undefined. A partial raw record therefore yields a partial event, not one full of undefined fields.

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/missing run value (m.run falsy after mapping) is also treated as rejected without 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.

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