Skip to main content

Extending @fhir-dsl/core — Consumer-Facing Helper Types

When you build your own abstractions on top of SearchQueryBuilder — condition libraries, query composers, policy wrappers — you run into the same problem every generic query builder has: how do you accept "any builder" without losing the caller's concrete type on the way back out?

@fhir-dsl/core exports a Kysely-inspired set of type helpers so you don't have to re-derive the answer. Pick the helper that matches your helper's shape and you get zero-any, variance-safe composition.

The five helpers

HelperWhat it isWhen to use
AnySearchBuilder<S>Wildcard over every slot but the schemaWrite helpers that take any builder, filter it, and hand it back
SearchBuilderOf<S, RT>Wildcard bound to a known resource typeHelpers that only run on a specific resource (e.g. "must be an Encounter")
FilterableBuilder<S, RT>Filter-only subset — no .include() / .transform() / .execute()Pure predicates (nothing that terminates the chain)
ResourceOf<QB>Extract RT from a builderRead the resource type of the builder you were handed
IncludesOf<QB>Extract the current include-mapRead which references have been .include()d

All five come from @fhir-dsl/core — the same module that exports SearchQueryBuilder.

import type {
AnySearchBuilder,
FilterableBuilder,
IncludesOf,
ResourceOf,
SearchBuilderOf,
} from "@fhir-dsl/core";

Why you need a wildcard: the variance trap

The naive approach fails:

// ❌ Breaks as soon as the caller .include()s anything
function applyFilters<S extends FhirSchema, RT extends string>(
qb: SearchQueryBuilder<S, RT>,
conditions: readonly ((q: SearchQueryBuilder<S, RT>) => SearchQueryBuilder<S, RT>)[],
): SearchQueryBuilder<S, RT> {
// ...
}

SearchQueryBuilder carries six generics (S, RT, SP, Inc, Prof, Sel). The moment the caller does .include("patient"), Inc widens, and your signature — which froze the generics at the call site — stops matching. TypeScript either rejects the call or collapses the return type to something useless.

The fix is a wildcard upper bound:

// ✅ Preserves the caller's full builder type
function applyFilters<QB extends AnySearchBuilder>(
qb: QB,
conditions: readonly ((q: QB) => QB)[],
): QB {
let out = qb;
for (const c of conditions) out = c(out);
return out;
}

AnySearchBuilder is structurally compatible with every concrete SearchQueryBuilder, so the constraint accepts anything the caller hands you. The QB generic then captures the caller's exact type and flows through the return — so .include() and .transform() stay available after the call.

Worked example — a reusable applyFilters

A policy-style helper that threads a list of conditions through a builder. Zero any, full inference preserved:

import type { AnySearchBuilder, FhirSchema } from "@fhir-dsl/core";

type FhirCondition<S extends FhirSchema = FhirSchema> = (
qb: AnySearchBuilder<S>,
) => AnySearchBuilder<S>;

function applyFilters<QB extends AnySearchBuilder>(
qb: QB,
conditions: readonly FhirCondition[],
): QB {
let out = qb;
for (const c of conditions) out = c(out) as QB;
return out;
}

// Somewhere else — library of reusable filters
const isFinished: FhirCondition = (qb) => qb.where("status", "eq", "finished");
const recent: FhirCondition = (qb) => qb.where("date", "gt", "2024-01-01");

// Call site — post-call chaining still works
const rows = await fhir
.search("Encounter")
.$call((q) => applyFilters(q, [isFinished, recent]))
.include("patient")
.transform((t) => ({
id: t("id", ""),
patientId: t.ref("subject.reference"),
}))
.execute();

The .include("patient") call after applyFilters still activates path auto-dereferencing inside .transform() — because QB carried the full concrete builder through the helper.

Resource-scoped helpers with SearchBuilderOf<S, RT>

When a helper only makes sense for one resource:

function onlyActivePatients<QB extends SearchBuilderOf<Schema, "Patient">>(qb: QB): QB {
return qb.where("active", "eq", true) as QB;
}

The caller can't pass an Observation builder — TypeScript rejects it at the call site.

Filter-only helpers with FilterableBuilder<S, RT>

If your helper genuinely doesn't need .include() / .transform() / .execute() / .stream(), use FilterableBuilder<S, RT> instead of SearchBuilderOf. It exposes only the filter surface, which sidesteps the variance trap entirely (no method on the filter surface references Inc or Scope):

function addTenantScope<RT extends string>(
qb: FilterableBuilder<Schema, RT>,
): FilterableBuilder<Schema, RT> {
return qb.where("_tag", "eq", "tenant|acme");
}

Introspection — ResourceOf and IncludesOf

When you need to branch on what kind of builder you got, extract the generics:

type MyBuilder = ReturnType<typeof buildQuery>;

type RT = ResourceOf<MyBuilder>; // e.g. "Encounter"
type Inc = IncludesOf<MyBuilder>; // e.g. { patient: "Patient" }

Useful for writing typed serializers, cache keys, or row-shape derivations that mirror the state of the builder.

Picking the right helper

Is your helper filter-only (no terminal calls)?
├── Yes → FilterableBuilder<S, RT>
└── No → Does it care which resource?
├── Yes → SearchBuilderOf<S, RT>
└── No → AnySearchBuilder<S>

When in doubt, start with AnySearchBuilder<S> — it's the most permissive and the one that best preserves caller types through a helper.

Migration from v0.22.0

v0.22.0 shipped the helper types initially but Scope collapsed to never against real generated schemas (the includeExpressions interface didn't satisfy the internal Record<string, unknown> gate), which silently broke any helper signature that referenced AnySearchBuilder. v0.22.1 fixes the underlying constraint and the wildcard now works as documented — no caller code needs to change.

If you wrote a workaround (SearchQueryBuilder<S, string, any, any, any, any> cast, or an ad-hoc wildcard interface), you can delete it in favor of AnySearchBuilder<S>.