|
9 min read
|

@atomic-ehr/codegen: Fixing FHIR Packages Without Waiting for Upstream

Summarize this article with:
ChatGPTPerplexityClaudeGrok

FHIR packages are how the FHIR world ships content: npm-style tarballs of StructureDefinitions, ValueSets and CodeSystems. Almost every tool reads them — FHIR and terminology servers, validators, the IG Publisher, SDK generators, etc.

The format is simple, the registries are open, and the contents are often not what a tool needs. The registry holds more than a thousand packages, from national programmes, vendors, project teams and HL7 itself, on separate schedules, across several standard releases. Some were always going to have defects.

@atomic-ehr/codegen compiles those definitions into typed SDKs, and the target languages are unforgiving. TypeScript, C# and Python/Pydantic do not allow dangling references: every type must exist, every enum value must be a real code, and — by the way — every derivation must narrow the definition. For codegen to work, the whole package set has to be consistent statically.

What to expect from real-world FHIR packages

Every example is in a package you can install today, at the version named. The defects come in three kinds, and each one breaks a different stage of the build: loading the packages, resolving a reference, applying a constraint.

1. Broken package metadata

The package manifest and the index are wrong, so the loader cannot even assemble the package set.

A broken or missing index. The spec requires an .index.json file, which maps canonicals to filenames so tools need not open every file. Some packages ship without it (ehelse.fhir.no.grunndata@2.3.5, hl7.fhir.no.basis@2.2.2), and others point at files that are not there (de.basisprofil.r4@1.6.0-ballot2).

A missing core dependency. The spec is explicit — "A dependency on a core package is required" — yet many packages skip it (simplifier.core.r4* at 4.0.x, ehelse.fhir.no.grunndata@2.3.5, nhn.fhir.no.kjernejournal@1.0.0, sfm.030322@2.0.1) while using R4 core types throughout.

A manifest that disagrees with the registry. simplifier.core.r4.base@4.0.0 depends on simplifier.core.r4.resources. The registry serves that package but the package.json inside the tarball says:

{ "name": "simplifier.core.r4.rResources", "version": "4.0.0" }

2. A reference that does not resolve

The definition points at something the package set does not contain.

A canonical nobody defines. In hl7.cda.uv.core@2.0.2-sd, Material.sdtcExpirationTime is typed as .../StructureDefinition/IVL_TS — underscore. The datatype is published at .../IVL-TS, hyphen, and nothing in the package declares the underscore spelling.

A datatype from another FHIR version. hl7.fhir.uv.extensions.r4 is HL7's Extensions Pack for R4 — its manifest says "fhirVersions": ["4.0.1"]. For three years it shipped extensions whose value[x] used CodeableReference or Availability, datatypes added in R5 that do not exist in 4.0.1:

PackagePublishedExtensions typed with R5-only datatypes
hl7.fhir.uv.extensions.r4@1.0.0Mar 202310
hl7.fhir.uv.extensions.r4@5.1.0Apr 20249
hl7.fhir.uv.extensions.r4@5.2.0Feb 20258
hl7.fhir.uv.extensions.r4@5.3.0May 20260

The issue was resolved in 5.3.0, released in May 2026. Earlier versions remain in use through package dependencies. Four packages in the C-CDA closure declare extensions.r4, and every one of them asks for a broken line: hl7.fhir.us.core, hl7.fhir.uv.xver-r5.r4 and hl7.terminology.r4 want 5.2.0, while hl7.terminology still wants 1.0.0. Requesting the fixed version yourself changes nothing — each declaration has to be redirected with ensureDependency, as the C-CDA example does.

3. A profile that contradicts its base

The packaging is fine and every canonical resolves. The constraint itself is not legal — a profile may only restrict what its base already allows. Let's see some examples:

It widens instead of narrowing. Base R4 lets RelatedPerson.patient point at exactly one thing:

// hl7.fhir.r4.core@4.0.1 — RelatedPerson.patient
"type": [{
  "code": "Reference",
  "targetProfile": ["http://hl7.org/fhir/StructureDefinition/Patient"]
}]

The Norwegian gd-RelatedPerson derives from it and lists five:

// ehelse.fhir.no.grunndata@2.3.5 — gd-RelatedPerson, same element
"type": [{
  "code": "Reference",
  "targetProfile": [
    "http://hl7.org/fhir/StructureDefinition/Patient",
    "http://hl7.org/fhir/StructureDefinition/Person",
    // ...
  ]
}]

It pins a value that does not exist. The bundle-type CodeSystem defines exactly ten codes:

// hl7.fhir.r5.core@5.0.0 — CodeSystem/bundle-type
"concept": [
  { "code": "document" },    { "code": "message" },
  { "code": "transaction" }, { "code": "transaction-response" },
  { "code": "batch" },       { "code": "batch-response" },
  { "code": "history" },     { "code": "searchset" },
  { "code": "collection" },  { "code": "subscription-notification" }
]

R5 core's own batch-bundle profile pins Bundle.type to an eleventh:

// hl7.fhir.r5.core@5.0.0 — StructureDefinition/batch-bundle
{ "id": "Bundle.type", "path": "Bundle.type", "patternCode": "bundle" }

Both ways out are blocked. Correcting the patternCode runs into HL7's rules: "fixed values and patterns will not be added, removed or changed in a way that could invalidate previous instances." Adding bundle to the CodeSystem is no better — bundle-type is normative and marked content: "complete". Either way it needs a ballot, not a patch.

Why waiting for upstream does not work

File it anyway — the defects that got fixed were fixed because someone filed a ticket. But upstream cannot be your only plan.

Speed. FHIR aims to release every "approximately 18-24 months". The real gap between R4 and R5 was 51 months. A ticket accepted today lands in a specification that does not exist yet, while your build has to pass now.

Immutability. HL7 said it in a security notice, explaining why a fix could not reach already-published content: "published packages are immutable." So a fix always ships as a new version, and moving to it is often not your call — a national programme pins a ballot version, US regulation names IG versions in 45 CFR § 170.215. Sometimes there is nothing newer at all: hl7.fhir.r4.core has had one release, 4.0.1, in October 2019.

Impossibility. And some defects have no upstream fix at all — correcting batch-bundle means reopening normative content, which is exactly what normative status prevents.

Repairing packages in your own build

Waiting, then, means waiting on the publisher of the package you need and on the publishers of everything it depends on. The repair has to happen on your side instead. But where exactly?

Edit the unpacked tarball. It is a common and straightforward fix: change the file and rerun the build. But the edit is not recorded, the next install overwrites it, and other tools may read the modified package without knowing it was changed.

Fork, vendor or re-host the package. You are now pinned to the version you forked, and every upstream release has to be re-forked by hand or quietly skipped. Your copy also does not replace the original: anything else in the closure that still depends on the real package pulls it in alongside yours, and the same canonicals end up defined twice.

Special-case the defect in your code generator. Now a fact about someone else's package lives in your source, where nobody will think to look for it.

Codegen takes the remaining route: the fix is declared in your codegen configuration, and the package is read through it.

What you can patch

Patches apply in the loader, before the generator sees anything.

package.json .index.json resources FHIR packageas published packageJsonensureDependency · renamePackage indexEntryexcludeCanonical fhirResourcereplaceText · ensureCodes patched copyin memory only

Five of them, against defects from earlier:

patches: {
  packageJson: [
    // Most Norge packages use R4 core types without declaring the dependency.
    ensureDependency({ "hl7.fhir.r4.core": "4.0.1" }),
    // Published as .resources, but calls itself .rResources in its own manifest.
    renamePackage("simplifier.core.r4.rResources", "simplifier.core.r4.resources"),
  ],
  indexEntry: [
    // shareablecodesystem's recursive concept.concept breaks the schema generator.
    excludeCanonical({
      package: { name: "hl7.fhir.r5.core", version: "5.0.0" },
      url: "http://hl7.org/fhir/StructureDefinition/shareablecodesystem",
      reason: "CodeSystem.concept.concept recursion breaks generation",
    }),
  ],
  fhirResource: [
    // Material.sdtcExpirationTime points at .../IVL_TS; the datatype is published
    // at .../IVL-TS. Scoped to Material, because IVL-TS's own `type` field carries
    // the underscore spelling legitimately — an unscoped replace would break it.
    inResource("http://hl7.org/cda/stds/core/StructureDefinition/Material", [
      replaceText(
        "http://hl7.org/cda/stds/core/StructureDefinition/IVL_TS",
        "http://hl7.org/cda/stds/core/StructureDefinition/IVL-TS",
      ),
    ]),
    // bundle-type is missing codes the R5 bundle profiles pin — including
    // "bundle", batch-bundle's typo, which is not a legal code anywhere.
    ensureCodes("http://hl7.org/fhir/bundle-type", ["bundle", "subscription-notification"]),
  ],
}

Three things are worth knowing before you write one.

Where a patch goes matters. The manifest, the index and resource bodies are read at different moments, so a manifest typo has to be repaired in packageJson — by the time resources are read, the dependency graph is already built.

Narrow a patch to its target. Wrapping one in inPackage or inResource restricts it to a single package or a single canonical. This is optional, and usually worth doing. ensureCodes above runs unscoped, which is fine — it only touches the CodeSystem it names.

Exclusion removes more than output. An excluded canonical disappears from resolution too, so anything derived from it has to go with it.

Two of these calls codegen makes for you:

  • Known HL7 defects are patched by default. builtinPatches carries them and applies on every build, so nobody rediscovers them. Turn them off or replace them if you disagree.
  • A package that ships no index gets one. It is built from a directory scan, automatically. An index that exists but is wrong is left alone with a warning until you ask for packageIndex: "recover" — rebuilding something a publisher did ship is a larger assumption to make on your behalf.

Living with patches

A patch changes someone else's data, so every applied fix lands in the generation report — each recovered index and each exclusion, carrying the reason its patch declared. "Why does my generated SDK differ from the published package?" therefore always has an answer, and one anybody can check. That is the real case for declaring a repair instead of making one by hand: an edited tarball reports nothing and a forked package explains nothing, while a patch has to say what it did and why. Three rules keep that worth having.

Still file it upstream. A patch buys time; it is not a fix.

Repair the input, do not redesign it. The bundle-type patch above is the cheap fix — it adds a code HL7 never defined instead of correcting batch-bundle's patternCode.

Expect to delete patches. Those eight exclusions became useless the day 5.3.0 shipped, and codegen did not notice. A patch that silently does nothing looks exactly like one doing important work.


What you know about a broken package belongs in configuration, where it is versioned and reviewed like everything else — not in a copy of the package you now maintain, not in your generator's source, not in a wiki page three people know about. Declare it once, with its reason, and file the upstream issue while your build stays green.

Every defect named here was checked in September 2026 against the published tarballs at the versions given. The configuration comes from codegen's example pipelines, which regenerate and typecheck against live registries on every push.

For what codegen builds once the input is healthy, see US Core Profiles in TypeScript and Type Schema: a pragmatic approach to build FHIR SDK.

GitHub | NPM | Aleksandr Penskoi on LinkedIn

Share this article
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.