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 + ValueSet | union of code literals |
type.targetProfile | Reference<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.tsOutput: 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 stringBuilder, который пишет в Observation, физически не может попасть в FHIR-store с битым enum или wrong-typed primitive — TS-compiler ловит на этапе сборки, Zod ловит на этапе parse.
Инструменты
| Tool | Целевой язык | Что генерит | Maintainer |
|---|---|---|---|
fhir-ts-codegen (@reasonhealth/fhir-ts-codegen) | TypeScript | CLI: FHIR NPM package → interfaces или Zod schemas; требует Bun ≥1.0 | reason-healthcare org, активный |
| @reasonhealth/fhir-zod | TypeScript | pre-generated Zod для R4/R4B/R5 (derived from codegen) | reason-healthcare org |
| @types/fhir | TypeScript | interfaces only (R2-R5 base) | DefinitelyTyped community |
fhir-codegen (бывший microsoft/fhir-codegen) | TS / C# / Python / Java / Rust / Swift | language-native types. Originally от Microsoft (Gino Canessa); ныне в FHIR/fhir-codegen org | FHIR org (HL7) |
| Firely .NET SDK | C# | classes + HTTP client + validator (full stack) | Firely (commercial healthcare tooling) |
| HAPI FHIR codegen | Java | POJO + builders + validators | community + 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