diff --git a/examples/local-package-folder/profile-typed-bundle.test.ts b/examples/local-package-folder/profile-typed-bundle.test.ts index 247611ec1..a0248d4ee 100644 --- a/examples/local-package-folder/profile-typed-bundle.test.ts +++ b/examples/local-package-folder/profile-typed-bundle.test.ts @@ -4,10 +4,15 @@ * The profile slices Bundle.entry[] by resource type: * - PatientEntry (min: 1, max: 1) — entry where resource is Patient * - OrganizationEntry (min: 0, max: *) — entry where resource is Organization + * + * Generic type parameters (BundleEntry, BundleEntry) let + * the compiler narrow `entry.resource` to the concrete resource type — no casts needed. */ import { describe, expect, test } from "bun:test"; import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle"; +import type { BundleEntry } from "./fhir-types/hl7-fhir-r4-core/Bundle"; +import type { DomainResource } from "./fhir-types/hl7-fhir-r4-core/DomainResource"; import type { Organization } from "./fhir-types/hl7-fhir-r4-core/Organization"; import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient"; @@ -80,9 +85,10 @@ describe("type-discriminated bundle slices", () => { expect(bundle.toResource().entry).toHaveLength(2); }); - test("set/get PatientEntry with full BundleEntry input", () => { + test("set/get PatientEntry with full BundleEntry input", () => { const bundle = createBundle(); - bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient }); + const input: BundleEntry = { fullUrl: "urn:uuid:p1", resource: smithPatient }; + bundle.setPatientEntry(input); const raw = bundle.getPatientEntry("raw")!; expect(raw.fullUrl).toBe("urn:uuid:p1"); @@ -93,9 +99,10 @@ describe("type-discriminated bundle slices", () => { expect(flat.resource).toEqual(smithPatient); }); - test("set/get OrganizationEntry with full BundleEntry input", () => { + test("set/get OrganizationEntry with full BundleEntry input", () => { const bundle = createBundle(); - bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg }); + const input: BundleEntry = { fullUrl: "urn:uuid:o1", resource: acmeOrg }; + bundle.setOrganizationEntry(input); const raw = bundle.getOrganizationEntry("raw")!; expect(raw.fullUrl).toBe("urn:uuid:o1"); @@ -106,3 +113,50 @@ describe("type-discriminated bundle slices", () => { expect(flat.resource).toEqual(acmeOrg); }); }); + +describe("generic type-family fields — compile-time narrowing", () => { + test("BundleEntry.resource is Patient (access Patient-specific fields without cast)", () => { + const bundle = createBundle(); + bundle.setPatientEntry({ resource: smithPatient }); + + const entry = bundle.getPatientEntry()!; + // entry.resource is Patient — .name is available directly, no cast needed + const family: string | undefined = entry.resource?.name?.[0]?.family; + expect(family).toBe("Smith"); + }); + + test("BundleEntry.resource is Organization (access Organization-specific fields without cast)", () => { + const bundle = createBundle(); + bundle.setOrganizationEntry({ resource: acmeOrg }); + + const entry = bundle.getOrganizationEntry()!; + // entry.resource is Organization — .name is string, not HumanName[] + const name: string | undefined = entry.resource?.name; + expect(name).toBe("Acme Corp"); + }); + + test("BundleEntry defaults to BundleEntry — unparameterized usage unchanged", () => { + const entry: BundleEntry = { resource: smithPatient }; + expect(entry.resource?.resourceType).toBe("Patient"); + }); + + test("DomainResource narrows contained to T[]", () => { + const container: DomainResource = { + resourceType: "Patient", + contained: [smithPatient, jonesPatient], + }; + // contained is Patient[] — .name available directly + const family: string | undefined = container.contained?.[0]?.name?.[0]?.family; + expect(family).toBe("Smith"); + }); + + test("BundleEntry rejects Organization at compile time", () => { + const patientEntry: BundleEntry = { resource: smithPatient }; + expect(patientEntry.resource?.resourceType).toBe("Patient"); + + // Uncomment to verify compile error: + // @ts-expect-error — Organization is not assignable to Patient + const _bad: BundleEntry = { resource: acmeOrg }; + void _bad; + }); +}); diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Bundle.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Bundle.ts index cce9315fb..3a9e01389 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Bundle.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Bundle.ts @@ -12,11 +12,11 @@ export type { BackboneElement } from "../hl7-fhir-r4-core/BackboneElement"; export type { Identifier } from "../hl7-fhir-r4-core/Identifier"; export type { Signature } from "../hl7-fhir-r4-core/Signature"; -export interface BundleEntry extends BackboneElement { +export interface BundleEntry extends BackboneElement { fullUrl?: string; link?: BundleLink[]; request?: BundleEntryRequest; - resource?: Resource; + resource?: T; response?: BundleEntryResponse; search?: BundleEntrySearch; } @@ -30,11 +30,11 @@ export interface BundleEntryRequest extends BackboneElement { url: string; } -export interface BundleEntryResponse extends BackboneElement { +export interface BundleEntryResponse extends BackboneElement { etag?: string; lastModified?: string; location?: string; - outcome?: Resource; + outcome?: T; status: string; } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DomainResource.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DomainResource.ts index e1ee97c04..bfd994441 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DomainResource.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DomainResource.ts @@ -10,10 +10,10 @@ export type { Extension } from "../hl7-fhir-r4-core/Extension"; export type { Narrative } from "../hl7-fhir-r4-core/Narrative"; // CanonicalURL: http://hl7.org/fhir/StructureDefinition/DomainResource (pkg: hl7.fhir.r4.core#4.0.1) -export interface DomainResource extends Resource { +export interface DomainResource extends Resource { resourceType: "DomainResource" | "Observation" | "OperationOutcome" | "Patient"; - contained?: Resource[]; + contained?: T[]; extension?: Extension[]; modifierExtension?: Extension[]; text?: Narrative; diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/DomainResource.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/DomainResource.ts index d2fab6707..7e5e38fe5 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/DomainResource.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/DomainResource.ts @@ -10,10 +10,10 @@ export type { Extension } from "../hl7-fhir-r4-core/Extension"; export type { Narrative } from "../hl7-fhir-r4-core/Narrative"; // CanonicalURL: http://hl7.org/fhir/StructureDefinition/DomainResource (pkg: hl7.fhir.r4.core#4.0.1) -export interface DomainResource extends Resource { +export interface DomainResource extends Resource { resourceType: "DomainResource" | "Observation" | "Patient"; - contained?: Resource[]; + contained?: T[]; extension?: Extension[]; modifierExtension?: Extension[]; text?: Narrative; diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index 5f1a378e0..a6bd52cd8 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -31,6 +31,18 @@ const collectChoiceBaseNames = (tsIndex: TypeSchemaIndex, typeId: Identifier): S return names; }; +/** Extract resource type name from a type-discriminator match (e.g. {"resource":{"resourceType":"Patient"}} → "Patient") */ +export const extractResourceTypeFromMatch = (match: Record): string | undefined => { + for (const value of Object.values(match)) { + if (typeof value !== "object" || value === null) continue; + const obj = value as Record; + if (typeof obj.resourceType === "string") return obj.resourceType; + const nested = extractResourceTypeFromMatch(obj); + if (nested) return nested; + } + return undefined; +}; + export const collectTypesFromSlices = ( tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema, @@ -39,11 +51,22 @@ export const collectTypesFromSlices = ( const pkgName = flatProfile.identifier.package; for (const field of Object.values(flatProfile.fields ?? {})) { if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) continue; + const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false; for (const slice of Object.values(field.slicing.slices)) { if (Object.keys(slice.match ?? {}).length > 0) { addType(field.type); const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined; if (cc) addType(cc.variantType); + // For type discriminator slices, also import the matched resource type + if (isTypeDisc && slice.match) { + const resourceTypeName = extractResourceTypeFromMatch(slice.match); + if (resourceTypeName) { + const resourceSchema = tsIndex.schemas.find( + (s) => s.identifier.name === resourceTypeName && s.identifier.kind === "resource", + ); + if (resourceSchema) addType(resourceSchema.identifier); + } + } } } } @@ -60,6 +83,8 @@ export const collectRequiredSliceNames = (field: RegularField): string[] | undef export type SliceDef = { fieldName: string; baseType: string; + /** Base type parameterized with the matched resource type (e.g. "BundleEntry") */ + typedBaseType: string; sliceName: string; match: Record; /** Required fields, already filtered (match keys and polymorphic base names removed) */ @@ -92,9 +117,12 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT : undefined; // Skip flattening for primitive types — can't intersect object with boolean/string/etc. const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined; + const resourceType = isTypeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined; + const typedBaseType = resourceType ? `${baseType}<${resourceType}>` : baseType; return { fieldName, baseType, + typedBaseType, sliceName, match: slice.match ?? {}, required, @@ -121,7 +149,7 @@ export const generateSliceSetters = ( const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); - const baseType = sliceDef.baseType; + const baseType = sliceDef.typedBaseType; // Make input optional when there are no required fields (input can be empty object) const inputOptional = sliceDef.required.length === 0; const unionType = `${typeName} | ${baseType}`; @@ -172,7 +200,7 @@ export const generateSliceGetters = ( const matchKeys = JSON.stringify(Object.keys(sliceDef.match)); const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); - const baseType = sliceDef.baseType; + const baseType = sliceDef.typedBaseType; const defaultReturn = defaultMode === "raw" ? baseType : typeName; // Overload signatures @@ -196,7 +224,11 @@ export const generateSliceGetters = ( w.line(`const item = ${fieldAccess}`); w.line("if (!item || !matchesValue(item, match)) return undefined"); } - w.line("if (mode === 'raw') return item"); + if (sliceDef.typeDiscriminator) { + w.line(`if (mode === 'raw') return item as ${baseType}`); + } else { + w.line("if (mode === 'raw') return item"); + } if (sliceDef.typeDiscriminator) { w.line(`return item as ${typeName}`); } else if (sliceDef.constrainedChoice) { diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 889c804e1..9cbb5e3da 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -575,12 +575,13 @@ const generateSliceInputTypes = (w: TypeScript, flatProfile: ProfileTypeSchema, } const excludedNames = allExcluded.map((name) => JSON.stringify(name)); const requiredNames = sliceDef.required.map((name) => JSON.stringify(name)); - let typeExpr = sliceDef.baseType; + const baseType = sliceDef.typedBaseType; + let typeExpr = baseType; if (excludedNames.length > 0) { typeExpr = `Omit<${typeExpr}, ${excludedNames.join(" | ")}>`; } if (requiredNames.length > 0) { - typeExpr = `${typeExpr} & Required>`; + typeExpr = `${typeExpr} & Required>`; } if (sliceDef.constrainedChoice) { typeExpr = `${typeExpr} & ${tsTypeFromIdentifier(sliceDef.constrainedChoice.variantType)}`; diff --git a/src/api/writer-generator/typescript/utils.ts b/src/api/writer-generator/typescript/utils.ts index 7475ccb5a..e949c399e 100644 --- a/src/api/writer-generator/typescript/utils.ts +++ b/src/api/writer-generator/typescript/utils.ts @@ -63,7 +63,10 @@ export const resolveFieldTsType = ( tsName: string, field: RegularField | ChoiceFieldInstance, resolveRef?: (ref: Identifier) => Identifier, + genericFieldMap?: Record, ): string => { + if (genericFieldMap?.[tsName]) return genericFieldMap[tsName]; + const rewriteFieldType = rewriteFieldTypeDefs[schemaName]?.[tsName]; if (rewriteFieldType) return rewriteFieldType(); diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index a3bcbe4e7..2e01b6bf9 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -1,5 +1,6 @@ import * as Path from "node:path"; import { fileURLToPath } from "node:url"; +import { uppercaseFirstLetter } from "@root/api/writer-generator/utils"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; import { type CanonicalUrl, @@ -9,6 +10,7 @@ import { isNestedIdentifier, isPrimitiveIdentifier, isProfileTypeSchema, + isResourceIdentifier, isResourceTypeSchema, isSpecializationTypeSchema, type Name, @@ -219,6 +221,32 @@ export class TypeScript extends Writer { name = tsResourceName(schema.identifier); } + // Collect fields whose type is a resource type family (has children) + const typeFamilyFields: { fieldName: string; familyTypeName: string }[] = []; + for (const [fieldName, field] of Object.entries(schema.fields ?? {})) { + if (isChoiceDeclarationField(field) || !field.type) continue; + if (isResourceIdentifier(field.type) && tsIndex.resourceChildren(field.type).length > 0) { + typeFamilyFields.push({ fieldName: tsFieldName(fieldName), familyTypeName: field.type.name }); + } + } + + // Build generic params from type-family fields + const genericFieldMap: Record = {}; + if (!genericTypes.includes(schema.identifier.name) && typeFamilyFields.length > 0) { + const [first, ...rest] = typeFamilyFields; + if (first && rest.length === 0) { + genericFieldMap[first.fieldName] = "T"; + name += ``; + } else { + const params = typeFamilyFields.map((tf) => { + const paramName = `T${uppercaseFirstLetter(tf.fieldName)}`; + genericFieldMap[tf.fieldName] = paramName; + return `${paramName} extends ${tf.familyTypeName} = ${tf.familyTypeName}`; + }); + name += `<${params.join(", ")}>`; + } + } + let extendsClause: string | undefined; if (schema.base) extendsClause = `extends ${tsNameFromCanonical(schema.base.url)}`; @@ -253,7 +281,7 @@ export class TypeScript extends Writer { this.debugComment(fieldName, ":", field); const tsName = tsFieldName(fieldName); - const tsType = resolveFieldTsType(schema.identifier.name, tsName, field); + const tsType = resolveFieldTsType(schema.identifier.name, tsName, field, undefined, genericFieldMap); const optionalSymbol = field.required ? "" : "?"; const arraySymbol = field.array ? "[]" : ""; this.lineSM(`${tsName}${optionalSymbol}: ${tsType}${arraySymbol}`); diff --git a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap index f04d18fa6..1ccf629b8 100644 --- a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap +++ b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap @@ -34,9 +34,11 @@ exports[`Local Package Folder - Multi-Package Generation TypeScript Generation w // Any manual changes made to this file may be overwritten. import type { Bundle, BundleEntry } from "../../hl7-fhir-r4-core/Bundle"; +import type { Organization } from "../../hl7-fhir-r4-core/Organization"; +import type { Patient } from "../../hl7-fhir-r4-core/Patient"; -export type ExampleTypedBundle_Entry_PatientEntrySliceFlat = BundleEntry; -export type ExampleTypedBundle_Entry_OrganizationEntrySliceFlat = BundleEntry; +export type ExampleTypedBundle_Entry_PatientEntrySliceFlat = BundleEntry; +export type ExampleTypedBundle_Entry_OrganizationEntrySliceFlat = BundleEntry; import { buildResource, @@ -133,47 +135,47 @@ export class ExampleTypedBundleProfile { // Extensions // Slices - public setPatientEntry (input?: ExampleTypedBundle_Entry_PatientEntrySliceFlat | BundleEntry): this { + public setPatientEntry (input?: ExampleTypedBundle_Entry_PatientEntrySliceFlat | BundleEntry): this { const match = ExampleTypedBundleProfile.PatientEntrySliceMatch if (input && matchesValue(input, match)) { - setArraySlice(this.resource.entry ??= [], match, input as BundleEntry) + setArraySlice(this.resource.entry ??= [], match, input as BundleEntry) return this } - const value = applySliceMatch(input ?? {}, match) + const value = applySliceMatch>(input ?? {}, match) setArraySlice(this.resource.entry ??= [], match, value) return this } - public setOrganizationEntry (input?: ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry): this { + public setOrganizationEntry (input?: ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry): this { const match = ExampleTypedBundleProfile.OrganizationEntrySliceMatch if (input && matchesValue(input, match)) { - setArraySlice(this.resource.entry ??= [], match, input as BundleEntry) + setArraySlice(this.resource.entry ??= [], match, input as BundleEntry) return this } - const value = applySliceMatch(input ?? {}, match) + const value = applySliceMatch>(input ?? {}, match) setArraySlice(this.resource.entry ??= [], match, value) return this } public getPatientEntry(mode: 'flat'): ExampleTypedBundle_Entry_PatientEntrySliceFlat | undefined; - public getPatientEntry(mode: 'raw'): BundleEntry | undefined; + public getPatientEntry(mode: 'raw'): BundleEntry | undefined; public getPatientEntry(): ExampleTypedBundle_Entry_PatientEntrySliceFlat | undefined; - public getPatientEntry (mode: 'flat' | 'raw' = 'flat'): ExampleTypedBundle_Entry_PatientEntrySliceFlat | BundleEntry | undefined { + public getPatientEntry (mode: 'flat' | 'raw' = 'flat'): ExampleTypedBundle_Entry_PatientEntrySliceFlat | BundleEntry | undefined { const match = ExampleTypedBundleProfile.PatientEntrySliceMatch const item = getArraySlice(this.resource.entry, match) if (!item) return undefined - if (mode === 'raw') return item + if (mode === 'raw') return item as BundleEntry return item as ExampleTypedBundle_Entry_PatientEntrySliceFlat } public getOrganizationEntry(mode: 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | undefined; - public getOrganizationEntry(mode: 'raw'): BundleEntry | undefined; + public getOrganizationEntry(mode: 'raw'): BundleEntry | undefined; public getOrganizationEntry(): ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | undefined; - public getOrganizationEntry (mode: 'flat' | 'raw' = 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry | undefined { + public getOrganizationEntry (mode: 'flat' | 'raw' = 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry | undefined { const match = ExampleTypedBundleProfile.OrganizationEntrySliceMatch const item = getArraySlice(this.resource.entry, match) if (!item) return undefined - if (mode === 'raw') return item + if (mode === 'raw') return item as BundleEntry return item as ExampleTypedBundle_Entry_OrganizationEntrySliceFlat } diff --git a/test/api/write-generator/multi-package/local-package.test.ts b/test/api/write-generator/multi-package/local-package.test.ts index 238e871fb..5719e8bd9 100644 --- a/test/api/write-generator/multi-package/local-package.test.ts +++ b/test/api/write-generator/multi-package/local-package.test.ts @@ -68,6 +68,10 @@ describe("Local Package Folder - Multi-Package Generation", async () => { "example.folder.structures": { "http://example.org/fhir/StructureDefinition/ExampleTypedBundle": {}, }, + "hl7.fhir.r4.core": { + "http://hl7.org/fhir/StructureDefinition/Patient": {}, + "http://hl7.org/fhir/StructureDefinition/Organization": {}, + }, }, }) .typescript({ inMemoryOnly: true, generateProfile: true, withDebugComment: false })