Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/clean-allof-required.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 12 additions & 12 deletions packages/openapi-typescript/examples/digital-ocean-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7065,7 +7065,7 @@ export interface components {
total?: number;
};
meta: {
meta: components["schemas"]["meta_properties"] & unknown;
meta: WithRequired<components["schemas"]["meta_properties"], "total">;
};
region: {
/**
Expand Down Expand Up @@ -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<components["schemas"]["domain_record"], "type" | "name" | "data">;
domain_record_aaaa: WithRequired<components["schemas"]["domain_record"], "type" | "name" | "data">;
domain_record_caa: WithRequired<components["schemas"]["domain_record"], "type" | "name" | "data" | "flags" | "tag">;
domain_record_cname: WithRequired<components["schemas"]["domain_record"], "type" | "name" | "data">;
domain_record_mx: WithRequired<components["schemas"]["domain_record"], "type" | "data" | "priority">;
domain_record_ns: WithRequired<components["schemas"]["domain_record"], "type" | "name" | "data" | "flags" | "tag">;
domain_record_soa: WithRequired<components["schemas"]["domain_record"], "type" | "ttl">;
domain_record_srv: WithRequired<components["schemas"]["domain_record"], "type" | "name" | "data" | "priority" | "port" | "flags" | "tag">;
domain_record_txt: WithRequired<components["schemas"]["domain_record"], "type" | "name" | "data" | "flags" | "tag">;
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.
Expand Down Expand Up @@ -12929,7 +12929,7 @@ export interface components {
*/
type: "assign";
};
floating_ip_action_unassign: Omit<components["schemas"]["floatingIPsAction"], "type"> & Record<string, never> & {
floating_ip_action_unassign: Omit<WithRequired<components["schemas"]["floatingIPsAction"], "type">, "type"> & {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
Expand Down Expand Up @@ -14897,7 +14897,7 @@ export interface components {
*/
type: "assign";
};
reserved_ip_action_unassign: Omit<components["schemas"]["reserved_ip_action_type"], "type"> & Record<string, never> & {
reserved_ip_action_unassign: Omit<WithRequired<components["schemas"]["reserved_ip_action_type"], "type">, "type"> & {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
Expand Down
186 changes: 175 additions & 11 deletions packages/openapi-typescript/src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,27 +230,43 @@ 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<SchemaObject | ReferenceObject>,
discoverRequiredKeys = false,
constraintRequiredKeys?: Set<string>,
): 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<X, Y> if parent specifies required properties
// (but only for valid keys)
if ("$ref" in item) {
itemType = transformSchemaObject(item, options);

const resolved = options.ctx.resolve<SchemaObject>(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<X, Y> 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);
}
Expand Down Expand Up @@ -278,10 +294,55 @@ export function transformSchemaObjectWithComposition(

// compile final type
let finalType: ts.TypeNode | undefined;
const translatedRequiredConstraintItems = new Set<SchemaObject | ReferenceObject>();
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] : [])]);
Expand Down Expand Up @@ -324,6 +385,109 @@ export function transformSchemaObjectWithComposition(
}

return finalType;

function collectSchemaObjectPropertyNames(
schema: SchemaObject | ReferenceObject,
propertyNames = new Set<string>(),
refsSeen = new Set<string>(),
): Set<string> {
// 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<SchemaObject | ReferenceObject>(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<string>(),
): 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<SchemaObject | ReferenceObject>(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<string>()): 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<SchemaObject | ReferenceObject>(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<T, K>.
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;
}

/**
Expand Down
Loading
Loading