Immutable Builders
Every chainable method on every fhir-dsl builder returns a new instance. The builder you started with is never mutated. This section explains why that matters, how to exploit it, and the composition patterns it unlocks.
The rule
Each call clones the builder state and constructs a fresh instance. From packages/core/src/search-query-builder.ts (and the analogous transaction / batch builders):
// Inside every .where(), .include(), .sort(), .count(), etc.
return new SearchQueryBuilderImpl<S, RT, SP, Inc, Prof, Sel>(
this.#state.resourceType,
this.#executor,
{ ...this.#state, /* new slice */ },
undefined,
this.#urlExecutor,
this.#schemas,
);
There is no this.#state = ... anywhere. The constructor is the only thing that writes state, and it runs once per new builder. The same pattern holds for TransactionBuilderImpl, BatchBuilderImpl, ReadQueryBuilderImpl, and so on.
Why immutable?
Three concrete wins:
- Forking. A partial builder is a reusable fragment. Call
.where(...)again on the same base to produce a sibling. - Re-use across requests. Store a pre-built base in a module constant, refine per-request, fire many
execute()s. - Composition.
$if()and$call()can hand the builder to arbitrary callbacks without risk; the caller is guaranteed not to see the callback's edits unless it chooses to.
Forking
const activePatients = fhir.search("Patient").where("active", "eq", "true");
// Branch — neither call mutates `activePatients`
const smiths = await activePatients.where("family", "eq", "Smith").execute();
const jones = await activePatients.where("family", "eq", "Jones").execute();
// Still usable unchanged
const everyone = await activePatients.count(100).execute();
Because builders are cheap (they hold a state object, a couple of references, and nothing else), forking has negligible cost.
If you come from a jQuery / Express style, you might write:
const qb = fhir.search("Patient");
qb.where("active", "eq", "true"); // ← does nothing to `qb`!
qb.count(10); // ← also does nothing
await qb.execute(); // still an unfiltered, uncounted search
You must assign the return value: const qb2 = qb.where(...).count(...).
Reuse across requests
A partial builder can live in a module scope and be refined per handler call:
// shared/queries.ts
export const recentObservations = (fhir: FhirClient<Schema>) =>
fhir
.search("Observation")
.whereLastUpdated("ge", new Date(Date.now() - 86_400_000).toISOString())
.sort("date", "desc")
.count(50);
// handlers/patient.ts
app.get("/patient/:id/labs", async (req, res) => {
const page = await recentObservations(fhir)
.where("subject", "eq", `Patient/${req.params.id}`)
.where("category", "eq", "laboratory")
.execute();
res.json(page.data);
});
// handlers/vitals.ts
app.get("/patient/:id/vitals", async (req, res) => {
const page = await recentObservations(fhir)
.where("subject", "eq", `Patient/${req.params.id}`)
.where("category", "eq", "vital-signs")
.execute();
res.json(page.data);
});
The recentObservations fragment is never mutated; every handler forks it.
Composition: $if and $call
Two universal helpers are exposed on every builder (search, read, transaction, batch). They exist specifically because builders are immutable — otherwise they would not be safe.
$if(condition, callback) — optional fragments
Applies the callback only when condition is truthy; otherwise returns this unchanged:
async function search(query: { name?: string; bornAfter?: string }) {
return fhir
.search("Patient")
.$if(Boolean(query.name), (qb) => qb.where("family", "eq", query.name!))
.$if(Boolean(query.bornAfter), (qb) => qb.where("birthdate", "ge", query.bornAfter!))
.count(25)
.execute();
}
No spread-and-reassign; no temporary let qb = .... The callback receives the same this type via polymorphic this, so every generic slot (Inc, Prof, Sel) narrows correctly inside the callback body.
$call(callback) — always-apply transformers
Always runs the callback and returns whatever it returns:
// A reusable fragment, typed generically
const onlyFinal = <QB extends SearchQueryBuilder<any, any, any, any, any, any>>(qb: QB): QB =>
qb.where("status", "eq", "final") as QB;
const labs = await fhir
.search("Observation")
.where("category", "eq", "laboratory")
.$call(onlyFinal)
.execute();
const vitals = await fhir
.search("Observation")
.where("category", "eq", "vital-signs")
.$call(onlyFinal)
.execute();
$call can also return a non-builder — it is typed as $call<R>(cb: (qb: this) => R): R. This lets you commit to the compiled plan mid-chain:
const plan = fhir
.search("Patient")
.where("family", "eq", "Smith")
.$call((qb) => qb.compile());
// plan: CompiledQuery, not a builder
Transactions and batches
The same immutability rule holds for TransactionBuilder and BatchBuilder. Each .create(...), .update(...), .delete(...) call clones the pending entry list:
const base = fhir.transaction().create({ resourceType: "Patient", /* ... */ });
// Two independent transactions — both include the Patient, each adds a different Observation
const withBp = base.create({ resourceType: "Observation", code: /* BP */ });
const withHr = base.create({ resourceType: "Observation", code: /* HR */ });
await withBp.execute();
await withHr.execute();
// `base` still has exactly one pending entry; withBp and withHr each have two.
$if and $call work here too:
const tx = fhir
.transaction()
.create(patient)
.$if(wantObservation, (t) => t.create(observation))
.$call((t) => t.update(updatedMedication));
await tx.execute();
Interaction with generics
Because builders thread their full type state through the six generics (see Types & Generics), immutability is what keeps the generic chain narrow inside $if and $call. If the builder mutated, the callback would have to decide whether to return the (mutated) this or a new instance; with immutability, the signature (qb: this) => this is honest and the callback cannot accidentally widen the type.
This is exactly the Kysely pattern: <DB, TB, O> carried through every call, with no mutation at runtime.
Summary
- Builders are cloned on every chainable call; the original is never touched.
- Fork freely — each branch is independent and cheap.
- Assign the return value every time. A bare
qb.where(...)is a dropped value. $ifand$callare the blessed composition escape hatches and exist because builders are immutable.- Storing a partial builder as a module constant and refining per handler is idiomatic.