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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,13 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators

## Pull Request Style

- PR body should be a bullet list summarizing changes — no section headers, no test plan.
- PR body should be a bullet list summarizing changes — no test plan section.
- Use two-level nesting to group related items when the list is long; keep it flat when short.
- Use `##` section headers to group changes by concern when the PR spans multiple topics (e.g. renames, new features, config changes).
- Keep bullets concise and focused on what changed, not why.
- When a PR changes generated code or user-facing API, include before/after code examples.
- Add a short motivation line before each example explaining why the change was made.
- When a PR changes user-facing config (generation scripts, tree shake rules, APIBuilder options), show the config diff as a before/after code block.

## Development Guidelines

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Tree shaking optimizes the generated output by including only the resources you
})
```

This feature automatically resolves and includes all dependencies (referenced types, base resources, nested types) while excluding unused resources, significantly reducing the size of generated code and improving compilation times.
This feature automatically resolves and includes all dependencies (referenced types, base resources, nested types, and extension definitions used by profiles) while excluding unused resources, significantly reducing the size of generated code and improving compilation times.

##### Field-Level Tree Shaking

Expand All @@ -259,6 +259,7 @@ Beyond resource-level filtering, tree shaking supports fine-grained field select
- `selectFields`: Only includes the specified fields (whitelist approach)
- `ignoreFields`: Removes specified fields, keeps everything else (blacklist approach)
- These options are **mutually exclusive** - you cannot use both in the same rule
- `ignoreExtensions`: Removes specific extensions from a profile by canonical URL

**Polymorphic Field Handling:**

Expand Down
8 changes: 7 additions & 1 deletion docs/posts/typescript-profiles-deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,13 @@ new APIBuilder()
.generate();
```

The generator resolves dependencies automatically. If you include `bodyweight`, it knows to include `Observation`, `DomainResource`, and any types referenced by the profile's fields.
The generator resolves dependencies automatically. If you include `bodyweight`, it knows to include `Observation`, `DomainResource`, and any types referenced by the profile's fields. Extension definitions used by profiles (e.g., `us-core-race`, `us-core-ethnicity`) are also auto-collected — you don't need to list them manually. To exclude specific extensions, use `ignoreExtensions`:

```typescript
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient": {
ignoreExtensions: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-genderIdentity"]
}
```

Generated files land in a `profiles/` subdirectory alongside the base types:

Expand Down
5 changes: 0 additions & 5 deletions examples/typescript-us-core/fhir-types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1995,7 +1995,6 @@
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter#hospitalization.dischargeDisposition_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter#type_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-extension-questionnaire-uri`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-genderIdentity`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-goal`
Expand All @@ -2012,8 +2011,6 @@
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-immunization#vaccineCode_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-implantable-device`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-implantable-device#type_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-jurisdiction`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-location`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-location#address.state_binding`
Expand Down Expand Up @@ -2066,7 +2063,6 @@
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-pulse-oximetry`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-pulse-oximetry#code_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-questionnaireresponse`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-race`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-relatedperson`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-relatedperson#relationship_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-respiratory-rate`
Expand All @@ -2086,7 +2082,6 @@
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-specimen#type_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-treatment-intervention-preference`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-treatment-intervention-preference#category_binding`
- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation`
- `http://hl7.org/fhir/us/core/StructureDefinition/uscdi-requirement`
- `http://hl7.org/fhir/us/core/ValueSet/us-core-clinical-note-type`
- `http://hl7.org/fhir/us/core/ValueSet/us-core-clinical-result-observation-category`
Expand Down
5 changes: 0 additions & 5 deletions examples/typescript-us-core/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@ if (require.main === module) {
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-race": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed": {},
},
},
})
Expand Down
24 changes: 12 additions & 12 deletions src/api/writer-generator/typescript/profile-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ const generateComplexExtensionSetter = (w: TypeScript, info: ExtensionMethodInfo
w.curlyBlock(["public", setMethodName, `(input: ${inputTypeName}): this`], () => {
w.line("const subExtensions: Extension[] = []");
for (const sub of ext.subExtensions ?? []) {
const valueField = sub.valueType ? tsValueFieldName(sub.valueType) : "value";
const valueField = sub.valueFieldType ? tsValueFieldName(sub.valueFieldType) : "value";
if (sub.max === "*") {
w.curlyBlock(["if", `(input.${sub.name})`], () => {
w.curlyBlock(["for", `(const item of input.${sub.name})`], () => {
Expand Down Expand Up @@ -282,7 +282,7 @@ const generateComplexExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo

generateExtensionGetterOverloads(w, ext, targetPath, getMethodName, inputType, extProfileInfo, () => {
const configItems = (ext.subExtensions ?? []).map((sub) => {
const valueField = sub.valueType ? tsValueFieldName(sub.valueType) : "value";
const valueField = sub.valueFieldType ? tsValueFieldName(sub.valueFieldType) : "value";
const isArray = sub.max === "*";
return `{ name: "${sub.url}", valueField: "${valueField}", isArray: ${isArray} }`;
});
Expand All @@ -295,7 +295,7 @@ const generateComplexExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo

const generateSingleValueExtensionSetter = (w: TypeScript, tsIndex: TypeSchemaIndex, info: ExtensionMethodInfo) => {
const { ext, setMethodName, targetPath, extProfileInfo } = info;
const firstValueType = ext.valueTypes?.[0];
const firstValueType = ext.valueFieldTypes?.[0];
if (!firstValueType) return;
const valueType = tsTypeFromIdentifier(firstValueType);
const valueField = tsValueFieldName(firstValueType);
Expand Down Expand Up @@ -335,7 +335,7 @@ const generateSingleValueExtensionSetter = (w: TypeScript, tsIndex: TypeSchemaIn

const generateSingleValueExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo) => {
const { ext, getMethodName, targetPath, extProfileInfo } = info;
const firstValueType = ext.valueTypes?.[0];
const firstValueType = ext.valueFieldTypes?.[0];
if (!firstValueType) return;
const valueType = tsTypeFromIdentifier(firstValueType);
const valueField = tsValueFieldName(firstValueType);
Expand Down Expand Up @@ -403,7 +403,7 @@ export const generateExtensionMethods = (
generateComplexExtensionSetter(w, info);
w.line();
generateComplexExtensionGetter(w, info);
} else if (ext.valueTypes?.length === 1 && ext.valueTypes[0]) {
} else if (ext.valueFieldTypes?.length === 1 && ext.valueFieldTypes[0]) {
generateSingleValueExtensionSetter(w, tsIndex, info);
w.line();
generateSingleValueExtensionGetter(w, info);
Expand All @@ -427,21 +427,21 @@ export const collectTypesFromExtensions = (
if (ext.isComplex && ext.subExtensions) {
needsExtensionType = true;
for (const sub of ext.subExtensions) {
if (!sub.valueType) continue;
if (!sub.valueFieldType) continue;
const resolvedType = tsIndex.resolveByUrl(
flatProfile.identifier.package,
sub.valueType.url as CanonicalUrl,
sub.valueFieldType.url as CanonicalUrl,
);
addType(resolvedType?.identifier ?? sub.valueType);
addType(resolvedType?.identifier ?? sub.valueFieldType);
}
} else if (ext.valueTypes && ext.valueTypes.length === 1) {
} else if (ext.valueFieldTypes && ext.valueFieldTypes.length === 1) {
needsExtensionType = true;
if (ext.valueTypes[0]) {
if (ext.valueFieldTypes[0]) {
const resolvedType = tsIndex.resolveByUrl(
flatProfile.identifier.package,
ext.valueTypes[0].url as CanonicalUrl,
ext.valueFieldTypes[0].url as CanonicalUrl,
);
addType(resolvedType?.identifier ?? ext.valueTypes[0]);
addType(resolvedType?.identifier ?? ext.valueFieldTypes[0]);
}
} else {
needsExtensionType = true;
Expand Down
2 changes: 1 addition & 1 deletion src/api/writer-generator/typescript/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ const generateInlineExtensionInputTypes = (w: TypeScript, tsIndex: TypeSchemaInd
const typeName = tsExtensionFlatTypeName(tsProfileName, ext.name);
w.curlyBlock(["export", "type", typeName, "="], () => {
for (const sub of ext.subExtensions ?? []) {
const tsType = sub.valueType ? tsTypeFromIdentifier(sub.valueType) : "unknown";
const tsType = sub.valueFieldType ? tsTypeFromIdentifier(sub.valueFieldType) : "unknown";
const isArray = sub.max === "*";
const isRequired = sub.min !== undefined && sub.min > 0;
w.lineSM(`${sub.name}${isRequired ? "" : "?"}: ${tsType}${isArray ? "[]" : ""}`);
Expand Down
64 changes: 30 additions & 34 deletions src/typeschema/core/profile-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import {
concatIdentifiers,
type ExtensionSubField,
type Identifier,
type Name,
type ProfileExtension,
type ProfileIdentifier,
type RichFHIRSchema,
} from "@typeschema/types";

import { buildFieldType } from "./field-builder";
import { mkIdentifier } from "./identifier";

const extractExtensionValueTypes = (
const extractExtensionValueFieldTypes = (
register: Register,
fhirSchema: RichFHIRSchema,
extensionUrl: CanonicalUrl,
Expand All @@ -29,14 +30,14 @@ const extractExtensionValueTypes = (
const extensionSchema = register.resolveFs(fhirSchema.package_meta, extensionUrl);
if (!extensionSchema?.elements) return undefined;

const valueTypes: Identifier[] = [];
const valueFieldTypes: Identifier[] = [];
for (const [key, element] of Object.entries(extensionSchema.elements)) {
if (element.choiceOf !== "value" && !key.startsWith("value")) continue;
const fieldType = buildFieldType(register, extensionSchema, [key], element, logger);
if (fieldType) valueTypes.push(fieldType);
if (fieldType) valueFieldTypes.push(fieldType);
}

return concatIdentifiers(valueTypes);
return concatIdentifiers(valueFieldTypes);
};

const extractLegacySubExtensions = (
Expand All @@ -63,15 +64,19 @@ const extractLegacySubExtensions = (
subExtensions.push({
name: sliceName,
url: element.url ?? sliceName,
valueType,
valueFieldType: valueType,
min: element.min,
max: element.max !== undefined ? String(element.max) : undefined,
});
}
return subExtensions;
};

const extractSlicingSubExtensions = (extensionSchema: RichFHIRSchema): ExtensionSubField[] => {
const extractSlicingSubExtensions = (
register: Register,
extensionSchema: RichFHIRSchema,
logger?: CodegenLog,
): ExtensionSubField[] => {
const subExtensions: ExtensionSubField[] = [];
const extensionElement = extensionSchema.elements?.extension as any;
const slices = extensionElement?.slicing?.slices;
Expand All @@ -86,22 +91,14 @@ const extractSlicingSubExtensions = (extensionSchema: RichFHIRSchema): Extension
for (const [elemKey, elemValue] of Object.entries(schema.elements ?? {})) {
const elem = elemValue as any;
if (elem.choiceOf !== "value" && !elemKey.startsWith("value")) continue;
if (elem.type) {
valueType = {
kind: "complex-type" as const,
package: extensionSchema.package_meta.name,
version: extensionSchema.package_meta.version,
name: elem.type as any,
url: `http://hl7.org/fhir/StructureDefinition/${elem.type}` as CanonicalUrl,
};
break;
}
valueType = buildFieldType(register, extensionSchema, [elemKey], elem, logger);
if (valueType) break;
}

subExtensions.push({
name: sliceName,
url: slice.match?.url ?? sliceName,
valueType,
valueFieldType: valueType,
min: schema._required ? 1 : (schema.min ?? 0),
// biome-ignore lint/style/noNestedTernary : okay here
max: schema.max !== undefined ? String(schema.max) : schema.array ? "*" : "1",
Expand All @@ -120,7 +117,7 @@ const extractSubExtensions = (
if (!extensionSchema?.elements) return undefined;

const legacySubs = extractLegacySubExtensions(register, extensionSchema, logger);
const slicingSubs = extractSlicingSubExtensions(extensionSchema);
const slicingSubs = extractSlicingSubExtensions(register, extensionSchema, logger);
const subExtensions = [...legacySubs, ...slicingSubs];

return subExtensions.length > 0 ? subExtensions : undefined;
Expand All @@ -135,7 +132,7 @@ export const extractProfileExtensions = (

const addExtensionEntry = (path: string[], name: string, schema: FHIRSchemaElement) => {
let url = schema.url as CanonicalUrl | undefined;
let valueTypes = url ? extractExtensionValueTypes(register, fhirSchema, url, logger) : undefined;
let valueFieldTypes = url ? extractExtensionValueFieldTypes(register, fhirSchema, url, logger) : undefined;
const subExtensions = url ? extractSubExtensions(register, fhirSchema, url, logger) : undefined;

// For extension profiles, sub-extension entries may lack a url.
Expand All @@ -144,33 +141,32 @@ export const extractProfileExtensions = (
const sliceSchema = (fhirSchema.elements?.extension as any)?.slicing?.slices?.[name]?.schema;
if (sliceSchema) {
url = (sliceSchema.elements?.url?.fixed?.value ?? name) as CanonicalUrl;
for (const [_elemKey, elemValue] of Object.entries(sliceSchema.elements ?? {})) {
const elem = elemValue as { choiceOf?: string; type?: string };
if (elem.choiceOf === "value" && elem.type) {
valueTypes = [
{
kind: "complex-type" as const,
package: fhirSchema.package_meta.name,
version: fhirSchema.package_meta.version,
name: elem.type as Name,
url: `http://hl7.org/fhir/StructureDefinition/${elem.type}` as CanonicalUrl,
},
];
break;
for (const [elemKey, elemValue] of Object.entries(sliceSchema.elements ?? {})) {
const elem = elemValue as FHIRSchemaElement;
if (elem.choiceOf === "value" || elemKey.startsWith("value")) {
const ft = buildFieldType(register, fhirSchema, [elemKey], elem, logger);
if (ft) {
valueFieldTypes = [ft];
break;
}
}
}
}
}

const isComplex = subExtensions && subExtensions.length > 0;
const extFs = url ? register.resolveFs(fhirSchema.package_meta, url) : undefined;
const profile = extFs ? (mkIdentifier(extFs) as ProfileIdentifier) : undefined;

extensions.push({
name,
path: [...path, "extension"].join("."),
url,
profile,
min: schema.min,
max: schema.max !== undefined ? String(schema.max) : undefined,
mustSupport: schema.mustSupport,
valueTypes,
valueFieldTypes,
subExtensions,
isComplex,
});
Expand Down
7 changes: 4 additions & 3 deletions src/typeschema/core/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { CodegenLog } from "@root/utils/log";
import type { Register } from "@typeschema/register";
import {
concatIdentifiers,
extractExtensionDeps,
type Field,
type Identifier,
isNestedIdentifier,
Expand Down Expand Up @@ -139,16 +140,16 @@ function transformFhirSchemaResource(

const extensions =
fhirSchema.derivation === "constraint" ? extractProfileExtensions(register, fhirSchema, logger) : undefined;
const extensionDeps = extensions?.flatMap((ext) => ext.valueTypes ?? []) ?? [];
const dependencies = extractDependencies(identifier, base, fields, nested);
const extensionDeps = extensions?.flatMap(extractExtensionDeps);
const dependencies = concatIdentifiers(extractDependencies(identifier, base, fields, nested), extensionDeps);

const typeSchema: TypeSchema = {
identifier,
base,
fields,
nested,
description: fhirSchema.description,
dependencies: concatIdentifiers(dependencies, extensionDeps),
dependencies,
extensions,
};

Expand Down
Loading
Loading