Skip to content
8 changes: 8 additions & 0 deletions src/api/writer-generator/typescript/profile-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ export const generateValidateMethod = (
);
}

// Base-resource required fields the profile chain did not re-state.
// Emitted here (not via the regular field loop) because they intentionally
// live outside `fields` to avoid pulling unrelated base metadata into the
// profile's getter/setter surface.
for (const inheritedName of snapshot.inheritedRequiredFields ?? []) {
errors.push(`...validateRequired(res, profileName, ${JSON.stringify(inheritedName)})`);
}

const emitArray = (label: string, exprs: string[]) => {
if (exprs.length === 0) {
w.line(`${label}: [],`);
Expand Down
8 changes: 7 additions & 1 deletion src/api/writer-generator/typescript/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,13 @@ const generateProfileHelpersImport = (
imports.push("isExtension", "getExtensionValue", "pushExtension");
if (extensions.some((ext) => ext.url && ext.max === "1")) imports.push("upsertExtension");
}
if (Object.keys(snapshot.fields).length > 0)
// validate() emits calls when the profile has its own fields OR when it
// inherits base-required fields it does not re-state (those produce
// validateRequired() calls with no entry in `fields`). Without the second
// clause, a profile whose only validation is an inherited required field
// (e.g. an Extension profile relying on the base Extension.url) would emit
// validateRequired() without importing it (TS2304).
if (Object.keys(snapshot.fields).length > 0 || (snapshot.inheritedRequiredFields?.length ?? 0) > 0)
imports.push(
"validateRequired",
"validateExcluded",
Expand Down
8 changes: 8 additions & 0 deletions src/typeschema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ export interface SnapshotProfileTypeSchema {
base: TypeIdentifier;
description?: string;
fields: Record<string, Field>;
/** Top-level required field names inherited from the base resource that the
* profile chain does not re-state in its differential. Needed so that
* validate() emits validateRequired() for base-FHIR cardinality (e.g. for
* Provenance.target / .recorded) when a profile leaves those fields
* untouched. Profiles that explicitly include the field in their
* differential are NOT listed here — their constraint is already in
* `fields` and the validator handles them via the regular path. */
inheritedRequiredFields?: string[];
extensions?: ProfileExtension[];
dependencies?: TypeIdentifier[];
nested?: NestedTypeSchema[];
Expand Down
52 changes: 44 additions & 8 deletions src/typeschema/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,14 +556,18 @@ export const mkTypeSchemaIndex = (
if (!schema.fields) continue;

for (const [fieldName, fieldConstraints] of Object.entries(schema.fields)) {
if (mergedFields[fieldName]) {
mergedFields[fieldName] = {
...mergedFields[fieldName],
...fieldConstraints,
};
} else {
mergedFields[fieldName] = { ...fieldConstraints };
const merged: Field = mergedFields[fieldName]
? { ...mergedFields[fieldName], ...fieldConstraints }
: { ...fieldConstraints };
// Profile-explicit relaxation: differential lowered min to 0
// → the field is no longer required even if a base ancestor
// (or earlier profile in the chain) declared it so. Without
// this, validate() would emit validateRequired() for a field
// the leaf profile intentionally relaxed.
if ("min" in fieldConstraints && fieldConstraints.min === 0) {
(merged as { required?: boolean }).required = false;
}
mergedFields[fieldName] = merged;
}
}

Expand Down Expand Up @@ -600,11 +604,43 @@ export const mkTypeSchemaIndex = (

const buildProfileSnapshot = (schema: ProfileTypeSchema): SnapshotProfileTypeSchema => {
const flat = flatProfile(schema);
const flatFields = flat.fields ?? {};

// Inherited-required collection: top-level required fields on the base
// resource that the profile chain never re-states. Listed separately
// from `fields` so this fix only adds validateRequired() calls without
// pulling unrelated base metadata (e.g. unused value[x] variants) into
// the snapshot — which would otherwise expand profile getters/setters.
const hierarchySchemas = hierarchy(schema);
const nonConstraintSchema = hierarchySchemas.find((s) => s.identifier.kind !== "profile") as
| SpecializationTypeSchema
| undefined;
const inheritedRequiredFields: string[] = [];
if (nonConstraintSchema?.fields) {
for (const [name, baseField] of Object.entries(nonConstraintSchema.fields)) {
if (!baseField.required) continue;
const flat = flatFields[name] as { required?: boolean; min?: number } | undefined;
// Profile explicitly relaxed the field via differential min:0 →
// skip (regular validate emission also skips it because flatField
// .required was reset to false in flatProfile).
if (flat?.min === 0) continue;
// Profile (or any ancestor in the chain) restated the field with
// required:true → already emitted by the regular field loop.
if (flat?.required) continue;
// Either the profile leaves the field untouched, or the FHIRSchema
// snapshot expansion inlined the inherited element with required
// unset / false despite the base requiring it. Either way the
// emitter needs an explicit validateRequired() call.
inheritedRequiredFields.push(name);
}
}

return {
identifier: snapshotIdentifier(flat.identifier),
base: flat.base,
description: flat.description,
fields: flat.fields ?? {},
fields: flatFields,
inheritedRequiredFields: inheritedRequiredFields.length > 0 ? inheritedRequiredFields : undefined,
extensions: flat.extensions,
dependencies: flat.dependencies,
nested: flat.nested,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
// Bun Snapshot v1, https://goo.gl/fbAQLP

exports[`Local Package Folder - Multi-Package Generation TypeScript Generation should generate ExampleNotebook type in custom package folder 1`] = `
"// WARNING: This file is autogenerated by @atomic-ehr/codegen.
Expand Down Expand Up @@ -182,6 +182,7 @@ export class ExampleTypedBundleProfile {
return {
errors: [
...validateSliceCardinality(res, profileName, "entry", {"resource":{"resourceType":"Patient"}}, "PatientEntry", 1, 1),
...validateRequired(res, profileName, "type"),
],
warnings: [],
}
Expand Down
74 changes: 74 additions & 0 deletions test/api/write-generator/profile-inherited-required.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "bun:test";
import * as Path from "node:path";
import { APIBuilder } from "@root/api/builder";
import { mkSilentLogger } from "@typeschema-test/utils";

const FIXTURE_PATH = Path.join(__dirname, "../../assets/profile-inherited-required");

/**
* Regression for codegen-8iw: a profile whose differential does NOT re-state
* a base-R4 required field (Provenance.target / .recorded) must still emit
* validateRequired() for those fields in the generated validate() method.
*
* The fixture mirrors the real de.cognovis.fhir.praxis PraxisProposalProvenance
* profile: its differential touches only activity, agent.role, agent.type, and
* entity — target and recorded are inherited from base R4 Provenance.
*/
describe("Profile inherited base-required fields (codegen-8iw)", async () => {
const result = await new APIBuilder({ logger: mkSilentLogger() })
.localStructureDefinitions({
package: { name: "cognovis.test.praxis", version: "0.0.1" },
path: FIXTURE_PATH,
dependencies: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }],
})
.typescript({ inMemoryOnly: true, generateProfile: true, withDebugComment: false })
.generate();

const profileKey = Object.keys(result.filesGenerated.typescript ?? {}).find((k) =>
k.includes("Provenance_PraxisProposalProvenance"),
);
const profileFile = () => (profileKey ? result.filesGenerated.typescript![profileKey] : undefined);

it("should succeed", () => {
expect(result.success).toBeTrue();
});

it("generates the Provenance profile class", () => {
expect(profileKey).toBeDefined();
});

it("emits validateRequired() for the differential-stated required fields", () => {
const file = profileFile();
expect(file).toBeDefined();
expect(file).toContain('validateRequired(res, profileName, "activity")');
expect(file).toContain('validateRequired(res, profileName, "agent")');
});

it("emits validateRequired() for base-R4 required fields the profile inherits", () => {
const file = profileFile();
expect(file).toBeDefined();
// target and recorded are required by base R4 Provenance and are NOT
// re-stated in the profile differential — these are the fields the bug
// silently dropped before the inheritedRequiredFields fix.
expect(file).toContain('validateRequired(res, profileName, "target")');
expect(file).toContain('validateRequired(res, profileName, "recorded")');
});

// An Extension profile whose differential states no fields still inherits the
// required Extension.url. validate() must emit validateRequired("url") AND the
// generated file must import validateRequired — otherwise tsc fails with TS2304
// ("Cannot find name 'validateRequired'"). The import guard previously keyed only
// on snapshot.fields being non-empty, which is false for this profile.
const extensionKey = Object.keys(result.filesGenerated.typescript ?? {}).find((k) =>
k.includes("MinimalRequiredExtension"),
);
const extensionFile = () => (extensionKey ? result.filesGenerated.typescript![extensionKey] : undefined);

it("imports validateRequired when the only validation is an inherited required field", () => {
const file = extensionFile();
expect(file).toBeDefined();
expect(file).toContain('validateRequired(res, profileName, "url")');
// The import must be present for the emitted call to typecheck.
expect(file).toMatch(/import\s*\{[^}]*\bvalidateRequired\b/);
});
});
30 changes: 30 additions & 0 deletions test/assets/profile-inherited-required/minimal-extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"resourceType": "StructureDefinition",
"id": "minimal-required-extension",
"url": "https://example.org/StructureDefinition/minimal-required-extension",
"version": "0.0.1",
"name": "MinimalRequiredExtension",
"title": "Minimal Required Extension",
"status": "active",
"fhirVersion": "4.0.1",
"kind": "complex-type",
"abstract": false,
"context": [
{
"type": "element",
"expression": "Patient"
}
],
"type": "Extension",
"baseDefinition": "http://hl7.org/fhir/StructureDefinition/Extension",
"derivation": "constraint",
"differential": {
"element": [
{
"id": "Extension",
"path": "Extension",
"short": "Extension that re-states no fields in its differential"
}
]
}
}
65 changes: 65 additions & 0 deletions test/assets/profile-inherited-required/provenance-profile.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"resourceType": "StructureDefinition",
"id": "praxis-proposal-provenance",
"url": "https://fhir.cognovis.de/praxis/StructureDefinition/praxis-proposal-provenance",
"version": "0.68.0",
"name": "PraxisProposalProvenance",
"title": "Praxis Proposal Provenance",
"status": "active",
"date": "2026-05-23T19:13:21+02:00",
"publisher": "cognovis GmbH",
"description": "Vendor-neutral Provenance profile for clinical proposal lifecycles: software or LLM suggestions, clinician confirmations, links to existing clinical records, and direct manual entries.",
"fhirVersion": "4.0.1",
"kind": "resource",
"abstract": false,
"type": "Provenance",
"baseDefinition": "http://hl7.org/fhir/StructureDefinition/Provenance",
"derivation": "constraint",
"differential": {
"element": [
{
"id": "Provenance",
"path": "Provenance",
"constraint": [
{
"key": "provenance-source-required-for-derived",
"severity": "error",
"human": "If agent.role contains software-suggested, llm-suggested, or linked-existing, then entity must be present.",
"expression": "agent.role.coding.where(system = 'https://fhir.cognovis.de/praxis/CodeSystem/proposal-contribution-role' and (code = 'software-suggested' or code = 'llm-suggested' or code = 'linked-existing')).exists() implies entity.exists()",
"source": "https://fhir.cognovis.de/praxis/StructureDefinition/praxis-proposal-provenance"
}
]
},
{
"id": "Provenance.activity",
"path": "Provenance.activity",
"short": "FHIR DataOperation for this proposal provenance event",
"definition": "Use CREATE for proposal creation, UPDATE for confirmation or structured-resource update events, and other FHIR DataOperation codes where appropriate.",
"min": 1
},
{
"id": "Provenance.agent.type",
"path": "Provenance.agent.type",
"short": "Standard FHIR provenance participant type",
"min": 1
},
{
"id": "Provenance.agent.role",
"path": "Provenance.agent.role",
"short": "Proposal contribution role",
"definition": "Classifies the participant's contribution to the proposal lifecycle, such as LLM suggestion, deterministic software suggestion, clinician confirmation, existing-record linkage, or manual entry.",
"min": 1,
"binding": {
"strength": "required",
"valueSet": "https://fhir.cognovis.de/praxis/ValueSet/proposal-contribution-role-vs"
}
},
{
"id": "Provenance.entity",
"path": "Provenance.entity",
"short": "Source or revision input for derived proposal events",
"definition": "Source entities are optional for direct manual entry, but required by invariant for software-suggested, LLM-suggested, and linked-existing events."
}
]
}
}
Loading
Loading