Skip to content

Commit b56abfe

Browse files
authored
Merge pull request #109 from atomic-ehr/ts-type-family-generics
TS: Generic BundleEntry<T> and DomainResource<T> for resource-typed fields
2 parents 50e8e4f + 69ecfe1 commit b56abfe

10 files changed

Lines changed: 156 additions & 32 deletions

File tree

examples/local-package-folder/profile-typed-bundle.test.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
* The profile slices Bundle.entry[] by resource type:
55
* - PatientEntry (min: 1, max: 1) — entry where resource is Patient
66
* - OrganizationEntry (min: 0, max: *) — entry where resource is Organization
7+
*
8+
* Generic type parameters (BundleEntry<Patient>, BundleEntry<Organization>) let
9+
* the compiler narrow `entry.resource` to the concrete resource type — no casts needed.
710
*/
811

912
import { describe, expect, test } from "bun:test";
1013
import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle";
14+
import type { BundleEntry } from "./fhir-types/hl7-fhir-r4-core/Bundle";
15+
import type { DomainResource } from "./fhir-types/hl7-fhir-r4-core/DomainResource";
1116
import type { Organization } from "./fhir-types/hl7-fhir-r4-core/Organization";
1217
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";
1318

@@ -80,9 +85,10 @@ describe("type-discriminated bundle slices", () => {
8085
expect(bundle.toResource().entry).toHaveLength(2);
8186
});
8287

83-
test("set/get PatientEntry with full BundleEntry input", () => {
88+
test("set/get PatientEntry with full BundleEntry<Patient> input", () => {
8489
const bundle = createBundle();
85-
bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient });
90+
const input: BundleEntry<Patient> = { fullUrl: "urn:uuid:p1", resource: smithPatient };
91+
bundle.setPatientEntry(input);
8692

8793
const raw = bundle.getPatientEntry("raw")!;
8894
expect(raw.fullUrl).toBe("urn:uuid:p1");
@@ -93,9 +99,10 @@ describe("type-discriminated bundle slices", () => {
9399
expect(flat.resource).toEqual(smithPatient);
94100
});
95101

96-
test("set/get OrganizationEntry with full BundleEntry input", () => {
102+
test("set/get OrganizationEntry with full BundleEntry<Organization> input", () => {
97103
const bundle = createBundle();
98-
bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg });
104+
const input: BundleEntry<Organization> = { fullUrl: "urn:uuid:o1", resource: acmeOrg };
105+
bundle.setOrganizationEntry(input);
99106

100107
const raw = bundle.getOrganizationEntry("raw")!;
101108
expect(raw.fullUrl).toBe("urn:uuid:o1");
@@ -106,3 +113,50 @@ describe("type-discriminated bundle slices", () => {
106113
expect(flat.resource).toEqual(acmeOrg);
107114
});
108115
});
116+
117+
describe("generic type-family fields — compile-time narrowing", () => {
118+
test("BundleEntry<Patient>.resource is Patient (access Patient-specific fields without cast)", () => {
119+
const bundle = createBundle();
120+
bundle.setPatientEntry({ resource: smithPatient });
121+
122+
const entry = bundle.getPatientEntry()!;
123+
// entry.resource is Patient — .name is available directly, no cast needed
124+
const family: string | undefined = entry.resource?.name?.[0]?.family;
125+
expect(family).toBe("Smith");
126+
});
127+
128+
test("BundleEntry<Organization>.resource is Organization (access Organization-specific fields without cast)", () => {
129+
const bundle = createBundle();
130+
bundle.setOrganizationEntry({ resource: acmeOrg });
131+
132+
const entry = bundle.getOrganizationEntry()!;
133+
// entry.resource is Organization — .name is string, not HumanName[]
134+
const name: string | undefined = entry.resource?.name;
135+
expect(name).toBe("Acme Corp");
136+
});
137+
138+
test("BundleEntry<T> defaults to BundleEntry<Resource> — unparameterized usage unchanged", () => {
139+
const entry: BundleEntry = { resource: smithPatient };
140+
expect(entry.resource?.resourceType).toBe("Patient");
141+
});
142+
143+
test("DomainResource<T> narrows contained to T[]", () => {
144+
const container: DomainResource<Patient> = {
145+
resourceType: "Patient",
146+
contained: [smithPatient, jonesPatient],
147+
};
148+
// contained is Patient[] — .name available directly
149+
const family: string | undefined = container.contained?.[0]?.name?.[0]?.family;
150+
expect(family).toBe("Smith");
151+
});
152+
153+
test("BundleEntry<Patient> rejects Organization at compile time", () => {
154+
const patientEntry: BundleEntry<Patient> = { resource: smithPatient };
155+
expect(patientEntry.resource?.resourceType).toBe("Patient");
156+
157+
// Uncomment to verify compile error:
158+
// @ts-expect-error — Organization is not assignable to Patient
159+
const _bad: BundleEntry<Patient> = { resource: acmeOrg };
160+
void _bad;
161+
});
162+
});

examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Bundle.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ export type { BackboneElement } from "../hl7-fhir-r4-core/BackboneElement";
1212
export type { Identifier } from "../hl7-fhir-r4-core/Identifier";
1313
export type { Signature } from "../hl7-fhir-r4-core/Signature";
1414

15-
export interface BundleEntry extends BackboneElement {
15+
export interface BundleEntry<T extends Resource = Resource> extends BackboneElement {
1616
fullUrl?: string;
1717
link?: BundleLink[];
1818
request?: BundleEntryRequest;
19-
resource?: Resource;
19+
resource?: T;
2020
response?: BundleEntryResponse;
2121
search?: BundleEntrySearch;
2222
}
@@ -30,11 +30,11 @@ export interface BundleEntryRequest extends BackboneElement {
3030
url: string;
3131
}
3232

33-
export interface BundleEntryResponse extends BackboneElement {
33+
export interface BundleEntryResponse<T extends Resource = Resource> extends BackboneElement {
3434
etag?: string;
3535
lastModified?: string;
3636
location?: string;
37-
outcome?: Resource;
37+
outcome?: T;
3838
status: string;
3939
}
4040

examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DomainResource.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ export type { Extension } from "../hl7-fhir-r4-core/Extension";
1010
export type { Narrative } from "../hl7-fhir-r4-core/Narrative";
1111

1212
// CanonicalURL: http://hl7.org/fhir/StructureDefinition/DomainResource (pkg: hl7.fhir.r4.core#4.0.1)
13-
export interface DomainResource extends Resource {
13+
export interface DomainResource<T extends Resource = Resource> extends Resource {
1414
resourceType: "DomainResource" | "Observation" | "OperationOutcome" | "Patient";
1515

16-
contained?: Resource[];
16+
contained?: T[];
1717
extension?: Extension[];
1818
modifierExtension?: Extension[];
1919
text?: Narrative;

examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/DomainResource.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ export type { Extension } from "../hl7-fhir-r4-core/Extension";
1010
export type { Narrative } from "../hl7-fhir-r4-core/Narrative";
1111

1212
// CanonicalURL: http://hl7.org/fhir/StructureDefinition/DomainResource (pkg: hl7.fhir.r4.core#4.0.1)
13-
export interface DomainResource extends Resource {
13+
export interface DomainResource<T extends Resource = Resource> extends Resource {
1414
resourceType: "DomainResource" | "Observation" | "Patient";
1515

16-
contained?: Resource[];
16+
contained?: T[];
1717
extension?: Extension[];
1818
modifierExtension?: Extension[];
1919
text?: Narrative;

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ const collectChoiceBaseNames = (tsIndex: TypeSchemaIndex, typeId: Identifier): S
3131
return names;
3232
};
3333

34+
/** Extract resource type name from a type-discriminator match (e.g. {"resource":{"resourceType":"Patient"}} → "Patient") */
35+
export const extractResourceTypeFromMatch = (match: Record<string, unknown>): string | undefined => {
36+
for (const value of Object.values(match)) {
37+
if (typeof value !== "object" || value === null) continue;
38+
const obj = value as Record<string, unknown>;
39+
if (typeof obj.resourceType === "string") return obj.resourceType;
40+
const nested = extractResourceTypeFromMatch(obj);
41+
if (nested) return nested;
42+
}
43+
return undefined;
44+
};
45+
3446
export const collectTypesFromSlices = (
3547
tsIndex: TypeSchemaIndex,
3648
flatProfile: ProfileTypeSchema,
@@ -39,11 +51,22 @@ export const collectTypesFromSlices = (
3951
const pkgName = flatProfile.identifier.package;
4052
for (const field of Object.values(flatProfile.fields ?? {})) {
4153
if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) continue;
54+
const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false;
4255
for (const slice of Object.values(field.slicing.slices)) {
4356
if (Object.keys(slice.match ?? {}).length > 0) {
4457
addType(field.type);
4558
const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined;
4659
if (cc) addType(cc.variantType);
60+
// For type discriminator slices, also import the matched resource type
61+
if (isTypeDisc && slice.match) {
62+
const resourceTypeName = extractResourceTypeFromMatch(slice.match);
63+
if (resourceTypeName) {
64+
const resourceSchema = tsIndex.schemas.find(
65+
(s) => s.identifier.name === resourceTypeName && s.identifier.kind === "resource",
66+
);
67+
if (resourceSchema) addType(resourceSchema.identifier);
68+
}
69+
}
4770
}
4871
}
4972
}
@@ -60,6 +83,8 @@ export const collectRequiredSliceNames = (field: RegularField): string[] | undef
6083
export type SliceDef = {
6184
fieldName: string;
6285
baseType: string;
86+
/** Base type parameterized with the matched resource type (e.g. "BundleEntry<Patient>") */
87+
typedBaseType: string;
6388
sliceName: string;
6489
match: Record<string, unknown>;
6590
/** Required fields, already filtered (match keys and polymorphic base names removed) */
@@ -92,9 +117,12 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT
92117
: undefined;
93118
// Skip flattening for primitive types — can't intersect object with boolean/string/etc.
94119
const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined;
120+
const resourceType = isTypeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined;
121+
const typedBaseType = resourceType ? `${baseType}<${resourceType}>` : baseType;
95122
return {
96123
fieldName,
97124
baseType,
125+
typedBaseType,
98126
sliceName,
99127
match: slice.match ?? {},
100128
required,
@@ -121,7 +149,7 @@ export const generateSliceSetters = (
121149
const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`;
122150
const tsField = tsFieldName(sliceDef.fieldName);
123151
const fieldAccess = tsGet("this.resource", tsField);
124-
const baseType = sliceDef.baseType;
152+
const baseType = sliceDef.typedBaseType;
125153
// Make input optional when there are no required fields (input can be empty object)
126154
const inputOptional = sliceDef.required.length === 0;
127155
const unionType = `${typeName} | ${baseType}`;
@@ -172,7 +200,7 @@ export const generateSliceGetters = (
172200
const matchKeys = JSON.stringify(Object.keys(sliceDef.match));
173201
const tsField = tsFieldName(sliceDef.fieldName);
174202
const fieldAccess = tsGet("this.resource", tsField);
175-
const baseType = sliceDef.baseType;
203+
const baseType = sliceDef.typedBaseType;
176204
const defaultReturn = defaultMode === "raw" ? baseType : typeName;
177205

178206
// Overload signatures
@@ -196,7 +224,11 @@ export const generateSliceGetters = (
196224
w.line(`const item = ${fieldAccess}`);
197225
w.line("if (!item || !matchesValue(item, match)) return undefined");
198226
}
199-
w.line("if (mode === 'raw') return item");
227+
if (sliceDef.typeDiscriminator) {
228+
w.line(`if (mode === 'raw') return item as ${baseType}`);
229+
} else {
230+
w.line("if (mode === 'raw') return item");
231+
}
200232
if (sliceDef.typeDiscriminator) {
201233
w.line(`return item as ${typeName}`);
202234
} else if (sliceDef.constrainedChoice) {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,12 +575,13 @@ const generateSliceInputTypes = (w: TypeScript, flatProfile: ProfileTypeSchema,
575575
}
576576
const excludedNames = allExcluded.map((name) => JSON.stringify(name));
577577
const requiredNames = sliceDef.required.map((name) => JSON.stringify(name));
578-
let typeExpr = sliceDef.baseType;
578+
const baseType = sliceDef.typedBaseType;
579+
let typeExpr = baseType;
579580
if (excludedNames.length > 0) {
580581
typeExpr = `Omit<${typeExpr}, ${excludedNames.join(" | ")}>`;
581582
}
582583
if (requiredNames.length > 0) {
583-
typeExpr = `${typeExpr} & Required<Pick<${sliceDef.baseType}, ${requiredNames.join(" | ")}>>`;
584+
typeExpr = `${typeExpr} & Required<Pick<${baseType}, ${requiredNames.join(" | ")}>>`;
584585
}
585586
if (sliceDef.constrainedChoice) {
586587
typeExpr = `${typeExpr} & ${tsTypeFromIdentifier(sliceDef.constrainedChoice.variantType)}`;

src/api/writer-generator/typescript/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ export const resolveFieldTsType = (
6363
tsName: string,
6464
field: RegularField | ChoiceFieldInstance,
6565
resolveRef?: (ref: Identifier) => Identifier,
66+
genericFieldMap?: Record<string, string>,
6667
): string => {
68+
if (genericFieldMap?.[tsName]) return genericFieldMap[tsName];
69+
6770
const rewriteFieldType = rewriteFieldTypeDefs[schemaName]?.[tsName];
6871
if (rewriteFieldType) return rewriteFieldType();
6972

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as Path from "node:path";
22
import { fileURLToPath } from "node:url";
3+
import { uppercaseFirstLetter } from "@root/api/writer-generator/utils";
34
import { Writer, type WriterOptions } from "@root/api/writer-generator/writer";
45
import {
56
type CanonicalUrl,
@@ -9,6 +10,7 @@ import {
910
isNestedIdentifier,
1011
isPrimitiveIdentifier,
1112
isProfileTypeSchema,
13+
isResourceIdentifier,
1214
isResourceTypeSchema,
1315
isSpecializationTypeSchema,
1416
type Name,
@@ -219,6 +221,32 @@ export class TypeScript extends Writer<TypeScriptOptions> {
219221
name = tsResourceName(schema.identifier);
220222
}
221223

224+
// Collect fields whose type is a resource type family (has children)
225+
const typeFamilyFields: { fieldName: string; familyTypeName: string }[] = [];
226+
for (const [fieldName, field] of Object.entries(schema.fields ?? {})) {
227+
if (isChoiceDeclarationField(field) || !field.type) continue;
228+
if (isResourceIdentifier(field.type) && tsIndex.resourceChildren(field.type).length > 0) {
229+
typeFamilyFields.push({ fieldName: tsFieldName(fieldName), familyTypeName: field.type.name });
230+
}
231+
}
232+
233+
// Build generic params from type-family fields
234+
const genericFieldMap: Record<string, string> = {};
235+
if (!genericTypes.includes(schema.identifier.name) && typeFamilyFields.length > 0) {
236+
const [first, ...rest] = typeFamilyFields;
237+
if (first && rest.length === 0) {
238+
genericFieldMap[first.fieldName] = "T";
239+
name += `<T extends ${first.familyTypeName} = ${first.familyTypeName}>`;
240+
} else {
241+
const params = typeFamilyFields.map((tf) => {
242+
const paramName = `T${uppercaseFirstLetter(tf.fieldName)}`;
243+
genericFieldMap[tf.fieldName] = paramName;
244+
return `${paramName} extends ${tf.familyTypeName} = ${tf.familyTypeName}`;
245+
});
246+
name += `<${params.join(", ")}>`;
247+
}
248+
}
249+
222250
let extendsClause: string | undefined;
223251
if (schema.base) extendsClause = `extends ${tsNameFromCanonical(schema.base.url)}`;
224252

@@ -253,7 +281,7 @@ export class TypeScript extends Writer<TypeScriptOptions> {
253281
this.debugComment(fieldName, ":", field);
254282

255283
const tsName = tsFieldName(fieldName);
256-
const tsType = resolveFieldTsType(schema.identifier.name, tsName, field);
284+
const tsType = resolveFieldTsType(schema.identifier.name, tsName, field, undefined, genericFieldMap);
257285
const optionalSymbol = field.required ? "" : "?";
258286
const arraySymbol = field.array ? "[]" : "";
259287
this.lineSM(`${tsName}${optionalSymbol}: ${tsType}${arraySymbol}`);

0 commit comments

Comments
 (0)