Skip to main content

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:

  1. Forking. A partial builder is a reusable fragment. Call .where(...) again on the same base to produce a sibling.
  2. Re-use across requests. Store a pre-built base in a module constant, refine per-request, fire many execute()s.
  3. 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.

Gotcha — do not treat chained calls as "apply to self"

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.
  • $if and $call are 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.