Architecture
fhir-dsl is a monorepo of six 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
┌─────────┐ ┌───────────┐ ┌─────────┐
│ cli │ ──> │ generator │ ──> │ utils │
└─────────┘ └───────────┘ └─────────┘
┌─────────────┐ ┌──────────┐ ┌─────────┐
│ runtime │ ──> │ core │ ──> │ types │
└─────────────┘ └──────────┘ └─────────┘
There are two independent stacks:
- Generation stack (
cli->generator->utils) -- Runs at build time to produce TypeScript types - Query stack (
runtime->core->types) -- Runs at application time to build and execute queries
These 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. Provides hand-written TypeScript interfaces for:
- FHIR primitive types (
FhirString,FhirDate,FhirBoolean, etc.) - Complex data types (
HumanName,Address,CodeableConcept, etc.) - Base resource types (
Resource,DomainResource,BackboneElement) - Search parameter types (
StringParam,TokenParam,DateParam, etc.) - Bundle types for transactions
These types are stable and rarely change between FHIR versions.
@fhir-dsl/core
The query builder DSL. Provides:
createFhirClient<S>()-- Factory for creating typed clientsSearchQueryBuilder-- Fluent builder for FHIR search queriesReadQueryBuilder-- Builder for single resource readsTransactionBuilder-- Builder for FHIR transaction BundlesCompiledQuery-- Raw query representation
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 sendsCompiledQueryto a FHIR serverFhirError-- Error class with OperationOutcome parsingpaginate()-- Async generator for streaming paginated resultsfetchAllPages()-- Fetches all pages into a single arrayunwrapBundle()-- Extracts typed resources from search Bundles
Runtime is optional -- you can use compile() and handle execution yourself.
@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
@fhir-dsl/cli
A thin wrapper around the generator, using Commander for argument parsing. Exposes the fhir-gen binary.
@fhir-dsl/utils
Shared utilities used by the generation stack:
- Name conversion (
toPascalCase,toKebabCase, etc.) - FHIR-to-TypeScript type mapping
- File naming helpers
- Logger
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
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
- Request/response interceptors
- Testing with mock servers
- Integration with existing HTTP infrastructure