FHIRPath + Query Builder
@fhir-dsl/core and @fhir-dsl/fhirpath ship as independent packages. There is one built-in integration point — .filter(expr) on the search builder — and everything else composes via plain JavaScript. This page lists every pattern, when to reach for it, and the gotchas.
When to combine them
| You want to… | Use |
|---|---|
| Filter beyond what FHIR search params support, server-side | Pattern A — compile to _filter |
| Project / reshape data after it lands | Pattern B — post-fetch expr.evaluate(resource) |
| Drop rows client-side using a typed predicate | Pattern C — filter result.data with .evaluate() |
| Reduce a bundle to a summary (counts, sums) | Pattern D — .aggregate() on the result set |
FHIRPath is not a replacement for typed search params (.where("family", "eq", "Smith")). Use search params first; reach for FHIRPath only when search params can't express the question.
Pattern A — FHIRPath → server-side _filter
Problem. The server supports _filter, but you don't want to hand-assemble a FHIRPath string.
Code.
import { fhirpath } from "@fhir-dsl/fhirpath";
import { createClient } from "./fhir/r4";
const client = createClient({ baseUrl: "https://hapi.fhir.org/baseR4" });
const officialFamilyIsSmith = fhirpath<"Patient">("Patient")
.name.where(($) => $.use.eq("official"))
.family.eq("Smith");
const result = await client
.search("Patient")
.filter(officialFamilyIsSmith)
.execute();
Expected wire URL.
GET /Patient?_filter=Patient.name.where(use%20%3D%20%27official%27).family%20%3D%20%27Smith%27
Explanation. .filter() accepts either a raw string or anything with a compile(): string method. Passing a FhirPathExpr directly avoids the .compile() call at the call site and keeps your query end-to-end type-safe.
Notes.
- Not every server supports
_filter. CheckCapabilityStatement.rest.searchParambefore committing to this path. - The expression is sent verbatim — if the server rejects it, you'll get a
400 OperationOutcome, not a TypeScript error.
Pattern B — Post-fetch projection
Problem. You already have a SearchResult, and you want to pull out a specific nested field across every row without writing .map().flat() yourself.
Code.
import { fhirpath } from "@fhir-dsl/fhirpath";
const result = await client.search("Patient").count(50).execute();
const cityExpr = fhirpath<"Patient">("Patient").address.city;
const cities: unknown[] = result.data.flatMap((p) => cityExpr.evaluate(p));
Expected output shape.
// cities: ["Boston", "Austin", "Providence", ...]
Explanation. .evaluate(resource) returns a flat unknown[] (FHIRPath collection semantics — empty inputs produce empty outputs). Build the expression once outside the loop and reuse it; the ops array is compiled on construction, not on every call.
Notes.
- The return is
unknown[]. Narrow with a predicate (.filter((x): x is string => typeof x === "string")) when you need a concrete type. includedresources are separate fromdata— project over them too if you need cross-reference fields.
Pattern C — Client-side filtering
Problem. The server returned 100 rows, and you want to keep only those satisfying a FHIRPath predicate that can't be expressed as a search param.
Code.
import { fhirpath } from "@fhir-dsl/fhirpath";
const result = await client.search("Observation").count(100).execute();
const highBloodPressure = fhirpath<"Observation">("Observation")
.component.where(($) => $.code.coding.code.eq("8480-6"))
.valueQuantity.value.where(($) => $.gt(140))
.exists();
const matches = result.data.filter((r) => highBloodPressure.evaluate(r)[0] === true);
Explanation. A FHIRPath predicate returns a boolean as the first element of a single-item collection, so the idiomatic pattern is .evaluate(r)[0] === true. This runs locally — no extra HTTP round-trip — but it burns memory on rows you'll throw away. When the matching fraction is small, prefer Pattern A.
Pattern D — Aggregates over the result set
Problem. You want a count, sum, or fold across a fetched bundle without a second request.
Code.
import { fhirpath, $total } from "@fhir-dsl/fhirpath";
const result = await client.search("Patient").count(1000).execute();
// Count patients with deceased = true
const deceasedCount = result.data.reduce(
(n, p) => n + (p.deceasedBoolean === true ? 1 : 0),
0,
);
// Or use FHIRPath's own aggregate over a nested collection on one resource:
const patient = result.data[0];
const addressCount = fhirpath<"Patient">("Patient")
.address.aggregate(($) => $total.add(1), 0)
.evaluate(patient);
// addressCount: [3] — always a single-element collection
Explanation. FHIRPath's .aggregate(fn, init) folds over a collection inside one resource; it is not a cross-resource reducer. For cross-resource folds (totals across result.data), plain Array.reduce is simpler and keeps types intact. Inside .aggregate(), $total is the current accumulator; $this is the current item.
Cheatsheet
| Pattern | Runs | Builder hook | Fhirpath surface |
|---|---|---|---|
A — _filter | server | .filter(expr) | fhirpath<T>(rt) + .compile() |
| B — project | client | none (pure TS) | .evaluate(resource) |
| C — filter | client | none (pure TS) | .evaluate(resource)[0] |
| D — aggregate | client | none (pure TS) | .aggregate(fn, init) or Array.reduce |
Gotchas
- Empty propagates. FHIRPath returns
[]for missing properties, which meansempty()/exists()/.eq(x)all behave predictably — but JSundefinedchecks do not translate. Never mixx === undefinedinto a FHIRPath chain; use.exists()/.empty(). - No string parser. FHIRPath expressions currently have to be built via the typed proxy — you cannot pass a raw
"Patient.name.family='Smith'"to.evaluate(). If you need to evaluate strings fromStructureDefinition.constraint.expression, that isn't supported yet. For_filteronly, a raw string still works via.filter("name eq 'Smith'"). .evaluate()loses type info. The return isunknown[]. If you need a typed projection, wrap the result in a narrowing guard.- Bundle size matters. Pattern C evaluates per row; on 10k-row bundles that adds up. Prefer Pattern A (server-side) when the server supports it.
_filterisn't universal. Many production servers disable it. Test with a400case before relying on it in a pipeline.
Related
@fhir-dsl/fhirpathAPI reference@fhir-dsl/coreAPI reference — see.filter(),.where(),.select()- Edge cases — condition-tree compile paths and
_filterserver compat - LLM usage guide — safe generation patterns when producing this combination