StructureDefinition — машино-читаемое описание shape’а ресурса. Если есть formal spec — из него можно сгенерировать типы (compile-time check) и парсеры (runtime check). Builder в коде физически не сможет собрать ресурс, который не пройдёт валидацию: TypeScript compiler становится первой ступенью валидатора, Zod parser — второй.

Это закрывает классическую дыру в AI-pipeline: LLM возвращает structured output → builder собирает FHIR → пушит в FHIR-store. Без codegen каждая ступень полагается на soft conventions; с codegen — на жёсткие constraints, выводимые из одного источника truth.

Конвенция «один ресурс — один профиль, extension — свой профиль»

Один StructureDefinition описывает один артефакт:

  • Один profile BloodGPTComposition — один SD.
  • Один extension requires-doctor-preparation — отдельный SD (с type: Extension).
  • Один базовый ресурс Patient (поставляется HL7) — отдельный SD.

Это значит при codegen каждому SD соответствует один сгенерированный тип. У нас не одна гигантская BloodGPTSchema, а коллекция:

fhir-profiles/
├── BloodGPTComposition.fsh             → BloodGPTComposition interface
├── BloodGPTObservation.fsh             → BloodGPTObservation interface
├── BloodGPTClinicalImpression.fsh      → BloodGPTClinicalImpression interface
├── extensions/
│   ├── RequiresDoctorPreparation.fsh   → RequiresDoctorPreparationExtension interface
│   ├── BloodGPTParameterAnalysis.fsh   → BloodGPTParameterAnalysisExtension interface
│   └── ...
└── value-sets/
    └── BloodGPTBiomarkerVS.fsh         → BloodGPTBiomarkerCode union type

Профиль ресурса ссылается на extensions через extension contains <SliceName> — в типах это разворачивается как typed extension[] (slot для каждого).

Pipeline

*.fsh (источник)
  ↓ SUSHI
*.json (StructureDefinition / ValueSet / CodeSystem)
  ↓ codegen tool (fhir-ts-codegen / Firely / fhir-codegen)
*.ts (interfaces) + *.zod.ts (schemas)
  ↓ TypeScript compiler / Zod parser
runtime — builder типизирован, parser валидирует garbage

Codegen — это transform от SD к target language. SUSHI и codegen — два разных тула, разные стадии. SUSHI отвечает за «author DSL → JSON spec», codegen — за «JSON spec → программные артефакты».

Что разворачивается в типы

Constraints из SD маппятся на TS-конструкции:

Constraint в SDЧто в TS-типе
min: 1 (required)поле без ?
min: 0 (optional)поле с ?
max: "1"scalar field
max: "*"T[]
min: 1, max: "*"[T, ...T[]] (non-empty tuple)
fixedString: "X" / fixedCode: "X"literal type 'X'
pattern[...] (partial fixed)partial literal
binding required + ValueSetunion of code literals
type.targetProfileReference<ProfileType>
slicing (closed rules)tuple с typed slots
slicing (open rules)typed first slots + rest as base type
extension контейнер (extension contains)discriminated union по url

Пример end-to-end (реально запущенный, не invented)

Прогоняем @reasonhealth/fhir-ts-codegen v1.0.6 на canonical hl7.fhir.r4.core@4.0.1 пакете. Команды и реальный output ниже — не synthetic example.

Команды

# 1. Install
npm install @reasonhealth/fhir-ts-codegen@1.0.6 zod
 
# 2. Generate TypeScript interfaces (требует bun ≥1.0 как runtime для CLI)
bun run node_modules/@reasonhealth/fhir-ts-codegen/src/cli.ts \
  --package hl7.fhir.r4.core \
  --package-version 4.0.1 \
  --fhir-version r4 \
  --emit typescript \
  --namespace fhir4 \
  --out ./r4-core.d.ts
 
# 3. Generate Zod schemas
bun run node_modules/@reasonhealth/fhir-ts-codegen/src/cli.ts \
  --package hl7.fhir.r4.core \
  --package-version 4.0.1 \
  --fhir-version r4 \
  --emit zod \
  --out ./r4-zod.ts

Output: 682 types parsed, TS — ~35K строк, Zod — ~13K строк.

Что получили — Observation TS interface (excerpt)

export interface Observation extends DomainResource {
  readonly resourceType: 'Observation';
  identifier?: Identifier[] | undefined;
  basedOn?: Reference[] | undefined;
  status: ('registered'|'preliminary'|'final'|'amended'|
           'corrected'|'cancelled'|'entered-in-error'|'unknown');
  category?: CodeableConcept[] | undefined;
  code: CodeableConcept;
  subject?: Reference | undefined;
  // choice type value[x] — отдельные optional fields, не discriminated union:
  effectiveDateTime?: string | undefined;
  effectivePeriod?: Period | undefined;
  effectiveTiming?: Timing | undefined;
  effectiveInstant?: string | undefined;
  // ...остальные value[x] аналогично
}

Наблюдения:

  • status — реально union of string literals (восемь allowed values), не open string. TS поймает любой typo.
  • code: CodeableConcept (required, без ?) — min: 1.
  • subject?: Reference (optional) — min: 0.
  • effective[x] choice type развёрнут в отдельные optional fields, не discriminated union. TS не предотвратит ситуацию когда оба effectiveDateTime и effectivePeriod заданы одновременно — это runtime check Zod’а или manual gate в builder’е.

Что получили — Observation Zod schema (excerpt)

export const ObservationSchema = DomainResourceSchema.extend({
  resourceType: z.literal('Observation'),
  identifier: z.array(IdentifierSchema).optional(),
  status: z.enum(['registered', 'preliminary', 'final', 'amended',
                  'corrected', 'cancelled', 'entered-in-error', 'unknown']),
  code: CodeableConceptSchema,
  subject: ReferenceSchema.optional(),
  effectiveDateTime: z.string().optional(),
  effectivePeriod: PeriodSchema.optional(),
  // ...
  valueQuantity: QuantitySchema.optional(),
  valueCodeableConcept: CodeableConceptSchema.optional(),
  // ...
})
export type Observation = z.infer<typeof ObservationSchema>

Validation реальной US Core example (Serum Total Bilirubin)

import { ObservationSchema } from './r4-zod'
import resource from './serum-bilirubin.json'  // hl7.org/fhir/us/core/Observation-serum-total-bilirubin.json
 
const result = ObservationSchema.safeParse(resource)
// → ✅ VALID
// resourceType: Observation
// status:       final
// code.coding[0].system: http://loinc.org
// code.coding[0].code:   1975-2
// valueQuantity: {"value":8.6,"unit":"mg/dL","system":"http://unitsofmeasure.org","code":"mg/dL"}
// subject.reference: Patient/example

Что ловит — garbage от LLM-hallucination

const invalid = {
  resourceType: 'Observation',
  status: 'COMPLETED',                                // ✗ wrong enum value
  code: { text: 'Bilirubin' },                        // missing coding[]
  valueQuantity: { value: '8.6', unit: 'mg/dL' },     // ✗ value should be number
}
 
ObservationSchema.safeParse(invalid)
// → ❌ INVALID — две issues:
//   • [status] invalid_value: Invalid option: expected one of
//       "registered"|"preliminary"|"final"|"amended"|"corrected"|"cancelled"|"entered-in-error"|"unknown"
//   • [valueQuantity.value] invalid_type: Invalid input: expected number, received string

Builder, который пишет в Observation, физически не может попасть в FHIR-store с битым enum или wrong-typed primitive — TS-compiler ловит на этапе сборки, Zod ловит на этапе parse.

Инструменты

ToolЦелевой языкЧто генеритMaintainer
fhir-ts-codegen (@reasonhealth/fhir-ts-codegen)TypeScriptCLI: FHIR NPM package → interfaces или Zod schemas; требует Bun ≥1.0reason-healthcare org, активный
@reasonhealth/fhir-zodTypeScriptpre-generated Zod для R4/R4B/R5 (derived from codegen)reason-healthcare org
@types/fhirTypeScriptinterfaces only (R2-R5 base)DefinitelyTyped community
fhir-codegen (бывший microsoft/fhir-codegen)TS / C# / Python / Java / Rust / Swiftlanguage-native types. Originally от Microsoft (Gino Canessa); ныне в FHIR/fhir-codegen orgFHIR org (HL7)
Firely .NET SDKC#classes + HTTP client + validator (full stack)Firely (commercial healthcare tooling)
HAPI FHIR codegenJavaPOJO + builders + validatorscommunity + Smile CDR (originally UHN)

См. fhir-tooling для более широкого landscape (validators / clients / server SDKs).

Что codegen НЕ делает

Чистый codegen эмитит только типы / парсеры. Он не делает:

  • HTTP-клиент (client.read('Patient', '123'), .search(...)) — это отдельный слой (fhir-kit-client / Medplum / Firely).
  • Builders / factory functions — кода вида PatientBuilder.withMrn(...).build(). Это бизнес-логика, пишется руками.
  • Search query builders — не часть generated types.
  • Persistence — где хранить ресурсы (это server-side, см. google-healthcare-api).

Codegen — это schema-to-types, чистая трансформация. Всё остальное — отдельные слои.

Failure modes

  • Drift между SDs и кодом — если codegen не запускается в CI, generated файлы устаревают. Обычное решение — npm run fhir:codegen в pre-commit hook + CI check.
  • Slicing рендерится hard-to-read в TS-типах — особенно nested slicing (slice inside slice). Иногда читать generated файл невозможно, нужно опираться на autocomplete.
  • Custom extensions с complex value[x] — multi-type fields генерируются как unions, иногда требуют дополнительной narrowing logic у пользователя.
  • Codegen tools несовместимы между версиями FHIR — нужно явно указывать R4 vs R5, иначе типы могут расходиться (FHIR R5 имеет различия в Resource.meta, например).

Открытые вопросы

  • Какой инструмент выбрать для нашего случая — fhir-ts-codegen (light), Microsoft fhir-codegen (multi-target, и TS и C#), или Medplum SDK (heavy, full stack)? См. fhir-tooling для сравнения.
  • Нужно ли генерить Zod runtime-валидацию (двойная защита) или хватит только TS типов (compile-time)? Zod ценен когда вход непредсказуемый — LLM output, external API.
  • Куда складывать generated файлы — в packages/fhir-types/generated/ (рядом с source), или в node_modules/@bloodgpt/fhir-types/ через published package?
  • При работе с extensions — codegen-имена должны совпадать с runtime-URL’ами; нужна ли явная конвенция?

Связано

  • fhir-conformance-resources — что генерируем (SDs / ValueSets как input)
  • fhir-profiling — почему генерим (constraint поверх base FHIR)
  • fsh — author-time источник, из которого собирается JSON SD
  • fhir-implementation-guide — где живут SDs для нашей собственной публикации
  • fhir-tooling — landscape FHIR-tooling за пределами codegen
  • formalize-fhir-profiles — наше решение использовать codegen — draft
  • extension-url-conventions — URL для своих extensions, влияет на generated тип-имена — draft