From 24f19fea6363a1028bc0ffc39f6cd8fa0fef05f6 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 16 Mar 2026 13:07:26 +0100 Subject: [PATCH 1/2] Add choice type narrowing and validation for profiles - Add narrowMergedChoiceDeclarations to restrict choice declarations to allowed variants - Add choiceRestrictions method on TypeSchemaIndex returning permitted/prohibited lists - Emit validateExcluded for disallowed choice instances in profile validation - Remove buildAllowedChoiceMap helper in favor of choiceRestrictions --- .../profile-bodyweight.test.ts | 24 +++++++++ .../typescript/profile-validation.ts | 7 ++- src/typeschema/types.ts | 1 + src/typeschema/utils.ts | 49 ++++++++++++++++++- .../__snapshots__/typescript.test.ts.snap | 10 ++++ 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/examples/typescript-us-core/profile-bodyweight.test.ts b/examples/typescript-us-core/profile-bodyweight.test.ts index c1c88e201..20312dc1b 100644 --- a/examples/typescript-us-core/profile-bodyweight.test.ts +++ b/examples/typescript-us-core/profile-bodyweight.test.ts @@ -73,6 +73,30 @@ describe("demo", () => { expect(profile.validate().errors).toEqual([]); }); + test("validate() catches disallowed value[x] variants on raw resource", () => { + const resource: Observation = { + resourceType: "Observation", + meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight"] }, + status: "final", + category: [ + { + coding: { + code: "vital-signs", + system: "http://terminology.hl7.org/CodeSystem/observation-category", + }, + }, + ] as any, + code: { coding: [{ code: "29463-7", system: "http://loinc.org" }] }, + subject: { reference: "Patient/pt-1" }, + effectiveDateTime: "2024-06-15", + valueString: "not allowed", + }; + + const profile = USCoreBodyWeightProfile.apply(resource); + const { errors } = profile.validate(); + expect(errors).toContain("USCoreBodyWeightProfile: field 'valueString' must not be present"); + }); + test("getVSCat() returns flat value, getVSCat('raw') includes discriminator", () => { const profile = USCoreBodyWeightProfile.create({ status: "final", diff --git a/src/api/writer-generator/typescript/profile-validation.ts b/src/api/writer-generator/typescript/profile-validation.ts index b03b91e10..e5270f765 100644 --- a/src/api/writer-generator/typescript/profile-validation.ts +++ b/src/api/writer-generator/typescript/profile-validation.ts @@ -74,7 +74,12 @@ export const generateValidateMethod = (w: TypeScript, tsIndex: TypeSchemaIndex, const errors: string[] = []; const warnings: string[] = []; for (const [name, field] of Object.entries(fields)) { - if (isChoiceInstanceField(field)) continue; + if (isChoiceInstanceField(field)) { + const decl = fields[field.choiceOf]; + if (decl && isChoiceDeclarationField(decl) && decl.prohibited?.includes(name)) + errors.push(`...validateExcluded(res, profileName, ${JSON.stringify(name)})`); + continue; + } if (isChoiceDeclarationField(field)) { if (field.required) diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 329ae6f93..748243268 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -288,6 +288,7 @@ export interface RegularField { export interface ChoiceFieldDeclaration { choices: string[]; + prohibited?: string[]; required?: boolean; excluded?: boolean; array?: boolean; diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index 0a8935a1a..ddba1c282 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -6,6 +6,7 @@ import type { IrReport } from "./ir/types"; import type { Register } from "./register"; import { type CanonicalUrl, + type ChoiceFieldInstance, type ConstrainedChoiceInfo, type Field, type Identifier, @@ -314,6 +315,50 @@ export const mkTypeSchemaIndex = ( return findLastSpecialization(schema).identifier; }; + /** Narrow choice declarations by finding the most derived schema that constrains each choice group. + * When a child profile declares only specific choice instances without re-declaring the declaration, + * restrict the declaration's choices array to only the allowed instances. */ + const narrowMergedChoiceDeclarations = ( + mergedFields: Record, + constraintSchemas: TypeSchema[], + ): Record => { + const result = { ...mergedFields }; + for (const [declName, declField] of Object.entries(result)) { + if (!isChoiceDeclarationField(declField) || declField.excluded) continue; + + for (const cSchema of constraintSchemas) { + const sFields = (cSchema as RegularTypeSchema).fields; + if (!sFields) continue; + if (sFields[declName] && isChoiceDeclarationField(sFields[declName])) continue; + + const instancesInSchema = Object.entries(sFields) + .filter(([_, f]) => isChoiceInstanceField(f) && (f as ChoiceFieldInstance).choiceOf === declName) + .map(([name]) => name); + if (instancesInSchema.length === 0) continue; + + const allowed = new Set(instancesInSchema); + result[declName] = { ...declField, choices: declField.choices.filter((c) => allowed.has(c)) }; + break; + } + } + + // Compute prohibited for all choice declarations + for (const [declName, declField] of Object.entries(result)) { + if (!isChoiceDeclarationField(declField)) continue; + const permitted = new Set(declField.excluded ? [] : declField.choices); + const prohibited = Object.entries(result) + .filter( + (e): e is [string, ChoiceFieldInstance] => + isChoiceInstanceField(e[1]) && e[1].choiceOf === declName, + ) + .filter(([name]) => !permitted.has(name)) + .map(([name]) => name); + if (prohibited.length > 0) result[declName] = { ...declField, prohibited }; + } + + return result; + }; + const flatProfile = (schema: ProfileTypeSchema): ProfileTypeSchema => { const hierarchySchemas = hierarchy(schema); const constraintSchemas = hierarchySchemas.filter((s) => s.identifier.kind === "profile"); @@ -339,6 +384,8 @@ export const mkTypeSchemaIndex = ( } } + const narrowedFields = narrowMergedChoiceDeclarations(mergedFields, constraintSchemas); + const dependencies = Object.values( Object.fromEntries( constraintSchemas @@ -362,7 +409,7 @@ export const mkTypeSchemaIndex = ( return { ...schema, base: nonConstraintSchema.identifier, - fields: mergedFields, + fields: narrowedFields, dependencies: dependencies, extensions: mergedExtensions.length > 0 ? mergedExtensions : undefined, }; diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index 075593769..76015e356 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -1715,6 +1715,16 @@ export class USCoreBodyWeightProfile { ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ...validateExcluded(res, profileName, "valueCodeableConcept"), + ...validateExcluded(res, profileName, "valueString"), + ...validateExcluded(res, profileName, "valueBoolean"), + ...validateExcluded(res, profileName, "valueInteger"), + ...validateExcluded(res, profileName, "valueRange"), + ...validateExcluded(res, profileName, "valueRatio"), + ...validateExcluded(res, profileName, "valueSampledData"), + ...validateExcluded(res, profileName, "valueTime"), + ...validateExcluded(res, profileName, "valueDateTime"), + ...validateExcluded(res, profileName, "valuePeriod"), ], warnings: [ ...validateEnum(res, profileName, "category", ["social-history","vital-signs","imaging","laboratory","procedure","survey","exam","therapy","activity"]), From 936afb7fb264c7446debe485b79101d59ef7fc34 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 16 Mar 2026 13:07:31 +0100 Subject: [PATCH 2/2] Regenerate US Core examples with choice narrowing validation --- .../profiles/Observation_USCoreBodyWeightProfile.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 41d921203..a2496de4e 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 @@ -286,6 +286,16 @@ export class USCoreBodyWeightProfile { ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ...validateExcluded(res, profileName, "valueCodeableConcept"), + ...validateExcluded(res, profileName, "valueString"), + ...validateExcluded(res, profileName, "valueBoolean"), + ...validateExcluded(res, profileName, "valueInteger"), + ...validateExcluded(res, profileName, "valueRange"), + ...validateExcluded(res, profileName, "valueRatio"), + ...validateExcluded(res, profileName, "valueSampledData"), + ...validateExcluded(res, profileName, "valueTime"), + ...validateExcluded(res, profileName, "valueDateTime"), + ...validateExcluded(res, profileName, "valuePeriod"), ], warnings: [ ...validateEnum(res, profileName, "category", ["social-history","vital-signs","imaging","laboratory","procedure","survey","exam","therapy","activity"]),