---
{
  "title": "@atomic-ehr/codegen: Fixing FHIR Packages Without Waiting for Upstream",
  "description": "FHIR packages ship with broken indexes, typo'd manifests and profiles pinning codes that do not exist — HL7's own included. Why they have to be repaired in your build, and how to declare the fixes.",
  "date": "2026-09-29",
  "author": "Aleksandr Penskoi",
  "reading-time": "9 minutes",
  "tags": [
    "FHIR Tools",
    "FHIR Standard",
    "Code Generation",
    "TypeScript",
    "Aidbox"
  ]
}
---

> For the complete documentation index, see [llms.txt](https://www.health-samurai.io/llms.txt).
> Use it to discover all available pages before guessing URLs.

---
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`](https://github.com/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:

```json
{ "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:

| Package                           | Published | Extensions typed with R5-only datatypes |
| --------------------------------- | --------- | --------------------------------------- |
| `hl7.fhir.uv.extensions.r4@1.0.0` | Mar 2023  | 10                                      |
| `hl7.fhir.uv.extensions.r4@5.1.0` | Apr 2024  | 9                                       |
| `hl7.fhir.uv.extensions.r4@5.2.0` | Feb 2025  | 8                                       |
| `hl7.fhir.uv.extensions.r4@5.3.0` | May 2026  | **0**                                   |

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](https://github.com/atomic-ehr/codegen/blob/main/examples/on-the-fly/ccda/generate.ts).

### 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:

```json
// 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:

```json
// 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:

```json
// 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:

```json
// 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.

```mermaid
flowchart LR
    P(FHIR package<br/>as published):::neutral2

    PJ(packageJson<br/>ensureDependency · renamePackage):::red2
    IE(indexEntry<br/>excludeCanonical):::red2
    FR(fhirResource<br/>replaceText · ensureCodes):::red2

    M(patched copy<br/>in memory only):::neutral1

    P -->|package.json| PJ -.-> M
    P -->|.index.json| IE -.-> M
    P -->|resources| FR -.-> M
```

Five of them, against defects from earlier:

```typescript
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](/blog/atomic-ehr-codegen-typescript-us-core-profiles) and [Type Schema: a pragmatic approach to build FHIR SDK](/blog/type-schema-a-pragmatic-approach-to-build-fhir-sdk).

[GitHub](https://github.com/atomic-ehr/codegen) | [NPM](https://www.npmjs.com/package/@atomic-ehr/codegen) | [Aleksandr Penskoi on LinkedIn](https://www.linkedin.com/in/aleksandr-penskoi/)
