Skip to content

Commit c496590

Browse files
authored
Merge pull request #231 from atomic-ehr/feat/extension-valid-flat-mode
TS: Split the complex extension flat getter into flat and vflat
2 parents a094f4a + aa04f8c commit c496590

8 files changed

Lines changed: 349 additions & 55 deletions

File tree

‎examples/typescript-custom-packages/__snapshots__/profile-complex-extension-flat.test.ts.snap‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,9 @@ import {
460460
validateMustSupport,
461461
} from "../../profile-helpers";
462462
463+
export type NotedPatient_NotedExtracted = Partial<Pick<NotedComplexExtensionProfileFlat, "note" | "detail">>;
464+
export type NotedPatient_NotedVFlat = Pick<NotedComplexExtensionProfileFlat, "note" | "detail">;
465+
463466
// CanonicalURL: http://example.test/StructureDefinition/noted-patient (pkg: example.folder.structures#0.0.1)
464467
export class NotedPatientProfile {
465468
static readonly resourceType = "Patient";
@@ -524,17 +527,23 @@ export class NotedPatientProfile {
524527
return this
525528
}
526529
527-
public getNoted(mode: 'flat'): NotedComplexExtensionProfileFlat | undefined;
530+
public getNoted(mode: 'flat'): NotedPatient_NotedExtracted | undefined;
531+
public getNoted(mode: 'vflat'): NotedPatient_NotedVFlat | undefined;
528532
public getNoted(mode: 'profile'): NotedComplexExtensionProfile | undefined;
529533
public getNoted(mode: 'raw'): Extension | undefined;
530-
public getNoted(): NotedComplexExtensionProfileFlat | undefined;
531-
public getNoted (mode: 'flat' | 'profile' | 'raw' = 'flat'): NotedComplexExtensionProfileFlat | NotedComplexExtensionProfile | Extension | undefined {
534+
public getNoted(): NotedPatient_NotedExtracted | undefined;
535+
public getNoted (mode: 'flat' | 'vflat' | 'profile' | 'raw' = 'flat'): NotedPatient_NotedExtracted | NotedPatient_NotedVFlat | NotedComplexExtensionProfile | Extension | undefined {
532536
const ext = this.resource.extension?.find(e => e.url === "http://example.test/StructureDefinition/noted-complex-extension")
533537
if (!ext) return undefined
534538
if (mode === 'raw') return ext
535539
if (mode === 'profile') return NotedComplexExtensionProfile.apply(ext)
536540
const config = [{ name: "note", valueField: "valueString", isArray: false }, { name: "detail", valueField: "valueString", isArray: false }]
537-
return extractComplexExtension<NotedComplexExtensionProfileFlat>(ext, config)
541+
if (mode === 'vflat') {
542+
const { errors } = NotedComplexExtensionProfile.apply(ext).validate()
543+
if (errors.length > 0) throw new Error(errors.join("; "))
544+
return extractComplexExtension<NotedPatient_NotedVFlat>(ext, config)
545+
}
546+
return extractComplexExtension<NotedPatient_NotedExtracted>(ext, config)
538547
}
539548
540549
// Slices

‎examples/typescript-custom-packages/profile-complex-extension-flat.test.ts‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ describe("demo: carry a complex extension on a patient", () => {
2828
expect(resource).toMatchSnapshot();
2929
});
3030

31-
test("the getter returns a flat value missing a member its type declares as required", () => {
32-
const patient = NotedPatientProfile.apply({
31+
// An incomplete extension: `note` is required by the profile but was never written.
32+
const incompletePatient = () =>
33+
NotedPatientProfile.apply({
3334
resourceType: "Patient",
3435
extension: [
3536
{
@@ -40,13 +41,32 @@ describe("demo: carry a complex extension on a patient", () => {
4041
],
4142
});
4243

43-
// The getter is typed with the factory's flat type while extraction only ever
44-
// fills sub-extension values — `note` is declared required but is absent.
45-
const flat = patient.getNoted();
46-
// @ts-expect-error the getter's type promises members extraction never fills
44+
test("the default getter returns only the sub-extension values extraction could find", () => {
45+
// Every member is optional: extraction fills what it finds, and the ordinary
46+
// field the factory takes (`id`) is not a sub-extension, so it is never filled.
47+
const flat = incompletePatient().getNoted();
48+
4749
expect(flat).toEqual({ detail: "d" });
4850
expect(flat?.note).toBeUndefined();
4951
});
52+
53+
test("the vflat getter validates first, so its members are guaranteed", () => {
54+
const patient = NotedPatientProfile.create().setNoted({ id: "n1", note: "hello", detail: "d" });
55+
56+
// `note` is typed as string, not string | undefined — the check backs the type.
57+
const note: string = patient.getNoted("vflat")!.note;
58+
59+
expect(note).toBe("hello");
60+
});
61+
62+
test("the vflat getter throws on an extension the profile would reject", () => {
63+
expect(() => incompletePatient().getNoted("vflat")).toThrow(
64+
"NotedComplexExtension.extension: slice 'note' requires at least 1 item(s), found 0",
65+
);
66+
67+
// The lenient arm still reads the same resource without complaint.
68+
expect(incompletePatient().getNoted()).toEqual({ detail: "d" });
69+
});
5070
});
5171

5272
describe("demo: the extension factory input variants", () => {
@@ -89,6 +109,21 @@ export const _flatContractTypes = () => {
89109
IdentifiedComplexExtensionProfile.createResource({ optionalValue: "present" });
90110
// @ts-expect-error The parent's setter inherits the extension's flat contract.
91111
NotedPatientProfile.create().setNoted({ note: "hello" });
112+
113+
// The default getter promises nothing extraction may fail to produce.
114+
const flat = NotedPatientProfile.create().getNoted();
115+
// @ts-expect-error extraction never populates the ordinary field
116+
void flat?.id;
117+
// @ts-expect-error a member extraction may not populate is optional
118+
const _note: string = flat!.note;
119+
void flat?.note?.trim();
120+
121+
// The vflat getter validated the extension, so the required member is guaranteed —
122+
// while the ordinary field is still not part of what extraction produces.
123+
const vFlat = NotedPatientProfile.create().getNoted("vflat");
124+
const _validNote: string = vFlat!.note;
125+
// @ts-expect-error extraction never populates the ordinary field, validated or not
126+
void vFlat?.id;
92127
};
93128

94129
describe("the generated modules", () => {

‎examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts‎

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ import {
4040
validateMustSupport,
4141
} from "../../profile-helpers";
4242

43+
export type USCorePatientProfile_RaceExtracted = Partial<Pick<USCoreRaceExtensionProfileFlat, "ombCategory" | "detailed" | "text">>;
44+
export type USCorePatientProfile_RaceVFlat = Pick<USCoreRaceExtensionProfileFlat, "ombCategory" | "detailed" | "text">;
45+
46+
export type USCorePatientProfile_EthnicityExtracted = Partial<Pick<USCoreEthnicityExtensionProfileFlat, "ombCategory" | "detailed" | "text">>;
47+
export type USCorePatientProfile_EthnicityVFlat = Pick<USCoreEthnicityExtensionProfileFlat, "ombCategory" | "detailed" | "text">;
48+
49+
export type USCorePatientProfile_TribalAffiliationExtracted = Partial<Pick<USCoreTribalAffiliationExtensionProfileFlat, "tribalAffiliation" | "isEnrolled">>;
50+
export type USCorePatientProfile_TribalAffiliationVFlat = Pick<USCoreTribalAffiliationExtensionProfileFlat, "tribalAffiliation" | "isEnrolled">;
51+
4352
export type USCorePatientProfileRaw = {
4453
identifier: Identifier[];
4554
name: HumanName[];
@@ -129,17 +138,23 @@ export class USCorePatientProfile {
129138
return this
130139
}
131140

132-
public getRace(mode: 'flat'): USCoreRaceExtensionProfileFlat | undefined;
141+
public getRace(mode: 'flat'): USCorePatientProfile_RaceExtracted | undefined;
142+
public getRace(mode: 'vflat'): USCorePatientProfile_RaceVFlat | undefined;
133143
public getRace(mode: 'profile'): USCoreRaceExtensionProfile | undefined;
134144
public getRace(mode: 'raw'): Extension | undefined;
135-
public getRace(): USCoreRaceExtensionProfileFlat | undefined;
136-
public getRace (mode: 'flat' | 'profile' | 'raw' = 'flat'): USCoreRaceExtensionProfileFlat | USCoreRaceExtensionProfile | Extension | undefined {
145+
public getRace(): USCorePatientProfile_RaceExtracted | undefined;
146+
public getRace (mode: 'flat' | 'vflat' | 'profile' | 'raw' = 'flat'): USCorePatientProfile_RaceExtracted | USCorePatientProfile_RaceVFlat | USCoreRaceExtensionProfile | Extension | undefined {
137147
const ext = this.resource.extension?.find(e => e.url === "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race")
138148
if (!ext) return undefined
139149
if (mode === 'raw') return ext
140150
if (mode === 'profile') return USCoreRaceExtensionProfile.apply(ext)
141151
const config = [{ name: "ombCategory", valueField: "valueCoding", isArray: false }, { name: "detailed", valueField: "valueCoding", isArray: true }, { name: "text", valueField: "valueString", isArray: false }]
142-
return extractComplexExtension<USCoreRaceExtensionProfileFlat>(ext, config)
152+
if (mode === 'vflat') {
153+
const { errors } = USCoreRaceExtensionProfile.apply(ext).validate()
154+
if (errors.length > 0) throw new Error(errors.join("; "))
155+
return extractComplexExtension<USCorePatientProfile_RaceVFlat>(ext, config)
156+
}
157+
return extractComplexExtension<USCorePatientProfile_RaceExtracted>(ext, config)
143158
}
144159

145160
public setEthnicity (input: USCoreEthnicityExtensionProfileFlat | USCoreEthnicityExtensionProfile | Extension): this {
@@ -154,17 +169,23 @@ export class USCorePatientProfile {
154169
return this
155170
}
156171

157-
public getEthnicity(mode: 'flat'): USCoreEthnicityExtensionProfileFlat | undefined;
172+
public getEthnicity(mode: 'flat'): USCorePatientProfile_EthnicityExtracted | undefined;
173+
public getEthnicity(mode: 'vflat'): USCorePatientProfile_EthnicityVFlat | undefined;
158174
public getEthnicity(mode: 'profile'): USCoreEthnicityExtensionProfile | undefined;
159175
public getEthnicity(mode: 'raw'): Extension | undefined;
160-
public getEthnicity(): USCoreEthnicityExtensionProfileFlat | undefined;
161-
public getEthnicity (mode: 'flat' | 'profile' | 'raw' = 'flat'): USCoreEthnicityExtensionProfileFlat | USCoreEthnicityExtensionProfile | Extension | undefined {
176+
public getEthnicity(): USCorePatientProfile_EthnicityExtracted | undefined;
177+
public getEthnicity (mode: 'flat' | 'vflat' | 'profile' | 'raw' = 'flat'): USCorePatientProfile_EthnicityExtracted | USCorePatientProfile_EthnicityVFlat | USCoreEthnicityExtensionProfile | Extension | undefined {
162178
const ext = this.resource.extension?.find(e => e.url === "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity")
163179
if (!ext) return undefined
164180
if (mode === 'raw') return ext
165181
if (mode === 'profile') return USCoreEthnicityExtensionProfile.apply(ext)
166182
const config = [{ name: "ombCategory", valueField: "valueCoding", isArray: false }, { name: "detailed", valueField: "valueCoding", isArray: true }, { name: "text", valueField: "valueString", isArray: false }]
167-
return extractComplexExtension<USCoreEthnicityExtensionProfileFlat>(ext, config)
183+
if (mode === 'vflat') {
184+
const { errors } = USCoreEthnicityExtensionProfile.apply(ext).validate()
185+
if (errors.length > 0) throw new Error(errors.join("; "))
186+
return extractComplexExtension<USCorePatientProfile_EthnicityVFlat>(ext, config)
187+
}
188+
return extractComplexExtension<USCorePatientProfile_EthnicityExtracted>(ext, config)
168189
}
169190

170191
public setTribalAffiliation (input: USCoreTribalAffiliationExtensionProfileFlat | USCoreTribalAffiliationExtensionProfile | Extension): this {
@@ -179,17 +200,23 @@ export class USCorePatientProfile {
179200
return this
180201
}
181202

182-
public getTribalAffiliation(mode: 'flat'): USCoreTribalAffiliationExtensionProfileFlat | undefined;
203+
public getTribalAffiliation(mode: 'flat'): USCorePatientProfile_TribalAffiliationExtracted | undefined;
204+
public getTribalAffiliation(mode: 'vflat'): USCorePatientProfile_TribalAffiliationVFlat | undefined;
183205
public getTribalAffiliation(mode: 'profile'): USCoreTribalAffiliationExtensionProfile | undefined;
184206
public getTribalAffiliation(mode: 'raw'): Extension | undefined;
185-
public getTribalAffiliation(): USCoreTribalAffiliationExtensionProfileFlat | undefined;
186-
public getTribalAffiliation (mode: 'flat' | 'profile' | 'raw' = 'flat'): USCoreTribalAffiliationExtensionProfileFlat | USCoreTribalAffiliationExtensionProfile | Extension | undefined {
207+
public getTribalAffiliation(): USCorePatientProfile_TribalAffiliationExtracted | undefined;
208+
public getTribalAffiliation (mode: 'flat' | 'vflat' | 'profile' | 'raw' = 'flat'): USCorePatientProfile_TribalAffiliationExtracted | USCorePatientProfile_TribalAffiliationVFlat | USCoreTribalAffiliationExtensionProfile | Extension | undefined {
187209
const ext = this.resource.extension?.find(e => e.url === "http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation")
188210
if (!ext) return undefined
189211
if (mode === 'raw') return ext
190212
if (mode === 'profile') return USCoreTribalAffiliationExtensionProfile.apply(ext)
191213
const config = [{ name: "tribalAffiliation", valueField: "valueCodeableConcept", isArray: false }, { name: "isEnrolled", valueField: "valueBoolean", isArray: false }]
192-
return extractComplexExtension<USCoreTribalAffiliationExtensionProfileFlat>(ext, config)
214+
if (mode === 'vflat') {
215+
const { errors } = USCoreTribalAffiliationExtensionProfile.apply(ext).validate()
216+
if (errors.length > 0) throw new Error(errors.join("; "))
217+
return extractComplexExtension<USCorePatientProfile_TribalAffiliationVFlat>(ext, config)
218+
}
219+
return extractComplexExtension<USCorePatientProfile_TribalAffiliationExtracted>(ext, config)
193220
}
194221

195222
public setSex (value: USCoreIndividualSexExtensionProfile | Extension | Coding): this {

‎src/api/writer-generator/typescript/name.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ export const tsExtensionFlatTypeName = (profileName: string, extensionName: stri
100100
return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Flat`;
101101
};
102102

103+
/** The shape `extractComplexExtension` can produce from any resource: every member optional. */
104+
export const tsExtensionExtractedTypeName = (profileName: string, extensionName: string): string => {
105+
return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Extracted`;
106+
};
107+
108+
/** The same shape once the extension validated: required sub-extensions are guaranteed. */
109+
export const tsExtensionVFlatTypeName = (profileName: string, extensionName: string): string => {
110+
return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}VFlat`;
111+
};
112+
103113
export const tsSliceStaticName = (name: string): string => name.replace(/\[x\]/g, "").replace(/[^a-zA-Z0-9_$]/g, "_");
104114

105115
export const tsValueFieldName = (id: TypeIdentifier): string => `value${uppercaseFirstLetter(id.name)}`;

0 commit comments

Comments
 (0)