From 2c11976eb494b50a2e5e7f867c4b0999d040a993 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 8 Apr 2026 15:59:09 +0200 Subject: [PATCH 1/5] TypeSchema: Precompute base names for profile methods Move collision-free base name resolution from the TypeScript generator into the TypeSchema layer so all language generators can reuse it. - Add NameCandidates type (candidates[] + recommended) to types.ts - Add nameCandidates field to ProfileExtension and FieldSlice - New module: src/typeschema/core/name-candidates.ts - Language-neutral candidate generation for extensions and slices - Collision resolution via multi-level candidate priority - assignRecommendedBaseNames() sets recommended after profile is built - Remove resolveProfileMethodBaseNames and related helpers from profile.ts - Remove 6 dead name functions from typescript/name.ts - Update TS generators to consume nameCandidates.recommended directly --- src/api/writer-generator/typescript/name.ts | 35 ----- .../typescript/profile-extensions.ts | 10 +- .../typescript/profile-slices.ts | 22 +-- .../writer-generator/typescript/profile.ts | 95 +++---------- src/typeschema/core/field-builder.ts | 8 +- src/typeschema/core/name-candidates.ts | 125 ++++++++++++++++++ src/typeschema/core/profile-extensions.ts | 5 +- src/typeschema/core/transformer.ts | 24 ++-- src/typeschema/types.ts | 7 + test/unit/typeschema/ir/tree-shake.test.ts | 4 + 10 files changed, 181 insertions(+), 154 deletions(-) create mode 100644 src/typeschema/core/name-candidates.ts diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index f6bcedca..27daf8e0 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -106,39 +106,4 @@ export const tsExtensionFlatTypeName = (profileName: string, extensionName: stri export const tsSliceStaticName = (name: string): string => name.replace(/\[x\]/g, "").replace(/[^a-zA-Z0-9_$]/g, "_"); -export const tsSliceMethodBaseName = (sliceName: string): string => - uppercaseFirstLetter(normalizeTsName(sliceName) || "Slice"); - -export const tsExtensionMethodBaseName = (name: string): string => - uppercaseFirstLetter(tsCamelCase(name) || "Extension"); - -export const tsQualifiedExtensionMethodBaseName = (name: string, path?: string): string => { - const rawPath = - path - ?.split(".") - .filter((p) => p && p !== "extension") - .join("_") ?? ""; - const pathPart = rawPath ? uppercaseFirstLetter(tsCamelCase(rawPath)) : ""; - return `${pathPart}${uppercaseFirstLetter(tsCamelCase(name) || "Extension")}`; -}; - -export const tsQualifiedSliceMethodBaseName = (fieldName: string, sliceName: string): string => { - const fieldPart = uppercaseFirstLetter(tsCamelCase(fieldName) || "Field"); - const slicePart = uppercaseFirstLetter(normalizeTsName(sliceName) || "Slice"); - return `${fieldPart}${slicePart}`; -}; - -export const tsResolvedExtensionBaseName = ( - extensionBaseNames: Record, - url: string, - path: string, - fallbackName: string, -): string => extensionBaseNames[`${url}:${path}`] ?? fallbackName; - -export const tsResolvedSliceBaseName = ( - sliceBaseNames: Record, - fieldName: string, - sliceName: string, -): string => sliceBaseNames[`${fieldName}:${sliceName}`] ?? sliceName; - export const tsValueFieldName = (id: TypeIdentifier): string => `value${uppercaseFirstLetter(id.name)}`; diff --git a/src/api/writer-generator/typescript/profile-extensions.ts b/src/api/writer-generator/typescript/profile-extensions.ts index 6a0c3f68..de23c910 100644 --- a/src/api/writer-generator/typescript/profile-extensions.ts +++ b/src/api/writer-generator/typescript/profile-extensions.ts @@ -12,7 +12,6 @@ import { tsExtensionFlatTypeName, tsProfileClassName, tsProfileModuleName, - tsResolvedExtensionBaseName, tsResourceName, tsValueFieldName, } from "./name"; @@ -396,15 +395,10 @@ const generateGenericExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo }); }; -export const generateExtensionMethods = ( - w: TypeScript, - tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, - extensionBaseNames: Record, -) => { +export const generateExtensionMethods = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { for (const ext of flatProfile.extensions ?? []) { if (!ext.url) continue; - const baseName = tsResolvedExtensionBaseName(extensionBaseNames, ext.url, ext.path, ext.name); + const baseName = ext.nameCandidates.recommended; const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); const extProfileInfo = resolveExtensionProfile(tsIndex, flatProfile.identifier.package, ext.url); const info: ExtensionMethodInfo = { diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index 655c1c4b..db79bb7a 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -11,7 +11,6 @@ import type { TypeSchemaIndex } from "@root/typeschema/utils"; import { tsFieldName, tsProfileClassName, - tsResolvedSliceBaseName, tsResourceName, tsSliceFlatAllTypeName, tsSliceFlatTypeName, @@ -101,6 +100,8 @@ export type SliceDef = { /** Base type parameterized with the matched resource type (e.g. "BundleEntry") */ typedBaseType: string; sliceName: string; + /** Collision-free base name from nameCandidates.recommended (e.g. "VSCat", "SystolicBP") */ + baseName: string; match: Record; /** Required fields, already filtered (match keys and polymorphic base names removed) */ required: string[]; @@ -141,6 +142,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT baseType, typedBaseType, sliceName, + baseName: slice.nameCandidates.recommended, match: slice.match ?? {}, required, excluded: slice.excluded ?? [], @@ -152,16 +154,11 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT }); }); -export const generateSliceSetters = ( - w: TypeScript, - sliceDefs: SliceDef[], - flatProfile: ProfileTypeSchema, - sliceBaseNames: Record, -) => { +export const generateSliceSetters = (w: TypeScript, sliceDefs: SliceDef[], flatProfile: ProfileTypeSchema) => { const profileClassName = tsProfileClassName(flatProfile); const tsProfileName = tsResourceName(flatProfile.identifier); for (const sliceDef of sliceDefs) { - const baseName = tsResolvedSliceBaseName(sliceBaseNames, sliceDef.fieldName, sliceDef.sliceName); + const baseName = sliceDef.baseName; const methodName = `set${baseName}`; const inputTypeName = tsSliceFlatTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; @@ -225,17 +222,12 @@ export const generateSliceSetters = ( } }; -export const generateSliceGetters = ( - w: TypeScript, - sliceDefs: SliceDef[], - flatProfile: ProfileTypeSchema, - sliceBaseNames: Record, -) => { +export const generateSliceGetters = (w: TypeScript, sliceDefs: SliceDef[], flatProfile: ProfileTypeSchema) => { const profileClassName = tsProfileClassName(flatProfile); const tsProfileName = tsResourceName(flatProfile.identifier); const defaultMode = w.opts.sliceGetterDefault ?? "flat"; for (const sliceDef of sliceDefs) { - const baseName = tsResolvedSliceBaseName(sliceBaseNames, sliceDef.fieldName, sliceDef.sliceName); + const baseName = sliceDef.baseName; const getMethodName = `get${baseName}`; const flatTypeName = tsSliceFlatAllTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 1bc77361..522e9ab6 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -7,7 +7,6 @@ import { isNotChoiceDeclarationField, isPrimitiveIdentifier, isResourceIdentifier, - type ProfileExtension, type ProfileTypeSchema, packageMeta, packageMetaToFhir, @@ -17,19 +16,15 @@ import type { TypeSchemaIndex } from "@root/typeschema/utils"; import { tsCamelCase, tsExtensionFlatTypeName, - tsExtensionMethodBaseName, tsFieldName, tsModulePath, tsNameFromCanonical, tsPackageDir, tsProfileClassName, tsProfileModuleName, - tsQualifiedExtensionMethodBaseName, - tsQualifiedSliceMethodBaseName, tsResourceName, tsSliceFlatAllTypeName, tsSliceFlatTypeName, - tsSliceMethodBaseName, tsSliceStaticName, } from "./name"; import { @@ -726,76 +721,16 @@ const generateFlatInputType = (w: TypeScript, flatProfile: ProfileTypeSchema) => w.line(); }; -type ResolvedProfileMethods = { - /** "url:path" → method base name (e.g., "Race" or "PathRace") */ - extensions: Record; - /** "fieldName:sliceName" → method base name */ - slices: Record; - /** All resolved base names (extensions + slices) for field accessor dedup */ - allBaseNames: Set; -}; - -type NameEntry = { key: string; candidates: string[] }; - -const countBy = (entries: NameEntry[], level: number): Record => - entries.reduce( - (counts, e) => { - const name = e.candidates[level] ?? ""; - counts[name] = (counts[name] ?? 0) + 1; - return counts; - }, - {} as Record, - ); - -/** Resolve naming collisions across multiple levels of candidates. - * Each entry provides candidate names in priority order (e.g. base → qualified → discriminated). */ -const resolveNameCollisions = (entries: NameEntry[]): Record => { - const levels = entries[0]?.candidates.length ?? 0; - - const resolve = (unresolved: NameEntry[], level: number): Record => { - if (unresolved.length === 0 || level >= levels) return {}; - const counts = countBy(unresolved, level); - const isLastLevel = level >= levels - 1; - const [resolved, colliding] = unresolved.reduce( - ([res, col], e) => { - const name = e.candidates[level] ?? ""; - return (counts[name] ?? 0) > 1 && !isLastLevel ? [res, [...col, e]] : [{ ...res, [e.key]: name }, col]; - }, - [{} as Record, [] as NameEntry[]], - ); - return { ...resolved, ...resolve(colliding, level + 1) }; - }; - - return resolve(entries, 0); -}; - -const toRecord = (entries: NameEntry[], resolved: Record): Record => - Object.fromEntries(entries.map((e) => [e.key, resolved[e.key] ?? e.candidates[0] ?? ""])); - -const resolveProfileMethodBaseNames = ( - extensions: ProfileExtension[], - sliceDefs: SliceDef[], -): ResolvedProfileMethods => { - const extensionEntries: NameEntry[] = extensions - .filter((ext) => ext.url) - .map((ext) => { - const base = tsExtensionMethodBaseName(ext.name); - const qualified = tsQualifiedExtensionMethodBaseName(ext.name, ext.path); - return { key: `${ext.url}:${ext.path}`, candidates: [base, qualified, `${qualified}Extension`] }; - }); - - const sliceEntries: NameEntry[] = sliceDefs.map((slice) => { - const base = tsSliceMethodBaseName(slice.sliceName); - const qualified = tsQualifiedSliceMethodBaseName(slice.fieldName, slice.sliceName); - return { key: `${slice.fieldName}:${slice.sliceName}`, candidates: [base, qualified, `${qualified}Slice`] }; - }); - - const resolved = resolveNameCollisions([...extensionEntries, ...sliceEntries]); - const extensionsRecords = toRecord(extensionEntries, resolved); - const slicesRecords = toRecord(sliceEntries, resolved); - const allBaseNames = new Set([...Object.values(extensionsRecords), ...Object.values(slicesRecords)]); - - return { extensions: extensionsRecords, slices: slicesRecords, allBaseNames }; +/** Collect all resolved base names (extensions + slices) for field accessor dedup. */ +const collectAllBaseNames = (flatProfile: ProfileTypeSchema, sliceDefs: SliceDef[]): Set => { + const names = new Set(); + for (const ext of flatProfile.extensions ?? []) { + if (ext.url) names.add(ext.nameCandidates.recommended); + } + for (const slice of sliceDefs) { + names.add(slice.baseName); + } + return names; }; export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { @@ -815,7 +750,7 @@ export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, fl const canonicalUrl = flatProfile.identifier.url; w.comment("CanonicalURL:", canonicalUrl, `(pkg: ${packageMetaToFhir(packageMeta(flatProfile))})`); - const resolvedMethodNames = resolveProfileMethodBaseNames(flatProfile.extensions ?? [], sliceDefs); + const allBaseNames = collectAllBaseNames(flatProfile, sliceDefs); w.curlyBlock(["export", "class", profileClassName], () => { w.lineSM(`static readonly canonicalUrl = ${JSON.stringify(canonicalUrl)}`); @@ -824,14 +759,14 @@ export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, fl w.lineSM(`private resource: ${tsBaseResourceName}`); w.line(); generateFactoryMethods(w, tsIndex, flatProfile, factoryInfo); - generateFieldAccessors(w, factoryInfo, resolvedMethodNames.allBaseNames); + generateFieldAccessors(w, factoryInfo, allBaseNames); w.line("// Extensions"); - generateExtensionMethods(w, tsIndex, flatProfile, resolvedMethodNames.extensions); + generateExtensionMethods(w, tsIndex, flatProfile); w.line("// Slices"); - generateSliceSetters(w, sliceDefs, flatProfile, resolvedMethodNames.slices); - generateSliceGetters(w, sliceDefs, flatProfile, resolvedMethodNames.slices); + generateSliceSetters(w, sliceDefs, flatProfile); + generateSliceGetters(w, sliceDefs, flatProfile); w.line("// Validation"); generateValidateMethod(w, tsIndex, flatProfile); diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index 7c6de590..07483533 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -22,6 +22,7 @@ import type { } from "../types"; import { BINDABLE_TYPES, buildEnum } from "./binding"; import { mkBindingIdentifier, mkIdentifier } from "./identifier"; +import { mkSliceNameCandidates } from "./name-candidates"; import { mkNestedIdentifier } from "./nested-types"; function isRequired(register: Register, fhirSchema: RichFHIRSchema, path: string[]): boolean { @@ -238,7 +239,7 @@ const computeMatchFromSchema = ( return result; }; -const buildSlicing = (element: FHIRSchemaElement): FieldSlicing | undefined => { +const buildSlicing = (fieldName: string, element: FHIRSchemaElement): FieldSlicing | undefined => { const slicing = element.slicing; if (!slicing) return undefined; @@ -255,6 +256,7 @@ const buildSlicing = (element: FHIRSchemaElement): FieldSlicing | undefined => { required, excluded, elements, + nameCandidates: mkSliceNameCandidates(fieldName, name), }; } @@ -372,7 +374,7 @@ export const mkField = ( array: element.array || false, min: element.min, max: element.max, - slicing: buildSlicing(element), + slicing: buildSlicing(path[path.length - 1] ?? "", element), choices: element.choices, choiceOf: element.choiceOf, @@ -396,6 +398,6 @@ export function mkNestedField( array: element.array || false, required: isRequired(register, fhirSchema, path), excluded: isExcluded(register, fhirSchema, path), - slicing: buildSlicing(element), + slicing: buildSlicing(path[path.length - 1] ?? "", element), }; } diff --git a/src/typeschema/core/name-candidates.ts b/src/typeschema/core/name-candidates.ts new file mode 100644 index 00000000..2276dd1f --- /dev/null +++ b/src/typeschema/core/name-candidates.ts @@ -0,0 +1,125 @@ +import { camelCase, uppercaseFirstLetter } from "@root/api/writer-generator/utils"; +import type { NameCandidates, ProfileTypeSchema } from "@root/typeschema/types"; + +// ── Language-neutral normalization ────────────────────────────────────── + +/** Normalize a FHIR name for use in identifiers. + * Strips special chars, preserves original casing, uppercases first letter. */ +const normalizeName = (s: string): string => { + const cleaned = s.replace(/\[x\]/g, "").replace(/[- :.]/g, "_"); + if (!cleaned) return ""; + return uppercaseFirstLetter(cleaned); +}; + +/** Normalize via camelCase + uppercaseFirstLetter (for extension names that may be kebab/snake). */ +const normalizeCamelName = (s: string): string => { + const cleaned = s.replace(/\[x\]/g, "").replace(/:/g, "_"); + if (!cleaned) return ""; + return uppercaseFirstLetter(camelCase(cleaned)); +}; + +// ── Candidate generators ──────────────────────────────────────────────── + +const extensionCandidates = (name: string, path: string): string[] => { + const base = normalizeCamelName(name) || "Extension"; + const pathParts = path + .split(".") + .filter((p) => p && p !== "extension") + .join("_"); + const pathPart = pathParts ? normalizeCamelName(pathParts) : ""; + const qualified = `${pathPart}${base}`; + return [base, qualified, `${qualified}Extension`]; +}; + +const sliceCandidates = (fieldName: string, sliceName: string): string[] => { + const base = normalizeName(sliceName) || "Slice"; + const fieldPart = normalizeCamelName(fieldName) || "Field"; + const qualified = `${fieldPart}${base}`; + return [base, qualified, `${qualified}Slice`]; +}; + +// ── Collision resolution ──────────────────────────────────────────────── + +type NameEntry = { key: string; candidates: string[] }; + +const countBy = (entries: NameEntry[], level: number): Record => + entries.reduce( + (counts, e) => { + const name = e.candidates[level] ?? ""; + counts[name] = (counts[name] ?? 0) + 1; + return counts; + }, + {} as Record, + ); + +/** Resolve naming collisions across multiple levels of candidates. + * Each entry provides candidate names in priority order (e.g. base → qualified → discriminated). */ +const resolveNameCollisions = (entries: NameEntry[]): Record => { + const levels = entries[0]?.candidates.length ?? 0; + + const resolve = (unresolved: NameEntry[], level: number): Record => { + if (unresolved.length === 0 || level >= levels) return {}; + const counts = countBy(unresolved, level); + const isLastLevel = level >= levels - 1; + const [resolved, colliding] = unresolved.reduce( + ([res, col], e) => { + const name = e.candidates[level] ?? ""; + return (counts[name] ?? 0) > 1 && !isLastLevel ? [res, [...col, e]] : [{ ...res, [e.key]: name }, col]; + }, + [{} as Record, [] as NameEntry[]], + ); + return { ...resolved, ...resolve(colliding, level + 1) }; + }; + + return resolve(entries, 0); +}; + +// ── Public API ────────────────────────────────────────────────────────── + +/** Compute nameCandidates for a ProfileExtension (recommended is set later by assignRecommendedBaseNames). */ +export const mkExtensionNameCandidates = (ext: { name: string; path: string }): NameCandidates => { + return { candidates: extensionCandidates(ext.name, ext.path), recommended: "" }; +}; + +/** Compute nameCandidates for a FieldSlice (recommended is set later by assignRecommendedBaseNames). */ +export const mkSliceNameCandidates = (fieldName: string, sliceName: string): NameCandidates => { + return { candidates: sliceCandidates(fieldName, sliceName), recommended: "" }; +}; + +/** Resolve collisions across all extensions and slices within a profile. + * Mutates `nameCandidates.recommended` on each extension/slice in place. */ +export const assignRecommendedBaseNames = (profile: ProfileTypeSchema): void => { + const extensionEntries: NameEntry[] = (profile.extensions ?? []) + .filter((ext) => ext.url) + .map((ext) => ({ + key: `ext:${ext.url}:${ext.path}`, + candidates: ext.nameCandidates.candidates, + })); + + const sliceEntries: NameEntry[] = Object.entries(profile.fields ?? {}).flatMap(([fieldName, field]) => { + if (!("slicing" in field) || !field.slicing?.slices) return []; + return Object.entries(field.slicing.slices).map(([sliceName, slice]) => ({ + key: `slice:${fieldName}:${sliceName}`, + candidates: slice.nameCandidates.candidates, + })); + }); + + const allEntries = [...extensionEntries, ...sliceEntries]; + if (allEntries.length === 0) return; + + const resolved = resolveNameCollisions(allEntries); + + for (const ext of profile.extensions ?? []) { + if (!ext.url) continue; + const key = `ext:${ext.url}:${ext.path}`; + if (resolved[key]) ext.nameCandidates.recommended = resolved[key]; + } + + for (const [fieldName, field] of Object.entries(profile.fields ?? {})) { + if (!("slicing" in field) || !field.slicing?.slices) continue; + for (const [sliceName, slice] of Object.entries(field.slicing.slices)) { + const key = `slice:${fieldName}:${sliceName}`; + if (resolved[key]) slice.nameCandidates.recommended = resolved[key]; + } + } +}; diff --git a/src/typeschema/core/profile-extensions.ts b/src/typeschema/core/profile-extensions.ts index 42c51837..068c5e42 100644 --- a/src/typeschema/core/profile-extensions.ts +++ b/src/typeschema/core/profile-extensions.ts @@ -20,6 +20,7 @@ import { import { buildFieldType } from "./field-builder"; import { mkIdentifier } from "./identifier"; +import { mkExtensionNameCandidates } from "./name-candidates"; const extractExtensionValueFieldTypes = ( register: Register, @@ -158,9 +159,10 @@ export const extractProfileExtensions = ( const extFs = url ? register.resolveFs(fhirSchema.package_meta, url) : undefined; const profile = extFs ? (mkIdentifier(extFs) as ProfileIdentifier) : undefined; + const extPath = [...path, "extension"].join("."); extensions.push({ name, - path: [...path, "extension"].join("."), + path: extPath, url, profile, min: schema.min, @@ -169,6 +171,7 @@ export const extractProfileExtensions = ( valueFieldTypes, subExtensions, isComplex, + nameCandidates: mkExtensionNameCandidates({ name, path: extPath }), }); }; diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index 5ae8f038..63a2239c 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -29,6 +29,7 @@ import { import { collectBindingSchemas, extractValueSetConceptsByUrl } from "./binding"; import { mkField, mkNestedField } from "./field-builder"; import { mkIdentifier, mkValueSetIdentifierByUrl } from "./identifier"; +import { assignRecommendedBaseNames } from "./name-candidates"; import { extractNestedDependencies, isNestedElement, mkNestedTypes } from "./nested-types"; import { extractProfileExtensions } from "./profile-extensions"; @@ -161,18 +162,17 @@ export function transformFhirSchema(register: Register, fhirSchema: RichFHIRSche const extensions = extractProfileExtensions(register, fhirSchema, logger); const extensionDeps = extensions?.flatMap(extractExtensionDeps); const rawDeps = extractProfileDependencies(identifier, base, fields, nested); - return [ - { - identifier, - base, - fields, - nested, - description: fhirSchema.description, - dependencies: concatIdentifiers(rawDeps, extensionDeps), - extensions, - }, - ...bindingSchemas, - ]; + const profileSchema = { + identifier, + base, + fields, + nested, + description: fhirSchema.description, + dependencies: concatIdentifiers(rawDeps, extensionDeps), + extensions, + }; + assignRecommendedBaseNames(profileSchema); + return [profileSchema, ...bindingSchemas]; } if (fhirSchema.kind === "primitive-type") { diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 7b8edfb7..cd0ccb3e 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -248,6 +248,11 @@ export type ConstrainedChoiceInfo = { allChoiceNames: string[]; }; +export type NameCandidates = { + candidates: string[]; + recommended: string; +}; + export interface FieldSlice { min?: number; max?: number; @@ -255,6 +260,7 @@ export interface FieldSlice { required?: string[]; excluded?: string[]; elements?: string[]; + nameCandidates: NameCandidates; } export interface ExtensionSubField { @@ -276,6 +282,7 @@ export interface ProfileExtension { valueFieldTypes?: TypeIdentifier[]; subExtensions?: ExtensionSubField[]; isComplex?: boolean; + nameCandidates: NameCandidates; } export const extractExtensionDeps = (ext: ProfileExtension): TypeIdentifier[] => [ diff --git a/test/unit/typeschema/ir/tree-shake.test.ts b/test/unit/typeschema/ir/tree-shake.test.ts index 77bd2881..ab73a6d7 100644 --- a/test/unit/typeschema/ir/tree-shake.test.ts +++ b/test/unit/typeschema/ir/tree-shake.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import assert from "node:assert"; +import { mkExtensionNameCandidates } from "@root/typeschema/core/name-candidates"; import { packageTreeShakeReadme, rootTreeShakeReadme, @@ -301,6 +302,7 @@ describe("ignoreExtensions", () => { url: "http://example.com/ext/race", profile: mkProfileId("http://example.com/ext/race"), valueFieldTypes: [mkDep("http://hl7.org/fhir/StructureDefinition/Coding")], + nameCandidates: mkExtensionNameCandidates({ name: "race", path: "Patient.extension" }), }, { name: "ethnicity", @@ -308,12 +310,14 @@ describe("ignoreExtensions", () => { url: "http://example.com/ext/ethnicity", profile: mkProfileId("http://example.com/ext/ethnicity"), valueFieldTypes: [mkDep("http://hl7.org/fhir/StructureDefinition/CodeableConcept")], + nameCandidates: mkExtensionNameCandidates({ name: "ethnicity", path: "Patient.extension" }), }, { name: "birthsex", path: "Patient.extension", url: "http://example.com/ext/birthsex", profile: mkProfileId("http://example.com/ext/birthsex"), + nameCandidates: mkExtensionNameCandidates({ name: "birthsex", path: "Patient.extension" }), }, ], }); From e9f6538dfce5b75b73d6826e951064b402e7c402 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 8 Apr 2026 16:17:57 +0200 Subject: [PATCH 2/5] fix: Replace accessor shadowing with reserved names in collision resolution Instead of skipping field accessors that collide with slice/extension names, treat field names as reserved during collision resolution so slices/extensions get bumped to qualified names. All field accessors are now always generated. --- .../writer-generator/typescript/profile.ts | 27 +++---------------- src/typeschema/core/name-candidates.ts | 16 +++++++---- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 522e9ab6..000d86cd 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -14,7 +14,6 @@ import { } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; import { - tsCamelCase, tsExtensionFlatTypeName, tsFieldName, tsModulePath, @@ -555,11 +554,7 @@ const generateFactoryMethods = ( w.line(); }; -const generateFieldAccessors = ( - w: TypeScript, - factoryInfo: ProfileFactoryInfo, - extSliceMethodBaseNames: Set, -) => { +const generateFieldAccessors = (w: TypeScript, factoryInfo: ProfileFactoryInfo) => { w.line("// Field accessors"); for (const p of factoryInfo.params) { const methodBaseName = uppercaseFirstLetter(p.name); @@ -574,10 +569,8 @@ const generateFieldAccessors = ( w.line(); } - // Getter and setter methods for choice instance fields (skip if extension/slice has same name) for (const a of factoryInfo.accessors) { - const methodBaseName = uppercaseFirstLetter(tsCamelCase(a.name)); - if (extSliceMethodBaseNames.has(methodBaseName)) continue; + const methodBaseName = uppercaseFirstLetter(a.name); const fieldAccess = tsFieldName(a.name); w.curlyBlock([`get${methodBaseName}`, "()", `: ${a.tsType} | undefined`], () => { w.lineSM(`return ${tsGet("this.resource", fieldAccess)} as ${a.tsType} | undefined`); @@ -721,18 +714,6 @@ const generateFlatInputType = (w: TypeScript, flatProfile: ProfileTypeSchema) => w.line(); }; -/** Collect all resolved base names (extensions + slices) for field accessor dedup. */ -const collectAllBaseNames = (flatProfile: ProfileTypeSchema, sliceDefs: SliceDef[]): Set => { - const names = new Set(); - for (const ext of flatProfile.extensions ?? []) { - if (ext.url) names.add(ext.nameCandidates.recommended); - } - for (const slice of sliceDefs) { - names.add(slice.baseName); - } - return names; -}; - export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { const tsBaseResourceName = tsTypeFromIdentifier(flatProfile.base); const profileClassName = tsProfileClassName(flatProfile); @@ -750,8 +731,6 @@ export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, fl const canonicalUrl = flatProfile.identifier.url; w.comment("CanonicalURL:", canonicalUrl, `(pkg: ${packageMetaToFhir(packageMeta(flatProfile))})`); - const allBaseNames = collectAllBaseNames(flatProfile, sliceDefs); - w.curlyBlock(["export", "class", profileClassName], () => { w.lineSM(`static readonly canonicalUrl = ${JSON.stringify(canonicalUrl)}`); w.line(); @@ -759,7 +738,7 @@ export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, fl w.lineSM(`private resource: ${tsBaseResourceName}`); w.line(); generateFactoryMethods(w, tsIndex, flatProfile, factoryInfo); - generateFieldAccessors(w, factoryInfo, allBaseNames); + generateFieldAccessors(w, factoryInfo); w.line("// Extensions"); generateExtensionMethods(w, tsIndex, flatProfile); diff --git a/src/typeschema/core/name-candidates.ts b/src/typeschema/core/name-candidates.ts index 2276dd1f..2245c0a7 100644 --- a/src/typeschema/core/name-candidates.ts +++ b/src/typeschema/core/name-candidates.ts @@ -42,24 +42,26 @@ const sliceCandidates = (fieldName: string, sliceName: string): string[] => { type NameEntry = { key: string; candidates: string[] }; -const countBy = (entries: NameEntry[], level: number): Record => +const countBy = (entries: NameEntry[], level: number, reserved: Set): Record => entries.reduce( (counts, e) => { const name = e.candidates[level] ?? ""; counts[name] = (counts[name] ?? 0) + 1; + if (reserved.has(name)) counts[name] = (counts[name] ?? 0) + 1; return counts; }, {} as Record, ); /** Resolve naming collisions across multiple levels of candidates. - * Each entry provides candidate names in priority order (e.g. base → qualified → discriminated). */ -const resolveNameCollisions = (entries: NameEntry[]): Record => { + * Each entry provides candidate names in priority order (e.g. base → qualified → discriminated). + * Names in `reserved` are treated as taken — entries colliding with them are bumped to the next level. */ +const resolveNameCollisions = (entries: NameEntry[], reserved: Set): Record => { const levels = entries[0]?.candidates.length ?? 0; const resolve = (unresolved: NameEntry[], level: number): Record => { if (unresolved.length === 0 || level >= levels) return {}; - const counts = countBy(unresolved, level); + const counts = countBy(unresolved, level, reserved); const isLastLevel = level >= levels - 1; const [resolved, colliding] = unresolved.reduce( ([res, col], e) => { @@ -87,6 +89,7 @@ export const mkSliceNameCandidates = (fieldName: string, sliceName: string): Nam }; /** Resolve collisions across all extensions and slices within a profile. + * Field accessor names are reserved — slices/extensions are bumped to avoid them. * Mutates `nameCandidates.recommended` on each extension/slice in place. */ export const assignRecommendedBaseNames = (profile: ProfileTypeSchema): void => { const extensionEntries: NameEntry[] = (profile.extensions ?? []) @@ -104,10 +107,13 @@ export const assignRecommendedBaseNames = (profile: ProfileTypeSchema): void => })); }); + // Field names are reserved so slices/extensions avoid colliding with field accessors + const reservedNames = new Set(Object.keys(profile.fields ?? {}).map(normalizeCamelName)); + const allEntries = [...extensionEntries, ...sliceEntries]; if (allEntries.length === 0) return; - const resolved = resolveNameCollisions(allEntries); + const resolved = resolveNameCollisions(allEntries, reservedNames); for (const ext of profile.extensions ?? []) { if (!ext.url) continue; From ec7c62898da955fa7c12fa727758c5209ad48eae Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 8 Apr 2026 16:27:28 +0200 Subject: [PATCH 3/5] fix: Add explicit ProfileTypeSchema type annotation in transformer --- src/typeschema/core/transformer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index 63a2239c..dfe22f79 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -24,6 +24,7 @@ import { type TypeIdentifier, type TypeSchema, type ValueSetTypeSchema, + type ProfileTypeSchema, } from "@typeschema/types"; import { collectBindingSchemas, extractValueSetConceptsByUrl } from "./binding"; @@ -162,7 +163,7 @@ export function transformFhirSchema(register: Register, fhirSchema: RichFHIRSche const extensions = extractProfileExtensions(register, fhirSchema, logger); const extensionDeps = extensions?.flatMap(extractExtensionDeps); const rawDeps = extractProfileDependencies(identifier, base, fields, nested); - const profileSchema = { + const profileSchema: ProfileTypeSchema = { identifier, base, fields, From 82fc48cbe17a138a1ab340d2770833516178fbd7 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 8 Apr 2026 16:29:27 +0200 Subject: [PATCH 4/5] fix: Sort imports in transformer.ts --- src/typeschema/core/transformer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index dfe22f79..391bce33 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -17,6 +17,7 @@ import { isNestedIdentifier, type NestedTypeSchema, type ProfileIdentifier, + type ProfileTypeSchema, packageMetaToFhir, type RichFHIRSchema, type RichValueSet, @@ -24,7 +25,6 @@ import { type TypeIdentifier, type TypeSchema, type ValueSetTypeSchema, - type ProfileTypeSchema, } from "@typeschema/types"; import { collectBindingSchemas, extractValueSetConceptsByUrl } from "./binding"; From 87b64604ced2fbeea4d0785d2701f858a6c39ec8 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 8 Apr 2026 16:35:44 +0200 Subject: [PATCH 5/5] fix: Restore tsCamelCase for choice accessor method names Field names like 'value IVL_TS' (CCDA) need camelCase normalization before uppercasing to produce valid method names. --- src/api/writer-generator/typescript/profile.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 000d86cd..dc730143 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -14,6 +14,7 @@ import { } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; import { + tsCamelCase, tsExtensionFlatTypeName, tsFieldName, tsModulePath, @@ -570,7 +571,7 @@ const generateFieldAccessors = (w: TypeScript, factoryInfo: ProfileFactoryInfo) } for (const a of factoryInfo.accessors) { - const methodBaseName = uppercaseFirstLetter(a.name); + const methodBaseName = uppercaseFirstLetter(tsCamelCase(a.name)); const fieldAccess = tsFieldName(a.name); w.curlyBlock([`get${methodBaseName}`, "()", `: ${a.tsType} | undefined`], () => { w.lineSM(`return ${tsGet("this.resource", fieldAccess)} as ${a.tsType} | undefined`);