Skip to main content

Evaluation model

This page explains how a syntactically-valid FLASH block produces output.

If you’re still learning the rule forms and indentation, start with Syntax. If you’re debugging errors or missing output, see Diagnostics.

Mental model

FLASH evaluates as a small, deterministic pipeline:

  • FHIR target context decides where a rule writes in the output resource.
  • Input context decides what expressions evaluate against.

You can think of a FLASH block as:

  • a FHIR-shaped write plan (the left-hand side paths)
  • with JSONata value computation (the right-hand side expressions)

The definition set (FHIR type/profile definitions) is what turns a path like name.family into:

  • a concrete element type
  • a cardinality rule (max=1 vs repeating)
  • slice/choice-type behavior
  • validation and any fixed/pattern-driven value injections

Pipeline (high level)

At a high level, evaluation follows these stages:

  1. Parse: the block is parsed into declarations, rules, and nested blocks.
  2. Preprocess: multi-step paths are normalized into nested single-step rules, and headers like Instance: are rewritten into equivalent rule forms.
  3. Resolve definitions: InstanceOf: is resolved to a FHIR type/profile and every rule path is resolved to an element definition (including cardinality, slices, and choice types).
  4. Evaluate rules: rule lines are executed top-to-bottom, evaluating expressions in the current input context and applying writes to the current FHIR target context.
  5. Shape + validate: values are type-checked/shaped according to the FHIR definitions, and validation produces diagnostics depending on your configured thresholds.
  6. Finalize: the resulting object is normalized (for example, meta.profile injection for profiled resources and definition-order key ordering).

The result is either:

  • a FHIR resource object (including resourceType), or
  • undefined when the block produces no meaningful output

FHIR target context

The FHIR target context is “the place in the output resource that this rule writes to”.

  • At the start of a block, the target context is the root resource (for example, Patient).
  • A context rule (a rule with no =) changes the target context for its nested rules.
  • Nested rules expand the effective path by concatenating parent + child segments.

These two blocks target the same element and produce the same output fragment:

InstanceOf: Patient
* maritalStatus.text = "Married"
InstanceOf: Patient
* maritalStatus
* text = "Married"

Output fragment:

{
"resourceType": "Patient",
"maritalStatus": {
"text": "Married"
}
}

“Empty rules” materialize/validate a target

An empty rule targets an element but does not assign a value on that line:

InstanceOf: Observation
* code.coding[ExampleCarePanel]

This form is useful when you want to:

  • select/create a specific slice entry, or
  • force validation of a structure that is otherwise only implied

Whether this produces diagnostics depends on the definition set (for example, whether the slice has required children).

Input context

The input context is what expressions evaluate against.

  • By default, expressions evaluate against the mapping input (the JSON value you pass to evaluation).
  • Context rules change only the FHIR target context; they do not change the input context.
  • Input-context rules (* (<expr>).<path>) evaluate <expr> and use its result as the input context for nested rule evaluation.

Input-context fan-out (“for each”)

When an input-context expression produces an array, the nested rule set evaluates once per item.

Input:

{
"phones": [
{ "value": "+1-555-0100" },
{ "value": "+1-555-0199" }
]
}

Mapping:

InstanceOf: Patient
* (phones).telecom
* system = "phone"
* value = value

Output fragment:

{
"telecom": [
{ "system": "phone", "value": "+1-555-0100" },
{ "system": "phone", "value": "+1-555-0199" }
]
}

In this example, inside the nested block the input context is each phone object, so value resolves to the phone’s value field.

When undefined produces no write

If an expression evaluates to undefined, that assignment produces no output for that rule.

This is commonly used as a guard:

InstanceOf: Patient
* deceasedBoolean = isDeceased

If isDeceased is missing from the input, deceasedBoolean is not written at all.

Block result can be undefined

If a FLASH block produces no meaningful output (for example, it only establishes the type but makes no writes), the block evaluates to undefined.

This matters most when you use FLASH blocks inside JSONata orchestration: JSONata often drops undefined values when building arrays/objects.

Arrays and cardinality shaping

FHIR definitions drive how repeated writes are combined.

max = 1: override (with merge semantics for objects)

When a target element has max cardinality 1:

  • multiple contributions are merged into a single occurrence
  • when two contributions conflict, the later one wins

Example: maritalStatus is a single CodeableConcept.

Input:

{
"maritalText": "Married",
"maritalCode": "M"
}

Mapping:

InstanceOf: Patient
* maritalStatus.text = maritalText
* maritalStatus.coding
* system = "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"
* code = maritalCode

Output fragment:

{
"maritalStatus": {
"text": "Married",
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus",
"code": "M"
}
]
}
}

Even though the mapping writes maritalStatus in two places, it produces a single maritalStatus object.

max > 1: append (each occurrence becomes an entry)

When a target element repeats (max > 1), each independent “occurrence” appends a new entry.

This is easiest to see with context rules:

InstanceOf: Patient
* name
* family = "ExampleCare"
* given = "Avery"

* name
* family = "ExampleCare"
* given = "Jordan"

Output fragment:

{
"name": [
{ "family": "ExampleCare", "given": ["Avery"] },
{ "family": "ExampleCare", "given": ["Jordan"] }
]
}

Gotcha: if an element repeats, splitting a single logical “thing” across multiple separated context blocks often creates multiple entries. When you mean “one entry with multiple fields”, keep those writes within the same context block (or target a slice when applicable).

Primitive arrays and underscore siblings

FHIR represents child properties of primitives (like id and extension) using underscore siblings. When a primitive repeats (for example, address.line[]), the underscore sibling is also an array and uses null placeholders to preserve index alignment.

You don’t need to write the underscore path yourself. For example, a rule that targets a primitive’s child path:

InstanceOf: Patient
* gender = "unknown"
* gender.extension
* url = "urn:examplecare:demo"
* valueString = "example"

…writes the extension under the primitive’s underscore sibling (_gender) in the output.

Type-aware shaping and injections

FLASH does not treat the output as a free-form JSON object. Every write is interpreted through the FHIR element definition that the path resolves to.

Primitive vs complex vs resource assignments

  • Writing a primitive value to a primitive element (for example, active = true) is accepted when the value can be coerced/validated to the target type.
  • Writing an object to a complex element (for example, address = {"city": "ExampleCare City"}) is validated against that complex type.
  • Writing to a Resource-typed element expects an object with a resourceType (for example, contained, or Bundle.entry.resource).

Whether type mismatches throw or only generate diagnostics depends on threshold configuration (see Diagnostics).

Choice types require a concrete choice

When a FHIR element is a choice type (like value[x]), rule paths use the concrete JSON name:

InstanceOf: Observation
* valueQuantity = 180

Quantity shorthand

When the target element is a Quantity, assigning a primitive value is treated as shorthand for the value field.

InstanceOf: Observation
* valueQuantity = 180

Output fragment:

{
"valueQuantity": { "value": 180 }
}

If the active definitions fix parts of the Quantity (for example, unit/system/code), those fixed values can be injected so the output reflects the constrained shape.

Profile-driven injections and ordering

Definitions can cause additional structure to appear even when it is not explicitly written, such as:

  • meta.profile injection when InstanceOf: refers to a constrained profile
  • fixed/pattern-driven values that make the output conform to profile constraints
  • reordering keys to match FHIR definition order

These effects are definition-driven: base resources without such constraints do not gain additional fields.

Slices

Slices are a definition-level way to distinguish specific entries under a repeating element (for example, “this identifier entry is the MRN”).

In FLASH, a slice selector in a path targets a single logical entry:

InstanceOf: Patient
* identifier[ExampleCareMrn]
* system = "urn:examplecare:mrn"
* value = mrn

Repeated writes to the same slice selector contribute to the same slice entry (merged according to the slice’s own cardinality constraints).

Slices also interact with validation:

  • if a sliced element has required slices, touching the sliced parent can cause missing required slices to be reported (or, in some configurations, auto-generated)
  • if a slice has required child fields, materializing the slice entry without those fields can produce diagnostics

When you want to “select/create the slice entry” without assigning on that line, the empty rule form is a good fit:

InstanceOf: Observation
* code.coding[ExampleCarePanel]
* system = "http://loinc.org"
* code = "1234-5"