The Collection Semantics That Surprise You at First

Editorial illustration in editorial-ink style depicting six panels naming the FHIRPath collection surprises: empty propagation, flattening, filter, iteration, order, type refine

Editorial illustration in editorial-ink style depicting six panels naming the FHIRPath collection surprises: empty propagation, flattening, filter, iteration, order, type refine

FHIRPath collections behave like flattened set-pipelines, and the four or five behaviors that surprise most people at first are worth naming explicitly. Once you have run into each of them once in the FHIRPath live tryout, the surface flattens out. For the broader entry, FHIRPath basics for someone who knows JavaScript is the starting point. For the wider FHIR framing, the rest of our FHIR coverage has more.

Empty Collections Everywhere

The first surprise. Patient.foo where foo does not exist returns an empty collection, not undefined. The pipeline keeps flowing. Patient.foo.bar.baz returns empty. No exception, no null pointer, no truthy value.

That silence is a feature. It means every expression can be composed without a defensive check at each step. It also means expressions that quietly return empty look identical to expressions that legitimately match zero items. Diagnosing "returns empty" always starts with "does the property exist on the target?"

Aggregation Flattens Silos

Patient.name.given returns all given names across all name entries as a single flat collection. If the Patient has two names each with two given names, the result is a collection of four strings. You cannot tell which given name came from which name entry from the result alone.

That flattening is the source of a lot of "why does my count not match?" questions. The count on Patient.name is the number of name entries. The count on Patient.name.given is the total number of given names across all entries. Different numbers, both correct.

For the memorized idioms that work around this, typical FHIRPath expressions worth memorizing has examples.

Filters Return Collections

.where() returns a collection too. Even when the filter matches exactly one item, the return type is a single-item collection, not an item. That distinction matters when you index into it: .where(use = 'official') is a collection, .where(use = 'official').first() is a single-item collection with the first match, and .where(use = 'official').single() is a single-item collection with the only match — throwing if there is more than one.

.first(), .last(), .single(), .tail(), .take(), and .skip() are the collection-navigation methods. Learning them by heart pays off fast.

Iteration Is Implicit

There is no .forEach() in FHIRPath. Everything that would be a loop in JavaScript is a pipeline step. Patient.name.given.length() returns the count. Patient.name.exists() returns whether the collection has items. Patient.name.all(family.exists()) returns whether every name has a family.

That last one — .all() — takes an expression evaluated against each item. It is the FHIRPath equivalent of array.every(). The sibling is .any(), equivalent to array.some().

Order Is Not Guaranteed

FHIRPath is not required to preserve order across pipeline steps that aggregate. Patient.name.given returns the given names, but the order across name entries is implementation-defined. Sort explicitly if order matters: .orderBy() is available in some engines, and manual sort via .aggregate() is available in the standard.

Expressions that assume a specific order are quietly fragile across engine implementations. When order matters, be explicit.

Types Get Refined Through Filters

Bundle.entry.resource returns a collection of Resource. .ofType(Patient) narrows it to a collection of Patient. .ofType(Observation) narrows to Observation. Chaining property accesses after .ofType() works because the collection's element type is now known.

Bundle.entry.resource.ofType(Patient).name.given returns the given names across all Patients in the Bundle. Without the .ofType(Patient), Bundle.entry.resource.name fails because name is not on every Resource type.

For the trap-heavy debugging path, reading a failing FHIRPath expression and finding the type mismatch is the entry.

The Short Version

Empty collections propagate silently. Property walks flatten. Filters return collections. Iteration is implicit through the pipeline. Order is not guaranteed. Types refine through .ofType(). Practice each one in the tryout with a small Patient resource; the intuition sticks in an hour.

Editorial-ink diagram of a FHIRPath collection pipeline with empty-collection propagation and flatten steps annotated, drawn as flat ink strokes with editorial-blue accents

Sources