Skip to main content

.transform() — Typed Row Projection

.transform(fn) is the final projection step on a search builder. You hand it a callback t => ({...}) and get back a typed row per matched resource. Any reference you've .include()d is auto-dereferenced through the bundle — t("subject.name.0.given.0", null) walks through the Patient entry the server returned alongside the Encounter, all with full type inference.

Quick start

const rows = await fhir
.search("Encounter")
.include("patient")
.include("practitioner")
.transform((t) => ({
id: t("id", null),
status: t("status", "unknown"),
patientId: t.ref("subject.reference"),
patientName: t("subject.name.0.family", null),
practitionerName: t("participant.0.actor.name.0.family", null),
}))
.execute();

for (const row of rows.data) {
// row is fully typed: { id: string | null, status: string, patientId: string | null, ... }
}

Three rules govern the whole API:

  1. An .include() activates its target field. Without .include("patient"), subject stays a Reference and subject.name doesn't compile. Add the include and subject becomes Reference | Patient — both sides are reachable.
  2. Paths use explicit numeric segments for arrays. name.0.given.0, not name.given. The type system requires this so the runtime walks the same path the type describes.
  3. Nulls are sinkable. If any segment along the path is null / undefined / missing, t returns the fallback without calling your map function. No try/catch, no optional chaining.

The t namespace

t(path, fallback, map?)

The main callable. Reads a single field. If the path is missing, returns fallback. If present, optionally transforms the value through map.

t("status", "unknown"), // string | "unknown"
t("count", 0, (n) => n * 10), // number with map
t("birthDate", null, (d) => new Date(d)), // Date | null

t.ref(path)

Strips the ResourceType/ prefix from a reference string. Returns the bare id, or null if the path is missing or the value isn't a string. Paths ending in .reference, .type, .identifier, .display are not auto-dereferenced — you get the Reference field directly.

t.ref("subject.reference"), // "123" (from "Patient/123")
t.ref("subject"), // same — accepts the Reference itself

t.coding(path, system)

Scans an array of { system?, code? } and returns the first code whose system matches. Typed against CodeableConcept.coding shapes.

t.coding("code.coding", "http://loinc.org"), // "1234-5" or null

t.valueOf(path, system)

Same shape for { system?, value? } arrays — ContactPoint, Identifier.

t.valueOf("telecom", "email"), // "alice@example.com" or null

t.enum(path, table, fallback)

Look up a string value in a plain object or Map. Returns fallback if the key is missing.

t.enum("gender", { male: "M", female: "F" }, "U"), // "M" | "F" | "U"

Auto-dereferencing

This is the feature that makes .transform() more than a typed .map().

Given an Encounter with subject: Reference<Patient>, writing subject.name.0.given.0 would normally be a type error — Reference has no name field. But when you call .include("patient"), the builder widens the scope: subject becomes Reference<Patient> | Patient, so both subject.reference (the Reference side) and subject.name.0.family (the Patient side) compile.

At runtime, t builds a lookup map from the bundle's included[] entries keyed by "ResourceType/id". When your path hits an activated reference field and the next segment isn't a Reference structural field, t swaps in the included resource and keeps walking.

// Server returns: { entry: [ { resource: encounter, search: { mode: "match" } },
// { resource: patient, search: { mode: "include" } } ] }

.include("patient")
.transform((t) => ({
refId: t.ref("subject.reference"), // reads Reference.reference → "123"
displayText: t("subject.display", null), // reads Reference.display → "Ada L."
givenName: t("subject.name.0.given.0", null), // dereferences into Patient → "Ada"
}))

If the reference doesn't resolve (server dropped the include, reference points outside the bundle), the path returns undefined and t falls back to your default. Never throws.

Array-flattening expressions

Some search-param expressions traverse arrays transparently — Encounter.participant.actor maps each participant's actor. In paths, you still spell out the index you want:

.include("practitioner")
.transform((t) => ({
firstPractitioner: t("participant.0.actor.name.0.family", null),
secondPractitioner: t("participant.1.actor.name.0.family", null),
}))

The type system accepts any numeric segment; it's your job to pick one that exists in the response. Missing indices return undefined and fall back — no throw.

Paths that point at the Reference itself

When auto-dereferencing is active, the Reference object is still reachable through its structural fields (reference, type, identifier, display). This lets you read both sides from the same builder:

.include("patient")
.transform((t) => ({
patientId: t.ref("subject.reference"), // "123"
refType: t("subject.type", null), // "Patient"
display: t("subject.display", null), // "Ada Lovelace"
familyName: t("subject.name.0.family", null), // auto-dereferences → "Lovelace"
}))

Extending t with custom helpers

t is open for extension via declaration merging. Register an implementation, then augment the TExtensions<Scope> interface so TypeScript knows the helper exists:

import { registerTHelper } from "@fhir-dsl/core";

declare module "@fhir-dsl/core" {
interface TExtensions<Scope> {
age(path: Path<Scope>): number | null;
}
}

registerTHelper("age", (ctx, ...args) => {
const dob = ctx.walk(args[0] as string);
if (typeof dob !== "string") return null;
return new Date().getFullYear() - Number.parseInt(dob.slice(0, 4), 10);
});

Now every t closure has t.age(path). ctx.walk(path) gives you the same dereferencing machinery t(path, ...) uses — use it to read paths through your helper.

Use unregisterTHelper(name) to remove a helper — useful for scoped tests.

Path validation & autocomplete tradeoff

t, t.ref, and t.enum validate paths with a segment-by-segment walker rather than enumerating the full Path<Scope> union. This keeps typecheck time flat on wide scopes (Encounter + 3 includes compiles in ~0.5s instead of ~4s), but means the editor has no enumerable list to complete against:

  • Typos are still caught — the walker rejects subject.naem.0.famiy at compile time.
  • Autocomplete is not offered — typing t("sub won't suggest subject, subject.name, etc. You type the path from memory or from the FHIR spec.
  • Return type still inferredt("status", "") infers string | "" from Scope, same as before.

If your editor stalls even so, see t.raw below.

t.raw(path, fallback, map?) — the escape hatch

Same runtime behavior as t(...) (auto-dereferencing, nullish fallback, optional map), but path is typed as bare string with no path validation at all and return type defaults to unknown. Use it when:

  • You need a path the walker can't express (e.g. dynamic path strings).
  • You want to squeeze out the last bit of typecheck cost on very hot projections — t.raw skips even the walker.
t.raw("subject.name.0.family", null), // unknown | null
t.raw<string>("subject.name.0.family", null), // string | null
t.raw("count", 0, (n) => (n as number) * 10), // number

Tradeoff: you lose typo detection entirely — misspelled paths silently return the fallback at runtime.

.transform() on .read()

.transform() also lives on .read(resourceType, id). Single-resource variant — no includes, no bundle, no auto-dereferencing. Paths walk the resource directly with the same nullish-fallback semantics.

const row = await client
.read("Patient", "123")
.transform((t) => ({
id: t("id", ""),
family: t("name.0.family", null),
given: t("name.0.given.0", null),
gender: t("gender", null),
}))
.execute();

.execute() returns Promise<Out> — a single row, not { data: Out[] }. There's no .stream() (nothing to stream). If you want typed paths across a bundle of includes, use .search(...) with .include(...).

execute() vs stream()

.transform() returns a TransformedQuery<Out> with two terminals:

// All rows at once
const { data, total, link, raw } = await builder.transform(fn).execute();

// One row at a time
for await (const row of builder.transform(fn).stream()) {
// row is Out
}

stream() yields rows as the underlying bundle pages are produced, so large result sets don't need to sit in memory all at once.

Hand-authored schemas

Auto-dereferencing is driven by the generator-emitted includeExpressions map on your schema. If you're hand-writing a FhirSchema, add includeExpressions: Record<string, never> to opt out of dereferencing — .transform() still works, it just treats references as plain Reference values without bundle lookups.

When not to use .transform()

  • You need the full FHIR resource. Use .execute() with typed includes — SearchResult<Primary, Included> gives you data[] and included[] separately.
  • The projection depends on external data (e.g. joining a non-FHIR table). .transform() only sees the bundle — do the join after.
  • You want validation. .transform() skips Standard Schema validation; chain .validate() before .transform() if you need it.

Edge cases

  • Included resource missing from the bundle. Path returns undefined, t returns the fallback. Servers sometimes drop includes for permissions or filtering — don't rely on presence.
  • urn:uuid: references. Skipped; they don't participate in the "ResourceType/id" lookup scheme. The Reference fields are still readable via t.ref / t("subject.reference", ...).
  • Multi-expression search params (e.g. Encounter.subject | Encounter.patient). Both expressions activate in the scope and the runtime matches either at walk time.
  • Unparseable FHIRPath expressions (extension("url").value.as(Reference)). The generator skips these and logs a warning — .include() still works, but the target field isn't auto-dereferenced. You can still read the Reference side normally.