Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions examples/local-package-folder/profile-typed-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Patient>, BundleEntry<Organization>) 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";

Expand Down Expand Up @@ -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<Patient> input", () => {
const bundle = createBundle();
bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient });
const input: BundleEntry<Patient> = { fullUrl: "urn:uuid:p1", resource: smithPatient };
bundle.setPatientEntry(input);

const raw = bundle.getPatientEntry("raw")!;
expect(raw.fullUrl).toBe("urn:uuid:p1");
Expand All @@ -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<Organization> input", () => {
const bundle = createBundle();
bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg });
const input: BundleEntry<Organization> = { fullUrl: "urn:uuid:o1", resource: acmeOrg };
bundle.setOrganizationEntry(input);

const raw = bundle.getOrganizationEntry("raw")!;
expect(raw.fullUrl).toBe("urn:uuid:o1");
Expand All @@ -106,3 +113,50 @@ describe("type-discriminated bundle slices", () => {
expect(flat.resource).toEqual(acmeOrg);
});
});

describe("generic type-family fields — compile-time narrowing", () => {
test("BundleEntry<Patient>.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<Organization>.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<T> defaults to BundleEntry<Resource> — unparameterized usage unchanged", () => {
const entry: BundleEntry = { resource: smithPatient };
expect(entry.resource?.resourceType).toBe("Patient");
});

test("DomainResource<T> narrows contained to T[]", () => {
const container: DomainResource<Patient> = {
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<Patient> rejects Organization at compile time", () => {
const patientEntry: BundleEntry<Patient> = { 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<Patient> = { resource: acmeOrg };
void _bad;
});
});
8 changes: 4 additions & 4 deletions examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Resource = Resource> extends BackboneElement {
fullUrl?: string;
link?: BundleLink[];
request?: BundleEntryRequest;
resource?: Resource;
resource?: T;
response?: BundleEntryResponse;
search?: BundleEntrySearch;
}
Expand All @@ -30,11 +30,11 @@ export interface BundleEntryRequest extends BackboneElement {
url: string;
}

export interface BundleEntryResponse extends BackboneElement {
export interface BundleEntryResponse<T extends Resource = Resource> extends BackboneElement {
etag?: string;
lastModified?: string;
location?: string;
outcome?: Resource;
outcome?: T;
status: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Resource = Resource> extends Resource {
resourceType: "DomainResource" | "Observation" | "OperationOutcome" | "Patient";

contained?: Resource[];
contained?: T[];
extension?: Extension[];
modifierExtension?: Extension[];
text?: Narrative;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Resource = Resource> extends Resource {
resourceType: "DomainResource" | "Observation" | "Patient";

contained?: Resource[];
contained?: T[];
extension?: Extension[];
modifierExtension?: Extension[];
text?: Narrative;
Expand Down
38 changes: 35 additions & 3 deletions src/api/writer-generator/typescript/profile-slices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string | undefined => {
for (const value of Object.values(match)) {
if (typeof value !== "object" || value === null) continue;
const obj = value as Record<string, unknown>;
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,
Expand All @@ -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);
}
}
}
}
}
Expand All @@ -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<Patient>") */
typedBaseType: string;
sliceName: string;
match: Record<string, unknown>;
/** Required fields, already filtered (match keys and polymorphic base names removed) */
Expand Down Expand Up @@ -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,
Expand All @@ -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}`;
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions src/api/writer-generator/typescript/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<${sliceDef.baseType}, ${requiredNames.join(" | ")}>>`;
typeExpr = `${typeExpr} & Required<Pick<${baseType}, ${requiredNames.join(" | ")}>>`;
}
if (sliceDef.constrainedChoice) {
typeExpr = `${typeExpr} & ${tsTypeFromIdentifier(sliceDef.constrainedChoice.variantType)}`;
Expand Down
3 changes: 3 additions & 0 deletions src/api/writer-generator/typescript/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ export const resolveFieldTsType = (
tsName: string,
field: RegularField | ChoiceFieldInstance,
resolveRef?: (ref: Identifier) => Identifier,
genericFieldMap?: Record<string, string>,
): string => {
if (genericFieldMap?.[tsName]) return genericFieldMap[tsName];

const rewriteFieldType = rewriteFieldTypeDefs[schemaName]?.[tsName];
if (rewriteFieldType) return rewriteFieldType();

Expand Down
30 changes: 29 additions & 1 deletion src/api/writer-generator/typescript/writer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +10,7 @@ import {
isNestedIdentifier,
isPrimitiveIdentifier,
isProfileTypeSchema,
isResourceIdentifier,
isResourceTypeSchema,
isSpecializationTypeSchema,
type Name,
Expand Down Expand Up @@ -219,6 +221,32 @@ export class TypeScript extends Writer<TypeScriptOptions> {
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<string, string> = {};
if (!genericTypes.includes(schema.identifier.name) && typeFamilyFields.length > 0) {
const [first, ...rest] = typeFamilyFields;
if (first && rest.length === 0) {
genericFieldMap[first.fieldName] = "T";
name += `<T extends ${first.familyTypeName} = ${first.familyTypeName}>`;
} 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)}`;

Expand Down Expand Up @@ -253,7 +281,7 @@ export class TypeScript extends Writer<TypeScriptOptions> {
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}`);
Expand Down
Loading
Loading