Flat Rows for Export
Problem
A reporting dashboard needs Encounters rendered as one row each, with the
Patient's name, the attending Practitioner, and the service-provider
Organization denormalized into the same row. Walking the Bundle by hand
means string-splitting "Patient/123", building a lookup map, and typing
every shape — four resources, one denormalization, dozens of lines of
glue per field.
.transform() collapses the whole pipeline into one builder chain.
Prerequisites
- Generated client at
./fhir/r4(runfhir-gen generate --version r4 --out ./src/fhir) - Packages:
@fhir-dsl/core - Server: any FHIR R4 server that honors
_include(HAPI works)
Steps
1. Create the client
import { createClient } from "./fhir/r4/client.js";
const fhir = createClient({
baseUrl: "https://hapi.fhir.org/baseR4",
});
2. Shape the row
Decide what each row looks like up front. The type is inferred — you're not declaring it, you're just building the shape inside the callback.
const rows = await fhir
.search("Encounter")
.where("status", "eq", "finished")
.include("patient")
.include("practitioner")
.include("service-provider")
.transform((t) => ({
encounterId: t("id", null),
status: t("status", "unknown"),
// Patient (activated by .include("patient"))
patientId: t.ref("subject.reference"),
patientGiven: t("subject.name.0.given.0", null),
patientFamily: t("subject.name.0.family", null),
patientGender: t.enum(
"subject.gender",
{ male: "M", female: "F", other: "O" },
"U",
),
// Practitioner (activated by .include("practitioner"))
practitionerId: t.ref("participant.0.actor.reference"),
practitionerFamily: t("participant.0.actor.name.0.family", null),
// Organization (activated by .include("service-provider"))
organizationName: t("serviceProvider.name", null),
// Structured codes
classCode: t.coding("class.coding", "http://terminology.hl7.org/CodeSystem/v3-ActCode"),
}))
.execute();
console.log(rows.data[0]);
// {
// encounterId: "abc-123",
// status: "finished",
// patientId: "pat-1",
// patientGiven: "Ada",
// patientFamily: "Lovelace",
// patientGender: "F",
// practitionerId: "pr-1",
// practitionerFamily: "Turing",
// organizationName: "St. Vincent's",
// classCode: "AMB",
// }
3. Stream for larger result sets
For thousands of Encounters, pull rows one at a time so memory doesn't
balloon. stream() yields each transformed row as its underlying page
lands.
import { createWriteStream } from "node:fs";
import { stringify } from "node:querystring";
const out = createWriteStream("encounters.csv");
out.write("encounter_id,patient_family,practitioner_family,org_name\n");
for await (const row of fhir
.search("Encounter")
.where("status", "eq", "finished")
.include("patient")
.include("practitioner")
.include("service-provider")
.transform((t) => ({
id: t("id", ""),
patient: t("subject.name.0.family", ""),
practitioner: t("participant.0.actor.name.0.family", ""),
org: t("serviceProvider.name", ""),
}))
.stream()) {
out.write(`${row.id},${row.patient},${row.practitioner},${row.org}\n`);
}
4. Handle missing data gracefully
.transform() returns your fallback whenever a path doesn't resolve —
the server dropped the include, the reference points to a resource
outside the bundle, or a field simply isn't populated. Choose fallbacks
that match the downstream consumer:
.transform((t) => ({
patientId: t.ref("subject.reference"), // null for missing Reference
patientName: t("subject.name.0.family", "(unknown)"), // string placeholder
loincCode: t.coding("code.coding", "http://loinc.org"), // null for missing Coding
}))
No try/catch, no optional chains — the walker never throws on missing data.
Why not .execute() + manual walk?
The old pattern looked like this:
// Before
const result = await fhir
.search("Encounter")
.include("patient")
.execute();
const byRef = new Map<string, Patient>();
for (const r of result.included ?? []) {
if (r.resourceType === "Patient" && r.id) byRef.set(`Patient/${r.id}`, r);
}
const rows = result.data.map((enc) => {
const patient = enc.subject?.reference ? byRef.get(enc.subject.reference) : undefined;
return {
id: enc.id,
patient: patient?.name?.[0]?.family ?? null,
};
});
With .transform():
// After
const rows = await fhir
.search("Encounter")
.include("patient")
.transform((t) => ({
id: t("id", null),
patient: t("subject.name.0.family", null),
}))
.execute();
Every step the manual version does — bundle split, prefixed lookup map,
reference resolution, optional chaining — moves inside .transform(). The
builder knows the expressions (Encounter.subject for patient) because
the generator emits them alongside the schema types, so there's no magic
or runtime introspection.
Related
.transform()guide — full API reference- Patient Timeline — building a chronological timeline across Encounter / Observation joins
- Parallel Reference Fetch — when
_includeisn't an option and you need to roll your own hydration