From 6eaee8a340ca074e8d60a7a4fa7196a597b423e1 Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sat, 23 May 2026 18:18:04 +0200 Subject: [PATCH] fix(ts-profile): preserve fixed CodeableConcept codings Add a generic fixed-value merge helper for generated TypeScript profile apply() methods so fixed CodeableConcept codings are applied without dropping compatible caller-provided codings. Skip placeholder-only UNK ValueSet expansions when generating enum constraints. Bead: codegen-vwd --- .../typescript/profile-helpers.ts | 36 ++++++ .../profiles/Extension_birthPlace.ts | 5 +- .../profiles/Extension_birthTime.ts | 5 +- .../profiles/Extension_nationality.ts | 5 +- .../profiles/Extension_own_prefix.ts | 5 +- .../Observation_observation_bodyweight.ts | 5 +- .../profiles/Observation_observation_bp.ts | 5 +- .../fhir-types/profile-helpers.ts | 36 ++++++ .../typescript-r4/profile-bodyweight.test.ts | 2 +- .../Extension_USCoreEthnicityExtension.ts | 5 +- .../Extension_USCoreIndividualSexExtension.ts | 5 +- ...ension_USCoreInterpreterNeededExtension.ts | 5 +- .../profiles/Extension_USCoreRaceExtension.ts | 5 +- ...ension_USCoreTribalAffiliationExtension.ts | 5 +- .../Observation_USCoreBloodPressureProfile.ts | 5 +- .../Observation_USCoreBodyWeightProfile.ts | 5 +- .../fhir-types/profile-helpers.ts | 36 ++++++ .../writer-generator/typescript/profile.ts | 9 +- src/typeschema/core/binding.ts | 10 ++ src/utils/log.ts | 1 + .../__snapshots__/typescript.test.ts.snap | 25 ++-- test/api/write-generator/typescript.test.ts | 122 +++++++++++++++++- 22 files changed, 280 insertions(+), 62 deletions(-) diff --git a/assets/api/writer-generator/typescript/profile-helpers.ts b/assets/api/writer-generator/typescript/profile-helpers.ts index 91cfb01f2..72164416a 100644 --- a/assets/api/writer-generator/typescript/profile-helpers.ts +++ b/assets/api/writer-generator/typescript/profile-helpers.ts @@ -92,6 +92,42 @@ export const applySliceMatch = (input: Partial, match: Part return result as T; }; +const mergeFixedArray = (current: unknown[], fixed: unknown[]): unknown[] => { + const result = [...current]; + for (const fixedItem of fixed) { + const existingIndex = result.findIndex((item) => matchesValue(item, fixedItem)); + if (existingIndex === -1) { + result.push(fixedItem); + } else { + result[existingIndex] = mergeFixedValue(result[existingIndex], fixedItem); + } + } + return result; +}; + +const mergeFixedValue = (current: unknown, fixed: unknown): unknown => { + if (Array.isArray(current) && Array.isArray(fixed)) { + return mergeFixedArray(current, fixed); + } + if (isRecord(current) && isRecord(fixed)) { + const result: Record = { ...current }; + for (const [key, fixedValue] of Object.entries(fixed)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + result[key] = mergeFixedValue(result[key], fixedValue); + } + return result; + } + return fixed; +}; + +/** Apply a generated fixed/profile value while preserving compatible existing data. */ +export const applyFixedValue = (resource: T, field: string, fixed: unknown): void => { + const rec = resource as Record; + rec[field] = mergeFixedValue(rec[field], fixed); +}; + /** * Recursively test whether `value` structurally contains everything in * `match`. Arrays are matched with "every match item has a corresponding diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts index 11c3994e8..665eae66c 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts @@ -6,6 +6,7 @@ import type { Address } from "../../hl7-fhir-r4-core/Address"; import type { Extension } from "../../hl7-fhir-r4-core/Extension"; import { + applyFixedValue, validateRequired, validateExcluded, validateFixedValue, @@ -45,9 +46,7 @@ export class birthPlaceProfile { static apply (resource: Extension) : birthPlaceProfile { resource.url = birthPlaceProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthPlace"); return new birthPlaceProfile(resource); } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts index 53e7f2b0e..66177168b 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts @@ -5,6 +5,7 @@ import type { Extension } from "../../hl7-fhir-r4-core/Extension"; import { + applyFixedValue, validateRequired, validateExcluded, validateFixedValue, @@ -44,9 +45,7 @@ export class birthTimeProfile { static apply (resource: Extension) : birthTimeProfile { resource.url = birthTimeProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/StructureDefinition/patient-birthTime", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthTime"); return new birthTimeProfile(resource); } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts index bec4deccf..04a3210e5 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts @@ -8,6 +8,7 @@ import type { Period } from "../../hl7-fhir-r4-core/Period"; import { isRawExtensionInput, + applyFixedValue, isExtension, getExtensionValue, pushExtension, @@ -56,9 +57,7 @@ export class nationalityProfile { static apply (resource: Extension) : nationalityProfile { resource.url = nationalityProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/StructureDefinition/patient-nationality", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/StructureDefinition/patient-nationality"); return new nationalityProfile(resource); } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts index 8e81b4870..41d427695 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts @@ -5,6 +5,7 @@ import type { Extension } from "../../hl7-fhir-r4-core/Extension"; import { + applyFixedValue, validateRequired, validateExcluded, validateFixedValue, @@ -44,9 +45,7 @@ export class own_prefixProfile { static apply (resource: Extension) : own_prefixProfile { resource.url = own_prefixProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix"); return new own_prefixProfile(resource); } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts index 362bf4605..5d5d6b516 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts @@ -15,6 +15,7 @@ export type Observation_bodyweight_Category_VSCatSliceFlatAll = Observation_body import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -70,9 +71,7 @@ export class observation_bodyweightProfile { static apply (resource: Observation) : observation_bodyweightProfile { ensureProfile(resource, observation_bodyweightProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], observation_bodyweightProfile.VSCatSliceMatch, diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts index 6e0655f47..a108c811e 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts @@ -21,6 +21,7 @@ export type Observation_bp_Component_DiastolicBPSliceFlatAll = Observation_bp_Co import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -85,9 +86,7 @@ export class observation_bpProfile { static apply (resource: Observation) : observation_bpProfile { ensureProfile(resource, observation_bpProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], observation_bpProfile.VSCatSliceMatch, diff --git a/examples/typescript-r4/fhir-types/profile-helpers.ts b/examples/typescript-r4/fhir-types/profile-helpers.ts index 91cfb01f2..72164416a 100644 --- a/examples/typescript-r4/fhir-types/profile-helpers.ts +++ b/examples/typescript-r4/fhir-types/profile-helpers.ts @@ -92,6 +92,42 @@ export const applySliceMatch = (input: Partial, match: Part return result as T; }; +const mergeFixedArray = (current: unknown[], fixed: unknown[]): unknown[] => { + const result = [...current]; + for (const fixedItem of fixed) { + const existingIndex = result.findIndex((item) => matchesValue(item, fixedItem)); + if (existingIndex === -1) { + result.push(fixedItem); + } else { + result[existingIndex] = mergeFixedValue(result[existingIndex], fixedItem); + } + } + return result; +}; + +const mergeFixedValue = (current: unknown, fixed: unknown): unknown => { + if (Array.isArray(current) && Array.isArray(fixed)) { + return mergeFixedArray(current, fixed); + } + if (isRecord(current) && isRecord(fixed)) { + const result: Record = { ...current }; + for (const [key, fixedValue] of Object.entries(fixed)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + result[key] = mergeFixedValue(result[key], fixedValue); + } + return result; + } + return fixed; +}; + +/** Apply a generated fixed/profile value while preserving compatible existing data. */ +export const applyFixedValue = (resource: T, field: string, fixed: unknown): void => { + const rec = resource as Record; + rec[field] = mergeFixedValue(rec[field], fixed); +}; + /** * Recursively test whether `value` structurally contains everything in * `match`. Arrays are matched with "every match item has a corresponding diff --git a/examples/typescript-r4/profile-bodyweight.test.ts b/examples/typescript-r4/profile-bodyweight.test.ts index 18c65da65..8c2885c9b 100644 --- a/examples/typescript-r4/profile-bodyweight.test.ts +++ b/examples/typescript-r4/profile-bodyweight.test.ts @@ -182,7 +182,7 @@ describe("factory method equivalence", () => { const fromCreate = bodyweightProfile.create(args).toResource(); const fromCreateResource = bodyweightProfile.createResource(args); const fromApply = bodyweightProfile - .apply({ resourceType: "Observation", status: "preliminary", code: { text: "Body weight" } }) + .apply({ resourceType: "Observation", status: "preliminary", code: {} }) .setStatus("final") .setSubject({ reference: "Patient/pt-1" }) .toResource(); diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts index c64e80b7c..1ebe13ec7 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts @@ -18,6 +18,7 @@ export type USCoreEthnicityExtension_Extension_TextSliceFlatAll = USCoreEthnicit import { isRawExtensionInput, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -80,9 +81,7 @@ export class USCoreEthnicityExtensionProfile { static apply (resource: Extension) : USCoreEthnicityExtensionProfile { resource.url = USCoreEthnicityExtensionProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity"); return new USCoreEthnicityExtensionProfile(resource); } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreIndividualSexExtension.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreIndividualSexExtension.ts index 189b1ebca..bc040e765 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreIndividualSexExtension.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreIndividualSexExtension.ts @@ -6,6 +6,7 @@ import type { Coding } from "../../hl7-fhir-r4-core/Coding"; import type { Extension } from "../../hl7-fhir-r4-core/Extension"; import { + applyFixedValue, validateRequired, validateExcluded, validateFixedValue, @@ -45,9 +46,7 @@ export class USCoreIndividualSexExtensionProfile { static apply (resource: Extension) : USCoreIndividualSexExtensionProfile { resource.url = USCoreIndividualSexExtensionProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex"); return new USCoreIndividualSexExtensionProfile(resource); } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreInterpreterNeededExtension.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreInterpreterNeededExtension.ts index f8ef05853..c6f1c9b7d 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreInterpreterNeededExtension.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreInterpreterNeededExtension.ts @@ -6,6 +6,7 @@ import type { Coding } from "../../hl7-fhir-r4-core/Coding"; import type { Extension } from "../../hl7-fhir-r4-core/Extension"; import { + applyFixedValue, validateRequired, validateExcluded, validateFixedValue, @@ -45,9 +46,7 @@ export class USCoreInterpreterNeededExtensionProfile { static apply (resource: Extension) : USCoreInterpreterNeededExtensionProfile { resource.url = USCoreInterpreterNeededExtensionProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed"); return new USCoreInterpreterNeededExtensionProfile(resource); } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts index 8a73b39b6..75eeb9570 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts @@ -18,6 +18,7 @@ export type USCoreRaceExtension_Extension_TextSliceFlatAll = USCoreRaceExtension import { isRawExtensionInput, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -80,9 +81,7 @@ export class USCoreRaceExtensionProfile { static apply (resource: Extension) : USCoreRaceExtensionProfile { resource.url = USCoreRaceExtensionProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race"); return new USCoreRaceExtensionProfile(resource); } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts index ae0064268..c333f1df9 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts @@ -15,6 +15,7 @@ export type USCoreTribalAffiliationExtension_Extension_IsEnrolledSliceFlatAll = import { isRawExtensionInput, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -73,9 +74,7 @@ export class USCoreTribalAffiliationExtensionProfile { static apply (resource: Extension) : USCoreTribalAffiliationExtensionProfile { resource.url = USCoreTribalAffiliationExtensionProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation"); return new USCoreTribalAffiliationExtensionProfile(resource); } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts index 2b7ca6de3..85129683f 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts @@ -24,6 +24,7 @@ export type USCoreBloodPressureProfile_Component_DiastolicSliceFlatAll = USCoreB import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -88,9 +89,7 @@ export class USCoreBloodPressureProfile { static apply (resource: Observation) : USCoreBloodPressureProfile { ensureProfile(resource, USCoreBloodPressureProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], USCoreBloodPressureProfile.VSCatSliceMatch, diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts index 118b7acd5..e2e0793cb 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts @@ -18,6 +18,7 @@ export type USCoreBodyWeightProfile_Category_VSCatSliceFlatAll = USCoreBodyWeigh import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -73,9 +74,7 @@ export class USCoreBodyWeightProfile { static apply (resource: Observation) : USCoreBodyWeightProfile { ensureProfile(resource, USCoreBodyWeightProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], USCoreBodyWeightProfile.VSCatSliceMatch, diff --git a/examples/typescript-us-core/fhir-types/profile-helpers.ts b/examples/typescript-us-core/fhir-types/profile-helpers.ts index 91cfb01f2..72164416a 100644 --- a/examples/typescript-us-core/fhir-types/profile-helpers.ts +++ b/examples/typescript-us-core/fhir-types/profile-helpers.ts @@ -92,6 +92,42 @@ export const applySliceMatch = (input: Partial, match: Part return result as T; }; +const mergeFixedArray = (current: unknown[], fixed: unknown[]): unknown[] => { + const result = [...current]; + for (const fixedItem of fixed) { + const existingIndex = result.findIndex((item) => matchesValue(item, fixedItem)); + if (existingIndex === -1) { + result.push(fixedItem); + } else { + result[existingIndex] = mergeFixedValue(result[existingIndex], fixedItem); + } + } + return result; +}; + +const mergeFixedValue = (current: unknown, fixed: unknown): unknown => { + if (Array.isArray(current) && Array.isArray(fixed)) { + return mergeFixedArray(current, fixed); + } + if (isRecord(current) && isRecord(fixed)) { + const result: Record = { ...current }; + for (const [key, fixedValue] of Object.entries(fixed)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + result[key] = mergeFixedValue(result[key], fixedValue); + } + return result; + } + return fixed; +}; + +/** Apply a generated fixed/profile value while preserving compatible existing data. */ +export const applyFixedValue = (resource: T, field: string, fixed: unknown): void => { + const rec = resource as Record; + rec[field] = mergeFixedValue(rec[field], fixed); +}; + /** * Recursively test whether `value` structurally contains everything in * `match`. Arrays are matched with "every match item has a corresponding diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 0c9471494..836933044 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -239,6 +239,7 @@ const generateProfileHelpersImport = ( if (snapshot.base.name === "Extension" && canonicalUrl && collectSubExtensionSlices(snapshot).length > 0) imports.push("isRawExtensionInput"); if (canonicalUrl && hasMeta) imports.push("ensureProfile"); + if (factoryInfo.autoFields.some((f) => f.name !== "resourceType")) imports.push("applyFixedValue"); if (sliceDefs.length > 0 || factoryInfo.sliceAutoFields.length > 0) imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults"); const hasUnboundedSlice = sliceDefs.some((s) => s.array && (s.max === 0 || s.max === undefined)); @@ -418,11 +419,9 @@ const generateFactoryMethods = ( } const applyAutoFields = factoryInfo.autoFields.filter((f) => f.name !== "resourceType"); if (applyAutoFields.length > 0) { - w.curlyBlock(["Object.assign(resource,"], () => { - for (const f of applyAutoFields) { - w.line(`${f.name}: ${f.value},`); - } - }, [")"]); + for (const f of applyAutoFields) { + w.lineSM(`applyFixedValue(resource, ${JSON.stringify(f.name)}, ${f.value})`); + } } for (const f of factoryInfo.sliceAutoFields) { const matchRefs = f.sliceNames.map((s) => `${profileClassName}.${tsSliceStaticName(s)}SliceMatch`); diff --git a/src/typeschema/core/binding.ts b/src/typeschema/core/binding.ts index 2e120779d..0a970a157 100644 --- a/src/typeschema/core/binding.ts +++ b/src/typeschema/core/binding.ts @@ -89,6 +89,7 @@ function extractValueSetConcepts( } const MAX_ENUM_LENGTH = 100; +const PLACEHOLDER_ONLY_ENUM_CODES = new Set(["UNK"]); // eld-11: Types that can have bindings export const BINDABLE_TYPES = new Set([ @@ -133,6 +134,15 @@ export function buildEnum( .map((c) => c.code) .filter((code) => code && typeof code === "string" && code.trim().length > 0); + const onlyCode = codes.length === 1 ? codes[0] : undefined; + if (onlyCode && PLACEHOLDER_ONLY_ENUM_CODES.has(onlyCode)) { + logger?.dryWarn( + "#placeholderValueSet", + `Value set ${valueSetUrl} only expands to placeholder code '${onlyCode}'; skipping enum generation.`, + ); + return undefined; + } + if (codes.length > MAX_ENUM_LENGTH) { logger?.dryWarn( "#largeValueSet", diff --git a/src/utils/log.ts b/src/utils/log.ts index 137240cf2..97086a04d 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -13,6 +13,7 @@ export { mkLogger } from "./common-log"; export type CodegenTag = | "#binding" | "#largeValueSet" + | "#placeholderValueSet" | "#fieldTypeNotFound" | "#skipCanonical" | "#duplicateSchema" diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index d6aec0514..f177e8fcf 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -518,6 +518,7 @@ export type Observation_bodyweight_Category_VSCatSliceFlatAll = Observation_body import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -573,9 +574,7 @@ export class observation_bodyweightProfile { static apply (resource: Observation) : observation_bodyweightProfile { ensureProfile(resource, observation_bodyweightProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], observation_bodyweightProfile.VSCatSliceMatch, @@ -748,6 +747,7 @@ export type Observation_bp_Component_DiastolicBPSliceFlatAll = Observation_bp_Co import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -812,9 +812,7 @@ export class observation_bpProfile { static apply (resource: Observation) : observation_bpProfile { ensureProfile(resource, observation_bpProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], observation_bpProfile.VSCatSliceMatch, @@ -1323,6 +1321,7 @@ export type USCoreBloodPressureProfile_Component_DiastolicSliceFlatAll = USCoreB import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -1387,9 +1386,7 @@ export class USCoreBloodPressureProfile { static apply (resource: Observation) : USCoreBloodPressureProfile { ensureProfile(resource, USCoreBloodPressureProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], USCoreBloodPressureProfile.VSCatSliceMatch, @@ -1721,6 +1718,7 @@ export type USCoreBodyWeightProfile_Category_VSCatSliceFlatAll = USCoreBodyWeigh import { ensureProfile, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -1776,9 +1774,7 @@ export class USCoreBodyWeightProfile { static apply (resource: Observation) : USCoreBodyWeightProfile { ensureProfile(resource, USCoreBodyWeightProfile.canonicalUrl); - Object.assign(resource, { - code: {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}, - }) + applyFixedValue(resource, "code", {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}); resource.category = ensureSliceDefaults( [...(resource.category ?? [])], USCoreBodyWeightProfile.VSCatSliceMatch, @@ -2050,6 +2046,7 @@ export type USCoreRaceExtension_Extension_TextSliceFlatAll = USCoreRaceExtension import { isRawExtensionInput, + applyFixedValue, applySliceMatch, matchesValue, setArraySlice, @@ -2112,9 +2109,7 @@ export class USCoreRaceExtensionProfile { static apply (resource: Extension) : USCoreRaceExtensionProfile { resource.url = USCoreRaceExtensionProfile.canonicalUrl; - Object.assign(resource, { - url: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", - }) + applyFixedValue(resource, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race"); return new USCoreRaceExtensionProfile(resource); } diff --git a/test/api/write-generator/typescript.test.ts b/test/api/write-generator/typescript.test.ts index cd51945e8..0e0231bec 100644 --- a/test/api/write-generator/typescript.test.ts +++ b/test/api/write-generator/typescript.test.ts @@ -1,7 +1,23 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; -import type { CanonicalUrl } from "@root/typeschema/types"; -import { ccdaManager, mkErrorLogger, r4Manager } from "@typeschema-test/utils"; +import type { ValueSet } from "@root/fhir-types/hl7-fhir-r4-core"; +import type { Register } from "@root/typeschema/register"; +import { type CanonicalUrl, enrichValueSet, type PackageMeta, packageMetaToFhir } from "@root/typeschema/types"; +import { + ccdaManager, + mkErrorLogger, + mkR4Register, + type PFS, + type PVS, + r4Manager, + registerFs, +} from "@typeschema-test/utils"; +import { applyFixedValue } from "../../../assets/api/writer-generator/typescript/profile-helpers"; + +const appendValueSet = (register: Register, pkg: PackageMeta, valueSet: PVS) => { + const richValueSet = enrichValueSet(valueSet as ValueSet, pkg); + register.resolver[packageMetaToFhir(pkg)]!.valueSets[richValueSet.url] = richValueSet; +}; describe("TypeScript Writer Generator", async () => { const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) @@ -56,6 +72,108 @@ describe("TypeScript Writer Generator", async () => { }); }); +describe("TypeScript profile fixed CodeableConcept semantics", async () => { + const fixedSystem = "http://example.org/CodeSystem/coverage-type"; + const secondarySystem = "http://example.org/CodeSystem/hzv-contract"; + + it("applies fixed CodeableConcept codings without dropping compatible secondary codings", () => { + const resource = { + type: { + text: "Private coverage", + coding: [ + { system: fixedSystem, code: "PKV", display: "Private insurance" }, + { system: secondarySystem, code: "AOK_BY_HZV" }, + ], + }, + }; + + applyFixedValue(resource, "type", { + coding: [{ system: fixedSystem }], + }); + + expect(resource.type).toEqual({ + text: "Private coverage", + coding: [ + { system: fixedSystem, code: "PKV", display: "Private insurance" }, + { system: secondarySystem, code: "AOK_BY_HZV" }, + ], + }); + }); + + it("generates apply() with fixed-value merge and skips placeholder-only enum validation", async () => { + const register = await mkR4Register(); + const pkg = { name: "codegen.test", version: "1.0.0" }; + const placeholderValueSetUrl = "http://example.org/ValueSet/placeholder-coverage-type"; + const profileUrl = "http://example.org/StructureDefinition/fixed-coverage"; + const profile: PFS = { + description: "Coverage profile with fixed CodeableConcept coding and placeholder-only binding", + derivation: "constraint", + type: "Coverage", + name: "FixedCoverage", + kind: "resource", + url: profileUrl, + base: "http://hl7.org/fhir/StructureDefinition/Coverage", + package_meta: pkg, + required: ["type"], + elements: { + type: { + type: "CodeableConcept", + binding: { + strength: "required", + valueSet: placeholderValueSetUrl, + bindingName: "PlaceholderCoverageType", + }, + elements: { + coding: { + slicing: { + slices: { + InsuranceType: { + min: 1, + match: { system: fixedSystem }, + }, + }, + }, + }, + }, + }, + }, + }; + + registerFs(register, profile); + appendValueSet(register, pkg, { + resourceType: "ValueSet", + id: "placeholder-coverage-type", + name: "PlaceholderCoverageType", + url: placeholderValueSetUrl, + status: "active", + expansion: { + timestamp: "2026-01-01T00:00:00Z", + contains: [{ system: "http://terminology.hl7.org/CodeSystem/v3-NullFlavor", code: "UNK" }], + }, + }); + + const result = await new APIBuilder({ register, logger: mkErrorLogger() }) + .typescript({ + inMemoryOnly: true, + withDebugComment: false, + generateProfile: true, + openResourceTypeSet: false, + }) + .generate(); + + expect(result.success).toBeTrue(); + const files = result.filesGenerated.typescript!; + const profileFile = Object.entries(files).find(([path]) => path.endsWith("profiles/Coverage_FixedCoverage.ts")); + expect(profileFile).toBeDefined(); + const profileTs = profileFile![1]; + + expect(profileTs).toContain(`applyFixedValue(resource, "type", {"coding":[{"system":"${fixedSystem}"}]})`); + expect(profileTs).not.toContain("Object.assign(resource"); + expect(profileTs).not.toContain(`validateEnum(res, profileName, "type", ["UNK"])`); + expect(profileTs).not.toContain(`CodeableConcept<("UNK")>`); + }); +}); + describe("TypeScript CDA with Logical Model Promotion to Resource", async () => { const result = await new APIBuilder({ register: ccdaManager, logger: mkErrorLogger() }) .typeSchema({