Skip to main content

Architecture

fhir-dsl is a monorepo of eleven decoupled packages, each with a clear responsibility. Understanding how they fit together helps you choose the right packages for your project and extend the system when needed.

Package Dependency Graph

Generation stack (build time)
┌─────────┐ ┌───────────┐ ┌──────────────┐ ┌─────────┐
│ cli │ ──> │ generator │ ──> │ terminology │ ──> │ utils │
└─────────┘ └───────────┘ └──────────────┘ └─────────┘
│ ▲
└──────────────────────────────────────┘

Query stack (runtime)
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐
│ smart │ ──> │ core │ <── │ runtime │ │ types │
└─────────┘ └──────────┘ └──────────┘ └─────────┘
│ ▲
└────────────────────────────────┤

┌──────────┴──────────┐
│ fhirpath │
└──────────────────────┘

LLM bridge (runtime, optional)
┌──────┐
│ mcp │ ──> core, runtime, fhirpath, smart (lazy), utils
└──────┘

React layer (runtime, optional)
┌─────────────────┐
│ tanstack-query │ ──> core, utils, react-query (peer)
└─────────────────┘

Every other package re-exports its error subclasses from utils, so the FhirDslError / Result<T, E> contract is shared across stacks (introduced in v1.2.0).

There are four logical groups:

  1. Generation stack (cligeneratorterminologyutils) — runs at build time to produce TypeScript types.
  2. Query stack (smartcoreruntime, with fhirpath as a sibling over types) — runs at application time to build and execute queries.
  3. LLM bridge (mcp) — depends on the query stack and lazy-loads smart only when the configured auth strategy needs JWT signing.
  4. React layer (tanstack-query) — turns any terminal builder into a typed TanStack Query options object. @tanstack/react-query is an optional peer dependency.

Generation and query stacks have no cross-dependencies. The generator doesn't import from core, and core doesn't import from the generator. They communicate through generated code — the types produced by the generation stack are consumed by the query stack.

Package Responsibilities

@fhir-dsl/types

The foundation layer. Hand-written TypeScript interfaces for:

  • FHIR primitive types (FhirString, FhirDate, FhirBoolean, …)
  • Complex data types (HumanName, Address, CodeableConcept, …)
  • Base resource types (Resource, DomainResource, BackboneElement)
  • Search parameter metadata types (StringParam, TokenParam, DateParam, …)
  • Bundle types for transactions

Stable and rarely changes between FHIR versions.

@fhir-dsl/core

The query builder DSL. Provides:

  • createFhirClient<S>() — factory for creating typed clients
  • SearchQueryBuilder — fluent builder for FHIR search queries
  • ReadQueryBuilder — builder for single-resource reads
  • TransactionBuilder — builder for FHIR transaction Bundles
  • CompiledQuery — raw query representation
  • AuthProvider / AuthConfig — pluggable auth abstraction (see Auth Layer)
  • performRequest — shared HTTP helper that attaches auth headers and handles 401 callbacks

Core is generic over a FhirSchema type parameter. It doesn't know about specific FHIR resources — that knowledge comes from the generated types.

@fhir-dsl/runtime

The execution layer. Provides:

  • FhirExecutor — HTTP client that sends CompiledQuery to a FHIR server (dispatches through performRequest)
  • FhirErrorkind: "runtime.fhir", extends FhirDslError with OperationOutcome parsing in context.operationOutcome
  • paginate() — async generator for streaming paginated results
  • fetchAllPages() — fetches all pages into a single array
  • unwrapBundle() — extracts typed resources from search Bundles

Runtime is optional — you can use compile() and handle execution yourself.

@fhir-dsl/smart

SMART on FHIR v2 authentication. Implements both flows of the spec:

  • Backend ServicesBackendServicesAuth signs an RS384/ES384 client_assertion JWT, exchanges it for an access token, and caches until expiry.
  • App LaunchbuildAuthorizeUrl / exchangeCode / refreshToken drive the OAuth2 + PKCE flow; SmartClient wraps the resulting token set, auto-refreshes, and exposes patient / encounter / fhirContext launch claims.

Also ships scope builders, .well-known discovery, PKCE helpers, typed errors (SmartAuthError, DiscoveryError), and a pluggable TokenStore. Both providers implement core's AuthProvider, so they drop into createFhirClient({ auth }) without extra plumbing. Depends on jose for JWT signing.

@fhir-dsl/fhirpath

Type-safe FHIRPath expression builder + evaluator. Covers the pragmatic subset of the spec that FHIR invariants and common navigation actually exercise (see the coverage table), plus FHIR-specific extensions:

  • Native UCUM-aware Quantity (v1.1.0) — same-dimension equality + ordering with no third-party dependency. Offset / log / multi-/ units throw UcumError instead of returning silent wrong answers.
  • setValue / createPatch (v0.53.0) — every typed leaf inverts an eq-shaped predicate path into a deep-cloned next resource or an RFC 6902 JSON Patch.
  • resolve() + terminology resolver hooks (v1.1.0) — EvalOptions.resolveReference is consulted after the Bundle-walk path misses; conformsTo / memberOf / subsumes / subsumedBy compile to spec strings and dispatch through EvalOptions.terminology.
  • Expression predicates with $this, polymorphic narrowing via ofType(), string/math/conversion helpers, and FHIRPath's full operator set (and, or, xor, implies, not, comparisons, equivalence pending).
  • compileInvariant / validateInvariants — runtime evaluator over ElementDefinition.constraint[*].expression that returns OperationOutcome issues; auto-wired into emitted Standard Schema validators when --validator is set.

Parallel to core — it consumes the generated resource types directly, not the FhirSchema. Use it for:

  • Compiling path strings to pass to a FHIR server that supports FHIRPath (_filter)
  • Evaluating expressions against in-memory resources
  • Building reusable navigation over nested resource shapes
  • Round-tripping write-back as JSON Patch documents to external apply

@fhir-dsl/tanstack-query

Optional TanStack Query bindings for React apps. Wraps every fhir-dsl terminal builder (fhir.read(...), fhir.search(...), fhir.update(...), …) into a typed UseQueryOptions / UseMutationOptions object — queryKey is derived from compile() (sorted, JSON-comparable), queryFn forwards AbortSignal into execute(), and the error channel is typed as FhirDslError so result.error?.kind narrows automatically.

useQuery(queryOptions(fhir.read("Patient", id))); // result.data: Patient | undefined
useMutation(mutationOptions((p: Patient) => fhir.update(p)));
useMutation(mutationOptionsBound(fhir.delete("Patient", id)));

@tanstack/react-query is an optional peer dependency — installing this package without React Query never pulls it in. See the API reference.

@fhir-dsl/generator

The code generation engine. Handles:

  • Downloading FHIR StructureDefinitions from official servers
  • Downloading Implementation Guide packages
  • Parsing StructureDefinitions into an internal model
  • Emitting TypeScript resource interfaces
  • Emitting typed search parameters
  • Emitting type registries and the FhirSchema
  • Emitting profile interfaces for IGs
  • Emitting markdown spec files (with --include-spec)

Every emitted table is derived from the per-version spec via a SpecCatalog — see SpecCatalog for the pipeline and data shape.

Delegates ValueSet resolution to @fhir-dsl/terminology.

@fhir-dsl/terminology

The terminology resolver used during code generation. Loads the FHIR spec's pre-expanded ValueSets and CodeSystems, resolves bindings by strength, and emits literal-union types (e.g. AdministrativeGender = "male" | "female" | ...) plus optional CodeSystem namespace objects.

Used internally by the generator when --expand-valuesets is set. Not a runtime dependency of applications.

@fhir-dsl/cli

A thin wrapper around the generator, using Commander for argument parsing. Exposes the fhir-gen binary with generate, capability, validate, scaffold-ig, diff, and mcp subcommands.

@fhir-dsl/mcp

Model Context Protocol bridge — exposes a FHIR endpoint as an MCP tool surface for an LLM agent. One server === one upstream FHIR endpoint, scoped to one IG. Ships ~10 generic FHIR verbs (read, vread, search, history, create, update, patch, delete, operation, capabilities), three pluggable auth strategies (bearer, backend-services, patient-launch), pluggable AuditSink implementations, write-gating + token-economy guards, and two transports: stdioTransport() for CLI MCP clients and httpTransport() (Streamable HTTP — POST, batched JSON-RPC, SSE notifications, optional CORS / auth hook / body cap / external-server mounting).

Generated alongside the typed client via fhir-gen generate --mcp <out>, or run inline with fhir-gen mcp <baseUrl>.

@fhir-dsl/utils

The shared toolbox every other package depends on:

  • ErrorsFhirDslError abstract base (kind discriminator, structured context, ES2022 cause, toJSON()), isFhirDslError, formatErrorChain, SerializedFhirDslError.
  • Result toolkitResult<T, E>, Ok<T> / Err<E>, tryAsync / trySync, mapErr / mapOk / match for Effect-style typed handling.
  • LoggerLogger, LogLevel, default logger singleton.
  • Naming helperstoPascalCase, toCamelCase, toKebabCase, fhirTypeToFileName, fhirPathToPropertyName, capitalizeFirst.
  • Type mappingsearchParamTypeToTs (FHIR search-param type → emitted TS literal).

The Type System

The type system is the core innovation. Here's how it flows:

StructureDefinition (JSON)
↓ parsed by generator
ResourceModel (internal)
↓ emitted as TypeScript
Patient interface, Observation interface, ...
↓ collected into
FhirResourceMap, SearchParamRegistry, IncludeRegistry, ProfileRegistry
↓ composed into
FhirSchema
↓ passed to
createFhirClient<FhirSchema>()
↓ powers
fhir.search("Patient").where("family", "eq", "Smith")
↑ ↑ ↑
lookup in lookup in validate
ResourceMap SearchParams against type

Each query method uses TypeScript's conditional types and mapped types to look up the correct types from the schema.

Immutability

All builders use a copy-on-write pattern. The internal QueryState is cloned on every method call:

// Simplified internal pattern
class SearchQueryBuilder<S, RT, SP, Inc, Prof> {
private constructor(private state: QueryState) {}

where(param, op, value) {
return new SearchQueryBuilder({
...this.state,
params: [...this.state.params, { param, op, value }],
});
}
}

This means:

  • Builders are safe to share across functions and modules
  • You can fork a builder to create variations without affecting the original
  • No hidden mutation bugs

Auth Layer

Authentication is abstracted behind the AuthProvider interface in core:

interface AuthProvider {
getAuthorization(req: { url: string; method: string }): Promise<string | undefined> | string | undefined;
onUnauthorized?(): Promise<void> | void;
}

type AuthConfig = AuthProvider | { type: "bearer" | "basic"; credentials: string };

createFhirClient({ auth }) accepts either form:

  • Static credentials{ type: "bearer", credentials: token } — resolved once into a fixed Authorization header.
  • AuthProvider instances — consulted per-request. Lets providers mint, refresh, or rotate tokens on demand without touching caller code.

Both the core fetch executor and the runtime FhirExecutor dispatch through the same performRequest helper. That helper:

  1. Resolves the provider (wrapping a static credential into a thin provider if needed).
  2. Calls getAuthorization() before each request, short-circuiting if it returns undefined.
  3. On a 401 response, invokes onUnauthorized() so the provider can invalidate its cache before the next call.

@fhir-dsl/smart ships two implementations of this interface (BackendServicesAuth, SmartClient); downstream projects can write their own for custom auth schemes.

Custom Executors

The Executor interface allows plugging in custom HTTP execution:

// Use compile() to get the raw query
const query = fhir.search("Patient").where("family", "eq", "Smith").compile();

// Execute with your own HTTP client
const response = await myCustomFetch(query.method, query.path, query.params);

This is useful for:

  • Custom authentication flows beyond AuthProvider
  • Request/response interceptors
  • Testing with mock servers
  • Integration with existing HTTP infrastructure