Skip to content

Commit 3b1c0ea

Browse files
authored
Merge pull request #105 from atomic-ehr/choice-narrowing
TypeSchema/TS: Add choice type narrowing and validation for profiles
2 parents f36f12c + 936afb7 commit 3b1c0ea

6 files changed

Lines changed: 99 additions & 2 deletions

File tree

examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,16 @@ export class USCoreBodyWeightProfile {
286286
...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]),
287287
...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]),
288288
...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]),
289+
...validateExcluded(res, profileName, "valueCodeableConcept"),
290+
...validateExcluded(res, profileName, "valueString"),
291+
...validateExcluded(res, profileName, "valueBoolean"),
292+
...validateExcluded(res, profileName, "valueInteger"),
293+
...validateExcluded(res, profileName, "valueRange"),
294+
...validateExcluded(res, profileName, "valueRatio"),
295+
...validateExcluded(res, profileName, "valueSampledData"),
296+
...validateExcluded(res, profileName, "valueTime"),
297+
...validateExcluded(res, profileName, "valueDateTime"),
298+
...validateExcluded(res, profileName, "valuePeriod"),
289299
],
290300
warnings: [
291301
...validateEnum(res, profileName, "category", ["social-history","vital-signs","imaging","laboratory","procedure","survey","exam","therapy","activity"]),

examples/typescript-us-core/profile-bodyweight.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,30 @@ describe("demo", () => {
7373
expect(profile.validate().errors).toEqual([]);
7474
});
7575

76+
test("validate() catches disallowed value[x] variants on raw resource", () => {
77+
const resource: Observation = {
78+
resourceType: "Observation",
79+
meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight"] },
80+
status: "final",
81+
category: [
82+
{
83+
coding: {
84+
code: "vital-signs",
85+
system: "http://terminology.hl7.org/CodeSystem/observation-category",
86+
},
87+
},
88+
] as any,
89+
code: { coding: [{ code: "29463-7", system: "http://loinc.org" }] },
90+
subject: { reference: "Patient/pt-1" },
91+
effectiveDateTime: "2024-06-15",
92+
valueString: "not allowed",
93+
};
94+
95+
const profile = USCoreBodyWeightProfile.apply(resource);
96+
const { errors } = profile.validate();
97+
expect(errors).toContain("USCoreBodyWeightProfile: field 'valueString' must not be present");
98+
});
99+
76100
test("getVSCat() returns flat value, getVSCat('raw') includes discriminator", () => {
77101
const profile = USCoreBodyWeightProfile.create({
78102
status: "final",

src/api/writer-generator/typescript/profile-validation.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ export const generateValidateMethod = (w: TypeScript, tsIndex: TypeSchemaIndex,
7474
const errors: string[] = [];
7575
const warnings: string[] = [];
7676
for (const [name, field] of Object.entries(fields)) {
77-
if (isChoiceInstanceField(field)) continue;
77+
if (isChoiceInstanceField(field)) {
78+
const decl = fields[field.choiceOf];
79+
if (decl && isChoiceDeclarationField(decl) && decl.prohibited?.includes(name))
80+
errors.push(`...validateExcluded(res, profileName, ${JSON.stringify(name)})`);
81+
continue;
82+
}
7883

7984
if (isChoiceDeclarationField(field)) {
8085
if (field.required)

src/typeschema/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ export interface RegularField {
288288

289289
export interface ChoiceFieldDeclaration {
290290
choices: string[];
291+
prohibited?: string[];
291292
required?: boolean;
292293
excluded?: boolean;
293294
array?: boolean;

src/typeschema/utils.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { IrReport } from "./ir/types";
66
import type { Register } from "./register";
77
import {
88
type CanonicalUrl,
9+
type ChoiceFieldInstance,
910
type ConstrainedChoiceInfo,
1011
type Field,
1112
type Identifier,
@@ -314,6 +315,50 @@ export const mkTypeSchemaIndex = (
314315
return findLastSpecialization(schema).identifier;
315316
};
316317

318+
/** Narrow choice declarations by finding the most derived schema that constrains each choice group.
319+
* When a child profile declares only specific choice instances without re-declaring the declaration,
320+
* restrict the declaration's choices array to only the allowed instances. */
321+
const narrowMergedChoiceDeclarations = (
322+
mergedFields: Record<string, Field>,
323+
constraintSchemas: TypeSchema[],
324+
): Record<string, Field> => {
325+
const result = { ...mergedFields };
326+
for (const [declName, declField] of Object.entries(result)) {
327+
if (!isChoiceDeclarationField(declField) || declField.excluded) continue;
328+
329+
for (const cSchema of constraintSchemas) {
330+
const sFields = (cSchema as RegularTypeSchema).fields;
331+
if (!sFields) continue;
332+
if (sFields[declName] && isChoiceDeclarationField(sFields[declName])) continue;
333+
334+
const instancesInSchema = Object.entries(sFields)
335+
.filter(([_, f]) => isChoiceInstanceField(f) && (f as ChoiceFieldInstance).choiceOf === declName)
336+
.map(([name]) => name);
337+
if (instancesInSchema.length === 0) continue;
338+
339+
const allowed = new Set(instancesInSchema);
340+
result[declName] = { ...declField, choices: declField.choices.filter((c) => allowed.has(c)) };
341+
break;
342+
}
343+
}
344+
345+
// Compute prohibited for all choice declarations
346+
for (const [declName, declField] of Object.entries(result)) {
347+
if (!isChoiceDeclarationField(declField)) continue;
348+
const permitted = new Set(declField.excluded ? [] : declField.choices);
349+
const prohibited = Object.entries(result)
350+
.filter(
351+
(e): e is [string, ChoiceFieldInstance] =>
352+
isChoiceInstanceField(e[1]) && e[1].choiceOf === declName,
353+
)
354+
.filter(([name]) => !permitted.has(name))
355+
.map(([name]) => name);
356+
if (prohibited.length > 0) result[declName] = { ...declField, prohibited };
357+
}
358+
359+
return result;
360+
};
361+
317362
const flatProfile = (schema: ProfileTypeSchema): ProfileTypeSchema => {
318363
const hierarchySchemas = hierarchy(schema);
319364
const constraintSchemas = hierarchySchemas.filter((s) => s.identifier.kind === "profile");
@@ -339,6 +384,8 @@ export const mkTypeSchemaIndex = (
339384
}
340385
}
341386

387+
const narrowedFields = narrowMergedChoiceDeclarations(mergedFields, constraintSchemas);
388+
342389
const dependencies = Object.values(
343390
Object.fromEntries(
344391
constraintSchemas
@@ -362,7 +409,7 @@ export const mkTypeSchemaIndex = (
362409
return {
363410
...schema,
364411
base: nonConstraintSchema.identifier,
365-
fields: mergedFields,
412+
fields: narrowedFields,
366413
dependencies: dependencies,
367414
extensions: mergedExtensions.length > 0 ? mergedExtensions : undefined,
368415
};

test/api/write-generator/__snapshots__/typescript.test.ts.snap

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,6 +1715,16 @@ export class USCoreBodyWeightProfile {
17151715
...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]),
17161716
...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]),
17171717
...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]),
1718+
...validateExcluded(res, profileName, "valueCodeableConcept"),
1719+
...validateExcluded(res, profileName, "valueString"),
1720+
...validateExcluded(res, profileName, "valueBoolean"),
1721+
...validateExcluded(res, profileName, "valueInteger"),
1722+
...validateExcluded(res, profileName, "valueRange"),
1723+
...validateExcluded(res, profileName, "valueRatio"),
1724+
...validateExcluded(res, profileName, "valueSampledData"),
1725+
...validateExcluded(res, profileName, "valueTime"),
1726+
...validateExcluded(res, profileName, "valueDateTime"),
1727+
...validateExcluded(res, profileName, "valuePeriod"),
17181728
],
17191729
warnings: [
17201730
...validateEnum(res, profileName, "category", ["social-history","vital-signs","imaging","laboratory","procedure","survey","exam","therapy","activity"]),

0 commit comments

Comments
 (0)