From f032c8e7dac545a5c15300b12b165ca955f5571a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Belli?= Date: Fri, 31 Jul 2026 10:17:19 +0200 Subject: [PATCH] fix: handle required-only allOf constraints Translate exact required-only allOf members through the existing required-property flow while preserving unsafe, annotated, polymorphic, and transformed schemas. Fixes #1474 Related to #1520 and #2570 --- .changeset/clean-allof-required.md | 5 + .../examples/digital-ocean-api.ts | 24 +- .../src/transform/schema-object.ts | 186 ++- .../schema-object/composition.test.ts | 1128 +++++++++++++++++ 4 files changed, 1320 insertions(+), 23 deletions(-) create mode 100644 .changeset/clean-allof-required.md diff --git a/.changeset/clean-allof-required.md b/.changeset/clean-allof-required.md new file mode 100644 index 000000000..6c594c6b2 --- /dev/null +++ b/.changeset/clean-allof-required.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": patch +--- + +Generate required composed properties from required-only `allOf` members instead of intersecting them with an empty object type. diff --git a/packages/openapi-typescript/examples/digital-ocean-api.ts b/packages/openapi-typescript/examples/digital-ocean-api.ts index b0f18c5e8..aecb4ed4a 100644 --- a/packages/openapi-typescript/examples/digital-ocean-api.ts +++ b/packages/openapi-typescript/examples/digital-ocean-api.ts @@ -7065,7 +7065,7 @@ export interface components { total?: number; }; meta: { - meta: components["schemas"]["meta_properties"] & unknown; + meta: WithRequired; }; region: { /** @@ -11632,15 +11632,15 @@ export interface components { */ tag?: string | null; }; - domain_record_a: components["schemas"]["domain_record"] & unknown; - domain_record_aaaa: components["schemas"]["domain_record"] & unknown; - domain_record_caa: components["schemas"]["domain_record"] & unknown; - domain_record_cname: components["schemas"]["domain_record"] & unknown; - domain_record_mx: components["schemas"]["domain_record"] & unknown; - domain_record_ns: components["schemas"]["domain_record"] & unknown; - domain_record_soa: components["schemas"]["domain_record"] & unknown; - domain_record_srv: components["schemas"]["domain_record"] & unknown; - domain_record_txt: components["schemas"]["domain_record"] & unknown; + domain_record_a: WithRequired; + domain_record_aaaa: WithRequired; + domain_record_caa: WithRequired; + domain_record_cname: WithRequired; + domain_record_mx: WithRequired; + domain_record_ns: WithRequired; + domain_record_soa: WithRequired; + domain_record_srv: WithRequired; + domain_record_txt: WithRequired; disk_info: { /** * @description The type of disk. All Droplets contain a `local` disk. Additionally, GPU Droplets can also have a `scratch` disk for non-persistent data. @@ -12929,7 +12929,7 @@ export interface components { */ type: "assign"; }; - floating_ip_action_unassign: Omit & Record & { + floating_ip_action_unassign: Omit, "type"> & { /** * @description discriminator enum property added by openapi-typescript * @enum {string} @@ -14897,7 +14897,7 @@ export interface components { */ type: "assign"; }; - reserved_ip_action_unassign: Omit & Record & { + reserved_ip_action_unassign: Omit, "type"> & { /** * @description discriminator enum property added by openapi-typescript * @enum {string} diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index caab5e10f..df67ef64e 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -230,9 +230,20 @@ export function transformSchemaObjectWithComposition( } /** Collect allOf with Omit<> for discriminators */ - function collectAllOfCompositions(items: (SchemaObject | ReferenceObject)[], required?: string[]): ts.TypeNode[] { + function collectAllOfCompositions( + items: (SchemaObject | ReferenceObject)[], + required?: string[], + translatedRequiredConstraintItems?: Set, + discoverRequiredKeys = false, + constraintRequiredKeys?: Set, + ): ts.TypeNode[] { const output: ts.TypeNode[] = []; for (const item of items) { + if (translatedRequiredConstraintItems?.has(item)) { + // Its required assertion is represented through the normal parent-required flow below. + continue; + } + let itemType: ts.TypeNode; // if this is a $ref, use WithRequired if parent specifies required properties // (but only for valid keys) @@ -240,17 +251,22 @@ export function transformSchemaObjectWithComposition( itemType = transformSchemaObject(item, options); const resolved = options.ctx.resolve(item.$ref); + const discriminator = options.ctx.discriminators.objects[item.$ref]; + const refHandled = options.ctx.discriminators.refsHandled.includes(item.$ref); // make keys required, if necessary - if ( - resolved && - typeof resolved === "object" && - "properties" in resolved && - // we have already handled this item (discriminator property was already added as required) - !options.ctx.discriminators.refsHandled.includes(item.$ref) - ) { + if (resolved && typeof resolved === "object") { + // refsHandled means the discriminator key was already patched as required. Preserve that + // behavior, but still apply newly translated, non-discriminator requirements before Omit<>. + const candidateRequired = refHandled + ? (required ?? []).filter((key) => constraintRequiredKeys?.has(key) && key !== discriminator?.propertyName) + : (required ?? []); // add WithRequired if necessary - const validRequired = (required ?? []).filter((key) => !!resolved.properties?.[key]); + const validRequired = discoverRequiredKeys + ? candidateRequired.filter((key) => collectSchemaObjectPropertyNames(resolved).has(key)) + : "properties" in resolved + ? candidateRequired.filter((key) => !!resolved.properties?.[key]) + : []; if (validRequired.length) { itemType = tsWithRequired(itemType, validRequired, options.ctx.injectFooter); } @@ -278,10 +294,55 @@ export function transformSchemaObjectWithComposition( // compile final type let finalType: ts.TypeNode | undefined; + const translatedRequiredConstraintItems = new Set(); + const constraintRequiredKeys: string[] = []; + // Hooks and filtering can replace, rename, or remove generated keys. Compositions and early-return + // schemas can produce unions/literals instead of objects. In either case raw-key inference is unsafe, + // so retain the original allOf member and its existing transformation semantics. + const canTranslateRequiredConstraints = + !options.ctx.transform && + !options.ctx.postTransform && + !options.ctx.transformProperty && + !options.ctx.excludeDeprecated && + !hasUnsafeRequiredComposition(schemaObject); + if (canTranslateRequiredConstraints) { + const requiredConstraintItems = (schemaObject.allOf ?? []).flatMap((item) => { + const required = getRequiredConstraintKeys(item); + return required ? [{ item, required }] : []; + }); + const requiredConstraintSchemas = new Set(requiredConstraintItems.map(({ item }) => item)); + const knownKeys = collectSchemaObjectPropertyNames(schemaObject); + const hasRetainedObjectAssertion = (schemaObject.allOf ?? []).some( + (item) => !requiredConstraintSchemas.has(item) && hasExplicitNonNullableObjectType(item), + ); + for (const { item, required } of requiredConstraintItems) { + // Removing the member is safe only when every assertion can be represented. A typed constraint + // also needs another retained object assertion; otherwise removing type: object widens the schema. + if (!required.every((key) => knownKeys.has(key)) || ("type" in item && !hasRetainedObjectAssertion)) { + continue; + } + translatedRequiredConstraintItems.add(item); + constraintRequiredKeys.push(...required); + } + } + const combinedRequired = constraintRequiredKeys.length + ? [...new Set([...(schemaObject.required ?? []), ...constraintRequiredKeys])] + : schemaObject.required; + // Feed translated keys through existing core/inline/ref required handling. Wrapping the completed + // aggregate could pass keys not shared by a union or keys removed by later transformations to WithRequired. // core + allOf: intersect - const coreObjectType = transformSchemaObjectCore(schemaObject, options); - const allOfType = collectAllOfCompositions(schemaObject.allOf ?? [], schemaObject.required); + const coreObjectType = transformSchemaObjectCore( + constraintRequiredKeys.length ? { ...schemaObject, required: combinedRequired } : schemaObject, + options, + ); + const allOfType = collectAllOfCompositions( + schemaObject.allOf ?? [], + combinedRequired, + translatedRequiredConstraintItems, + constraintRequiredKeys.length > 0, + new Set(constraintRequiredKeys), + ); if (coreObjectType || allOfType.length) { const allOf: ts.TypeNode | undefined = allOfType.length ? tsIntersection(allOfType) : undefined; finalType = tsIntersection([...(coreObjectType ? [coreObjectType] : []), ...(allOf ? [allOf] : [])]); @@ -324,6 +385,109 @@ export function transformSchemaObjectWithComposition( } return finalType; + + function collectSchemaObjectPropertyNames( + schema: SchemaObject | ReferenceObject, + propertyNames = new Set(), + refsSeen = new Set(), + ): Set { + // Required properties may be declared behind nested allOf refs, so follow them recursively while + // guarding cycles. Explicit primitives ignore properties during transformation and cannot supply keys. + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + return propertyNames; + } + if ("$ref" in schema) { + if (refsSeen.has(schema.$ref)) { + return propertyNames; + } + refsSeen.add(schema.$ref); + const resolved = options.ctx.resolve(schema.$ref); + if (resolved) { + collectSchemaObjectPropertyNames(resolved, propertyNames, refsSeen); + } + return propertyNames; + } + if ("properties" in schema && schema.properties && (!("type" in schema) || schema.type === "object")) { + for (const key of Object.keys(schema.properties)) { + propertyNames.add(key); + } + } + for (const item of schema.allOf ?? []) { + collectSchemaObjectPropertyNames(item, propertyNames, refsSeen); + } + return propertyNames; + } + + function hasExplicitNonNullableObjectType( + schema: SchemaObject | ReferenceObject, + refsSeen = new Set(), + ): boolean { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + return false; + } + if ("$ref" in schema) { + if (refsSeen.has(schema.$ref)) { + return false; + } + refsSeen.add(schema.$ref); + const resolved = options.ctx.resolve(schema.$ref); + return resolved ? hasExplicitNonNullableObjectType(resolved, refsSeen) : false; + } + if (schema.nullable || (Array.isArray(schema.type) && schema.type.includes("null"))) { + return false; + } + if (schema.type === "object") { + return true; + } + return (schema.allOf ?? []).some((item) => hasExplicitNonNullableObjectType(item, refsSeen)); + } + + function hasUnsafeRequiredComposition(schema: SchemaObject | ReferenceObject, refsSeen = new Set()): boolean { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + return false; + } + if ("$ref" in schema) { + if (refsSeen.has(schema.$ref)) { + return false; + } + refsSeen.add(schema.$ref); + const resolved = options.ctx.resolve(schema.$ref); + return resolved ? hasUnsafeRequiredComposition(resolved, refsSeen) : false; + } + // These paths return literals/unions or apply nullable/composition semantics before object properties + // can be trusted. Raw property names therefore cannot safely parameterize WithRequired. + if ( + (schema.const !== null && schema.const !== undefined) || + (Array.isArray(schema.enum) && (!("type" in schema) || schema.type !== "object") && !("properties" in schema)) || + schema.nullable || + Array.isArray(schema.type) || + schema.anyOf || + schema.oneOf || + (schema.type === "object" && schema.enum) + ) { + return true; + } + return (schema.allOf ?? []).some((item) => hasUnsafeRequiredComposition(item, refsSeen)); + } +} + +function getRequiredConstraintKeys(schema: SchemaObject | ReferenceObject): string[] | undefined { + // Translate only exact { required } or { type: "object", required } members. Any other keyword may + // carry validation or annotation semantics that cannot be preserved after removing the allOf member. + if ( + !schema || + typeof schema !== "object" || + Array.isArray(schema) || + "$ref" in schema || + ("type" in schema && schema.type !== "object") || + !Array.isArray(schema.required) || + schema.required.length === 0 || + !schema.required.every((key) => typeof key === "string") || + Object.keys(schema).some((key) => key !== "type" && key !== "required") + ) { + return undefined; + } + return schema.required; } /** diff --git a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts index 72e8cebb8..e0eab039b 100644 --- a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts @@ -1,4 +1,5 @@ import { fileURLToPath } from "node:url"; +import ts from "typescript"; import { astToString } from "../../../src/lib/ts.js"; import transformSchemaObject from "../../../src/transform/schema-object.js"; import { DEFAULT_CTX, type TestCase } from "../../test-helpers.js"; @@ -580,6 +581,636 @@ describe("composition", () => { }, }, ], + [ + "allOf > Scramble object required constraint", + { + given: { + allOf: [{ $ref: "#/components/schemas/Base" }, { type: "object", required: ["required_string"] }], + }, + want: `WithRequired`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { + required_string: { type: "string" }, + optional_number: { type: "number" }, + }, + }, + }), + }, + ], + [ + "allOf > annotations prevent required constraint classification", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { + required: ["required_string"], + title: "Required base fields", + description: "This annotation does not change validation.", + $comment: "Required in this use of Base.", + }, + ], + }, + want: `components["schemas"]["Base"] & unknown`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > required constraint across composed members", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { $ref: "#/components/schemas/Details" }, + { + type: "object", + properties: { inline_flag: { type: "boolean" } }, + }, + { + required: ["required_string", "detail_id", "inline_flag"], + }, + ], + }, + want: `WithRequired & WithRequired & { + inline_flag: boolean; +}`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + Details: { + type: "object", + properties: { detail_id: { type: "number" } }, + }, + }), + }, + ], + [ + "allOf > parent and constraint required are combined and deduplicated", + { + given: { + required: ["optional_number", "required_string"], + allOf: [{ $ref: "#/components/schemas/Base" }, { type: "object", required: ["required_string"] }], + }, + want: `WithRequired`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { + required_string: { type: "string" }, + optional_number: { type: "number" }, + }, + }, + }), + }, + ], + [ + "allOf > required constraint applies to core and ref properties", + { + given: { + type: "object", + properties: { core_id: { type: "string" } }, + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["core_id", "required_string"] }], + }, + want: `{ + core_id: string; +} & WithRequired`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > required constraint discovers nested ref properties", + { + given: { + allOf: [{ $ref: "#/components/schemas/Derived" }, { required: ["required_string"] }], + }, + want: `WithRequired`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + Derived: { + allOf: [{ $ref: "#/components/schemas/Base" }], + }, + }), + }, + ], + [ + "allOf > required constraint discovers properties through a recursive ref cycle", + { + given: { + allOf: [{ $ref: "#/components/schemas/RecursiveA" }, { type: "object", required: ["recursive_key"] }], + }, + want: `WithRequired`, + options: optionsWithSchemas({ + RecursiveA: { + allOf: [{ $ref: "#/components/schemas/RecursiveB" }], + }, + RecursiveB: { + type: "object", + properties: { recursive_key: { type: "string" } }, + allOf: [{ $ref: "#/components/schemas/RecursiveA" }], + }, + }), + }, + ], + [ + "allOf > multiple required constraints deduplicate keys", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { required: ["required_string"] }, + { required: ["optional_number", "required_string"] }, + ], + }, + want: `WithRequired`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { + required_string: { type: "string" }, + optional_number: { type: "number" }, + }, + }, + }), + }, + ], + [ + "allOf > typed required constraints do not prove object-likeness for each other", + { + given: { + allOf: [ + { + properties: { + first: { type: "string" }, + second: { type: "string" }, + }, + }, + { type: "object", required: ["first"] }, + { type: "object", required: ["second"] }, + ], + }, + want: `{ + first?: string; + second?: string; +} & Record & Record`, + }, + ], + [ + "allOf > typed required constraint does not erase a primitive composition", + { + given: { + allOf: [ + { + type: "string", + properties: { required_string: { type: "string" } }, + }, + { type: "object", required: ["required_string"] }, + ], + }, + want: `string & Record`, + }, + ], + [ + "allOf > typed required constraint with unknown key preserves its object assertion", + { + given: { + allOf: [{ $ref: "#/components/schemas/Base" }, { type: "object", required: ["unknown_property"] }], + }, + want: `components["schemas"]["Base"] & Record`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > untyped required constraint with mixed known and unknown keys is preserved", + { + given: { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["required_string", "unknown_property"] }], + }, + want: `components["schemas"]["Base"] & unknown`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > typed required constraint with mixed known and unknown keys is preserved", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { type: "object", required: ["required_string", "unknown_property"] }, + ], + }, + want: `components["schemas"]["Base"] & Record`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > polymorphic type array ref prevents untyped constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Polymorphic" }, { required: ["required_string"] }], + }, + want: `components["schemas"]["Polymorphic"] & unknown`, + options: optionsWithSchemas({ + Polymorphic: { + type: ["object", "string"], + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > polymorphic type array ref prevents typed constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Polymorphic" }, { type: "object", required: ["required_string"] }], + }, + want: `components["schemas"]["Polymorphic"] & Record`, + options: optionsWithSchemas({ + Polymorphic: { + type: ["object", "string"], + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > primitive ref properties do not make required keys discoverable", + { + given: { + allOf: [{ $ref: "#/components/schemas/Primitive" }, { required: ["required_string"] }], + }, + want: `components["schemas"]["Primitive"] & unknown`, + options: optionsWithSchemas({ + Primitive: { + type: "string", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > const object ref prevents untyped constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Constant" }, { required: ["required_string"] }], + }, + want: `components["schemas"]["Constant"] & unknown`, + options: optionsWithSchemas({ + Constant: { + type: "object", + const: {}, + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > const object ref prevents typed constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Constant" }, { type: "object", required: ["required_string"] }], + }, + want: `components["schemas"]["Constant"] & Record`, + options: optionsWithSchemas({ + Constant: { + type: "object", + const: {}, + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > early-return enum ref prevents constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Enumerated" }, { required: ["required_string"] }], + }, + want: `components["schemas"]["Enumerated"] & unknown`, + options: optionsWithSchemas({ + Enumerated: { + enum: ["fixed"], + allOf: [ + { + type: "object", + properties: { required_string: { type: "string" } }, + }, + ], + }, + }), + }, + ], + [ + "allOf > nullable object ref does not prove typed constraint object-likeness", + { + given: { + allOf: [{ $ref: "#/components/schemas/NullableBase" }, { type: "object", required: ["required_string"] }], + }, + want: `components["schemas"]["NullableBase"] & Record`, + options: optionsWithSchemas({ + NullableBase: { + type: ["object", "null"], + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > outer anyOf prevents required constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["required_string"] }], + anyOf: [{ type: "string" }], + }, + want: `(components["schemas"]["Base"] & unknown) | string`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > outer oneOf prevents required constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["required_string"] }], + oneOf: [{ type: "object", properties: { variant: { type: "string" } } }], + }, + want: `(components["schemas"]["Base"] & unknown) & { + variant?: string; +}`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > excludeDeprecated prevents required constraint translation", + { + given: { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["deprecated_key"] }], + }, + want: `components["schemas"]["Base"] & unknown`, + options: { + ...optionsWithSchemas({ + Base: { + type: "object", + properties: { deprecated_key: { type: "string", deprecated: true } }, + }, + }), + ctx: { + ...optionsWithSchemas({ + Base: { + type: "object", + properties: { deprecated_key: { type: "string", deprecated: true } }, + }, + }).ctx, + excludeDeprecated: true, + }, + }, + }, + ], + [ + "allOf > explicit additionalProperties false is not a required-only constraint", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { + type: "object", + required: ["required_string"], + additionalProperties: false, + }, + ], + }, + want: `components["schemas"]["Base"] & Record`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > member with properties remains an ordinary intersection", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { + type: "object", + required: ["inline_flag"], + properties: { inline_flag: { type: "boolean" } }, + }, + ], + }, + want: `components["schemas"]["Base"] & { + inline_flag: boolean; +}`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > member with another validation keyword remains an ordinary intersection", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { + type: "object", + required: ["required_string"], + minProperties: 1, + }, + ], + }, + want: `components["schemas"]["Base"] & Record`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > nullable object type union is not a required-only constraint", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { + type: ["object", "null"], + required: ["required_string"], + }, + ], + }, + want: `components["schemas"]["Base"] & (Record | null)`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > vendor extension prevents required constraint classification", + { + given: { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { + required: ["required_string"], + "x-validation-behavior": true, + }, + ], + }, + want: `components["schemas"]["Base"] & unknown`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "allOf > unknown required constraint name preserves the member", + { + given: { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["unknown_property"] }], + }, + want: `components["schemas"]["Base"] & unknown`, + options: optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + }, + ], + [ + "discriminator > allOf required constraint uses ref required handling before Omit", + { + given: { + type: "object", + allOf: [{ $ref: "#/components/schemas/parent" }, { type: "object", required: ["name"] }], + }, + want: `{ + operation: "test"; +} & Omit, "operation">`, + options: { + ...DEFAULT_OPTIONS, + ctx: { + ...DEFAULT_OPTIONS.ctx, + discriminators: { + objects: { + [DEFAULT_OPTIONS.path]: { + propertyName: "operation", + mapping: { test: DEFAULT_OPTIONS.path }, + }, + "#/components/schemas/parent": { + propertyName: "operation", + mapping: { test: DEFAULT_OPTIONS.path }, + }, + }, + refsHandled: [], + }, + resolve($ref) { + if ($ref === "#/components/schemas/parent") { + return { + type: "object", + properties: { + operation: { type: "string" }, + name: { type: "string" }, + }, + }; + } + return undefined as any; + }, + }, + }, + }, + ], + [ + "discriminator > handled ref keeps translated non-discriminator required key", + { + given: { + allOf: [{ $ref: "#/components/schemas/parent" }, { required: ["name"] }], + }, + want: `Omit, "operation">`, + options: { + ...DEFAULT_OPTIONS, + ctx: { + ...DEFAULT_OPTIONS.ctx, + discriminators: { + objects: { + "#/components/schemas/parent": { + propertyName: "operation", + mapping: { test: DEFAULT_OPTIONS.path }, + }, + }, + refsHandled: ["#/components/schemas/parent"], + }, + resolve($ref) { + if ($ref === "#/components/schemas/parent") { + return { + type: "object", + required: ["operation"], + properties: { + operation: { type: "string", enum: ["test"] }, + name: { type: "string" }, + }, + }; + } + return undefined as any; + }, + }, + }, + }, + ], [ "anyOf > basic", { @@ -628,4 +1259,501 @@ describe("composition", () => { ci?.timeout, ); } + + for (const [keyword, value] of [ + ["default", { required_string: "default" }], + ["readOnly", true], + ["writeOnly", true], + ["deprecated", true], + ["examples", [{ required_string: "example" }]], + ["example", { required_string: "legacy example" }], + ] as const) { + test(`allOf > ${keyword} prevents required constraint classification`, () => { + const result = astToString( + transformSchemaObject( + { + allOf: [ + { $ref: "#/components/schemas/Base" }, + { + type: "object", + required: ["required_string"], + [keyword]: value, + }, + ], + } as any, + optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + ), + ); + + expect(result).toBe('components["schemas"]["Base"] & Record\n'); + }); + } + + test("allOf > transform callback replacement prevents constraint translation", () => { + let transformCalls = 0; + let postTransformCalls = 0; + const options = optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }); + options.ctx.transform = () => { + transformCalls++; + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); + }; + options.ctx.postTransform = () => void postTransformCalls++; + + const result = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Base" }, { type: "object", required: ["required_string"] }], + } as any, + options, + ), + ); + + expect(result).toBe('components["schemas"]["Base"] & number\n'); + expect(transformCalls).toBe(1); + expect(postTransformCalls).toBe(3); + }); + + test("allOf > postTransform callback replacement prevents constraint translation", () => { + const options = optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }); + options.ctx.postTransform = (type) => + ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && type.typeName.text === "Record" + ? ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword) + : undefined; + + const result = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Base" }, { type: "object", required: ["required_string"] }], + } as any, + options, + ), + ); + + expect(result).toBe('components["schemas"]["Base"] & number\n'); + }); + + test("allOf > generated required constraint types compile", () => { + const scrambleOptions = optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }); + const scramble = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Base" }, { type: "object", required: ["required_string"] }], + } as any, + scrambleOptions, + ), + ).trim(); + expectTypeScriptToCompile(` + type WithRequired = T & { [P in K]-?: T[P] }; + interface components { schemas: { Base: { required_string?: string } } } + type Generated = ${scramble}; + const valid: Generated = { required_string: "value" }; + // @ts-expect-error required_string is required + const invalid: Generated = {}; + `); + + const untyped = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["required_string"] }], + } as any, + optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + ), + ).trim(); + expectTypeScriptToCompile(` + type WithRequired = T & { [P in K]-?: T[P] }; + interface components { schemas: { Base: { required_string?: string } } } + type Generated = ${untyped}; + const valid: Generated = { required_string: "value" }; + // @ts-expect-error required_string is required + const invalid: Generated = {}; + `); + + const nullableOptions = optionsWithSchemas({ + NullableBase: { + type: ["object", "null"], + properties: { required_string: { type: "string" } }, + }, + }); + const nullable = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/NullableBase" }, { type: "object", required: ["required_string"] }], + } as any, + nullableOptions, + ), + ).trim(); + expectTypeScriptToCompile(` + interface components { schemas: { NullableBase: { required_string?: string } | null } } + type Generated = ${nullable}; + // @ts-expect-error the retained typed constraint remains unassignable + const invalid: Generated = { required_string: "value" }; + `); + + const deprecatedOptions = optionsWithSchemas({ + Base: { + type: "object", + properties: { deprecated_key: { type: "string", deprecated: true } }, + }, + }); + deprecatedOptions.ctx.excludeDeprecated = true; + const deprecated = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["deprecated_key"] }], + } as any, + deprecatedOptions, + ), + ).trim(); + expectTypeScriptToCompile(` + interface components { schemas: { Base: Record } } + type Generated = ${deprecated}; + const valid: Generated = {}; + `); + + const transformPropertyOptions = { ...DEFAULT_OPTIONS, ctx: { ...DEFAULT_OPTIONS.ctx } }; + transformPropertyOptions.ctx.transformProperty = (property) => + ts.factory.updatePropertySignature( + property, + property.modifiers, + ts.factory.createIdentifier("renamed"), + property.questionToken, + property.type, + ); + const renamed = astToString( + transformSchemaObject( + { + allOf: [{ type: "object", properties: { original: { type: "string" } } }, { required: ["original"] }], + } as any, + transformPropertyOptions, + ), + ).trim(); + expect(renamed).toBe(`{ + renamed?: string; +} & unknown`); + expectTypeScriptToCompile(` + type Generated = ${renamed}; + const valid: Generated = { renamed: "value" }; + `); + + const union = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Base" }, { required: ["required_string"] }], + anyOf: [{ type: "string" }], + } as any, + optionsWithSchemas({ + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }), + ), + ).trim(); + expectTypeScriptToCompile(` + interface components { schemas: { Base: { required_string?: string } } } + type Generated = ${union}; + const objectValue: Generated = {}; + const stringValue: Generated = "value"; + `); + + const polymorphicOptions = optionsWithSchemas({ + Polymorphic: { + type: ["object", "string"], + properties: { required_string: { type: "string" } }, + }, + }); + const polymorphic = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Polymorphic" }, { required: ["required_string"] }], + } as any, + polymorphicOptions, + ), + ).trim(); + expect(polymorphic).toBe('components["schemas"]["Polymorphic"] & unknown'); + expectTypeScriptToCompile(` + interface components { schemas: { Polymorphic: { required_string?: string } | string } } + type Generated = ${polymorphic}; + const objectValue: Generated = {}; + const stringValue: Generated = "value"; + `); + + const typedPolymorphic = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/Polymorphic" }, { type: "object", required: ["required_string"] }], + } as any, + polymorphicOptions, + ), + ).trim(); + expect(typedPolymorphic).toBe('components["schemas"]["Polymorphic"] & Record'); + expectTypeScriptToCompile(` + interface components { schemas: { Polymorphic: { required_string?: string } | string } } + type Generated = ${typedPolymorphic}; + // @ts-expect-error the retained object constraint remains unassignable + const invalid: Generated = { required_string: "value" }; + `); + + const handledDiscriminatorOptions = optionsWithSchemas({ + parent: { + type: "object", + required: ["operation"], + properties: { + operation: { type: "string", enum: ["test"] }, + name: { type: "string" }, + }, + }, + }); + handledDiscriminatorOptions.ctx.discriminators = { + objects: { + "#/components/schemas/parent": { + propertyName: "operation", + mapping: { test: DEFAULT_OPTIONS.path }, + }, + }, + refsHandled: ["#/components/schemas/parent"], + }; + const handledDiscriminator = astToString( + transformSchemaObject( + { + allOf: [{ $ref: "#/components/schemas/parent" }, { required: ["name"] }], + } as any, + handledDiscriminatorOptions, + ), + ).trim(); + expectTypeScriptToCompile(` + type WithRequired = T & { [P in K]-?: T[P] }; + interface components { schemas: { parent: { operation: "test"; name?: string } } } + type Generated = ${handledDiscriminator}; + const valid: Generated = { name: "value" }; + // @ts-expect-error name is required after omitting the discriminator property + const invalid: Generated = {}; + `); + }); + + test("allOf > generated components and constrained types compile together", () => { + const baseSchemas = { + Base: { + type: "object", + properties: { required_string: { type: "string" } }, + }, + }; + const scramble = generateComponentsAndConstrainedType(baseSchemas, { + allOf: [{ $ref: "#/components/schemas/Base" }, { type: "object", required: ["required_string"] }], + }); + expectTypeScriptToCompile(`${scramble.source} + const valid: Generated = { required_string: "value" }; + // @ts-expect-error required_string is required + const invalid: Generated = {}; + `); + + const constantSchemas = { + Constant: { + type: "object", + const: {}, + properties: { required_string: { type: "string" } }, + }, + }; + for (const constraint of [{ required: ["required_string"] }, { type: "object", required: ["required_string"] }]) { + const constant = generateComponentsAndConstrainedType(constantSchemas, { + allOf: [{ $ref: "#/components/schemas/Constant" }, constraint], + }); + expect(constant.generated).not.toContain("WithRequired"); + expectTypeScriptToCompile(`${constant.source} + type Check = Generated; + `); + } + + const enumerated = generateComponentsAndConstrainedType( + { + Enumerated: { + enum: ["fixed"], + allOf: [ + { + type: "object", + properties: { required_string: { type: "string" } }, + }, + ], + }, + }, + { + allOf: [{ $ref: "#/components/schemas/Enumerated" }, { required: ["required_string"] }], + }, + ); + expect(enumerated.generated).not.toContain("WithRequired"); + expectTypeScriptToCompile(`${enumerated.source} + const valid: Generated = "fixed"; + `); + + const polymorphic = generateComponentsAndConstrainedType( + { + Polymorphic: { + type: ["object", "string"], + properties: { required_string: { type: "string" } }, + }, + }, + { + allOf: [{ $ref: "#/components/schemas/Polymorphic" }, { required: ["required_string"] }], + }, + ); + expect(polymorphic.generated).not.toContain("WithRequired"); + expectTypeScriptToCompile(`${polymorphic.source} + const objectValue: Generated = {}; + const stringValue: Generated = "value"; + `); + + const nullable = generateComponentsAndConstrainedType( + { + Nullable: { + type: ["object", "null"], + properties: { required_string: { type: "string" } }, + }, + }, + { + allOf: [{ $ref: "#/components/schemas/Nullable" }, { type: "object", required: ["required_string"] }], + }, + ); + expect(nullable.generated).not.toContain("WithRequired"); + expectTypeScriptToCompile(`${nullable.source} + type Check = Generated; + `); + + const deprecated = generateComponentsAndConstrainedType( + { + Deprecated: { + type: "object", + properties: { old: { type: "string", deprecated: true } }, + }, + }, + { + allOf: [{ $ref: "#/components/schemas/Deprecated" }, { required: ["old"] }], + }, + (options) => { + options.ctx.excludeDeprecated = true; + }, + ); + expect(deprecated.generated).not.toContain("WithRequired"); + expectTypeScriptToCompile(`${deprecated.source} + const valid: Generated = {}; + `); + + const renamed = generateComponentsAndConstrainedType( + { + Renamed: { + type: "object", + properties: { original: { type: "string" } }, + }, + }, + { + allOf: [{ $ref: "#/components/schemas/Renamed" }, { required: ["original"] }], + }, + (options) => { + options.ctx.transformProperty = (property) => + ts.factory.updatePropertySignature( + property, + property.modifiers, + ts.factory.createIdentifier("renamed"), + property.questionToken, + property.type, + ); + }, + ); + expect(renamed.generated).not.toContain("WithRequired"); + expectTypeScriptToCompile(`${renamed.source} + const valid: Generated = { renamed: "value" }; + `); + }); }); + +function optionsWithSchemas(schemas: Record) { + return { + ...DEFAULT_OPTIONS, + ctx: { + ...DEFAULT_OPTIONS.ctx, + resolve($ref: string) { + const name = $ref.startsWith("#/components/schemas/") ? $ref.slice("#/components/schemas/".length) : undefined; + return name ? schemas[name] : undefined; + }, + }, + }; +} + +function generateComponentsAndConstrainedType( + schemas: Record, + constrainedSchema: any, + configure?: (options: ReturnType) => void, +) { + const options = optionsWithSchemas(schemas); + options.ctx.injectFooter = []; + configure?.(options); + const componentTypes = Object.entries(schemas) + .map(([name, schema]) => { + const type = astToString( + transformSchemaObject(schema, { + ...options, + path: `#/components/schemas/${name}`, + }), + ).trim(); + return `${JSON.stringify(name)}: ${type};`; + }) + .join("\n"); + const generated = astToString(transformSchemaObject(constrainedSchema, options)).trim(); + const footer = astToString(options.ctx.injectFooter).trim(); + + return { + generated, + source: ` + interface components { schemas: { ${componentTypes} } } + type Generated = ${generated}; + ${footer} + `, + }; +} + +function expectTypeScriptToCompile(source: string) { + const fileName = "/required-constraint.test.ts"; + const compilerOptions: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ESNext, + }; + const host = ts.createCompilerHost(compilerOptions); + const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, true); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => + name === fileName ? sourceFile : getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile); + const fileExists = host.fileExists.bind(host); + host.fileExists = (name) => name === fileName || fileExists(name); + const readFile = host.readFile.bind(host); + host.readFile = (name) => (name === fileName ? source : readFile(name)); + + const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram([fileName], compilerOptions, host)); + expect(diagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))).toEqual([]); +}