@@ -123,32 +123,195 @@ function normalizeAllOfSchema(
123123 } as OpenApiSchemaObject ;
124124}
125125
126+ /** True when the schema node itself is not a single object shape. */
127+ function directlyEmitsNonObjectType (
128+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
129+ ) : boolean {
130+ // Bridge assertions: AnyOtherAttribute infects all schema property access
131+ if ( schema . enum || ( schema . nullable as boolean | undefined ) === true ) {
132+ return true ;
133+ }
134+ const type = schema . type as string | string [ ] | undefined ;
135+ const isObjectType =
136+ ! type ||
137+ type === 'object' ||
138+ ( Array . isArray ( type ) && type . length === 1 && type [ 0 ] === 'object' ) ;
139+ if ( ! isObjectType ) {
140+ return true ;
141+ }
142+ return false ;
143+ }
144+
145+ function isDirectlyNullable (
146+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
147+ ) : boolean {
148+ if ( ( schema . nullable as boolean | undefined ) === true ) {
149+ return true ;
150+ }
151+ const type = schema . type as string | string [ ] | undefined ;
152+ return type === 'null' || ( Array . isArray ( type ) && type . includes ( 'null' ) ) ;
153+ }
154+
155+ function directlyEmitsOnlyObjectOrNull (
156+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
157+ ) : boolean {
158+ if ( schema . enum ) {
159+ return false ;
160+ }
161+ const type = schema . type as string | string [ ] | undefined ;
162+ const hasObjectType =
163+ ! type ||
164+ type === 'object' ||
165+ ( Array . isArray ( type ) && type . includes ( 'object' ) ) ;
166+ const hasOnlyObjectAndNull =
167+ ! type ||
168+ type === 'object' ||
169+ ( Array . isArray ( type ) &&
170+ type . every ( ( memberType ) => [ 'object' , 'null' ] . includes ( memberType ) ) ) ;
171+ return (
172+ hasObjectType &&
173+ hasOnlyObjectAndNull &&
174+ ( ( schema . nullable as boolean | undefined ) === true ||
175+ ( Array . isArray ( type ) && type . includes ( 'null' ) ) )
176+ ) ;
177+ }
178+
179+ function isEnumMember (
180+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
181+ context : ContextSpec ,
182+ ) : boolean {
183+ return resolveObject ( { schema, combined : true , context } ) . isEnum ;
184+ }
185+
186+ function hasAllEnumMembers (
187+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
188+ context : ContextSpec ,
189+ ) : boolean {
190+ if ( isReference ( schema ) ) {
191+ return false ;
192+ }
193+ const compositions = [ schema . allOf , schema . oneOf , schema . anyOf ] as (
194+ | ( OpenApiSchemaObject | OpenApiReferenceObject ) [ ]
195+ | undefined
196+ ) [ ] ;
197+ return compositions . some (
198+ ( members ) =>
199+ ! ! members ?. length &&
200+ members . every ( ( member ) => isEnumMember ( member , context ) ) ,
201+ ) ;
202+ }
203+
204+ function usesCanonicalNullableOneOfObject (
205+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
206+ ) : boolean {
207+ if ( isReference ( schema ) ) {
208+ return false ;
209+ }
210+ const members = schema . oneOf as
211+ | ( OpenApiSchemaObject | OpenApiReferenceObject ) [ ]
212+ | undefined ;
213+ if ( ! members ) {
214+ return false ;
215+ }
216+ const isNullMember = (
217+ member : OpenApiSchemaObject | OpenApiReferenceObject ,
218+ ) : boolean => {
219+ if ( isReference ( member ) ) {
220+ return false ;
221+ }
222+ const type = member . type as string | string [ ] | undefined ;
223+ return (
224+ type === 'null' ||
225+ ( Array . isArray ( type ) && type . length === 1 && type [ 0 ] === 'null' )
226+ ) ;
227+ } ;
228+ const nonNullMembers = members . filter ( ( member ) => ! isNullMember ( member ) ) ;
229+ const nonNullMember = nonNullMembers [ 0 ] ;
230+ if (
231+ ! members . some ( isNullMember ) ||
232+ nonNullMembers . length !== 1 ||
233+ ! nonNullMember ||
234+ isReference ( nonNullMember )
235+ ) {
236+ return false ;
237+ }
238+ const type = nonNullMember . type as string | string [ ] | undefined ;
239+ const properties = nonNullMember . properties as
240+ | Record < string , unknown >
241+ | undefined ;
242+ return (
243+ ( type === 'object' || ( ! type && ! ! properties ) ) &&
244+ ! ! properties &&
245+ Object . keys ( properties ) . length > 0
246+ ) ;
247+ }
248+
126249/**
127- * True when the schema's emitted type may union something other than a single
128- * object shape — a `null` branch, an enum literal union, a multi-entry type
129- * array (`type: ['object', 'string']` emits `{...} | string`), or any
130- * anyOf/oneOf variants. Property keys collected from such a node are not
131- * guaranteed in `keyof` of the emitted type, so a plain `Required<Pick<T, 'k'>>`
132- * on them could fail with TS2344; callers must leave those keys to the
133- * `Extract` guard instead. Also applied to `$ref`-site objects, whose
134- * `nullable`/type-array siblings are merged into the emission by the resolver.
250+ * True when this node can emit a branch that also omits keys contributed by
251+ * its `allOf` descendants. Direct non-object output escapes the full
252+ * intersection. Direct inline anyOf nullability does so only when `resolveValue`
253+ * resolves this node through a component `$ref` and appends `| null` to the
254+ * imported alias. Canonical nullable oneOf and all-enum sibling compositions
255+ * only make the node's own properties unsafe: `combineSchemas` still
256+ * intersects every `allOf` member with their emitted union. A non-null object
257+ * sibling or guaranteed properties on the parent also eliminate an
258+ * object-or-null member's null branch from the parent intersection, preserving
259+ * that member's object keys.
135260 */
136- function emitsUnionType (
261+ function cannotGuaranteeAllOfPropertyKeys (
137262 schema : OpenApiSchemaObject | OpenApiReferenceObject ,
263+ crossesComponentRefBoundary : boolean ,
264+ nullBranchesEliminated = false ,
138265) : boolean {
139- // Bridge assertions: AnyOtherAttribute infects all schema property access
140266 if (
141- schema . enum ||
142- schema . anyOf ||
143- schema . oneOf ||
144- ( schema . nullable as boolean | undefined ) === true
267+ directlyEmitsNonObjectType ( schema ) &&
268+ ! ( nullBranchesEliminated && directlyEmitsOnlyObjectOrNull ( schema ) )
145269 ) {
146270 return true ;
147271 }
148- const type = schema . type as string | string [ ] | undefined ;
272+ const anyOfMembers = ( schema . anyOf ?? [ ] ) as (
273+ | OpenApiSchemaObject
274+ | OpenApiReferenceObject
275+ ) [ ] ;
276+ return anyOfMembers . some (
277+ ( member ) =>
278+ crossesComponentRefBoundary &&
279+ ! nullBranchesEliminated &&
280+ ! isReference ( member ) &&
281+ isDirectlyNullable ( member ) ,
282+ ) ;
283+ }
284+
285+ /**
286+ * True when this node's own property keys are not guaranteed in `keyof` of the
287+ * referenced output. Nullable, enum, scalar, array, or mixed-type nodes fail
288+ * directly; a missing type and OAS 3.1 `type: ['object']` remain object-capable.
289+ *
290+ * anyOf/oneOf members otherwise remain safe because `combineSchemas`
291+ * intersects the node's own properties into every grouped branch. The exception
292+ * is direct nullability in an inline anyOf member on a component `$ref` target:
293+ * `resolveValue` propagates it to the referenced wrapper as a separate `| null`.
294+ * Inline allOf members, reference members, non-null scalars, oneOf members, and
295+ * nested unions stay inside the grouped intersection. An all-enum composition
296+ * is also unsafe because `combineValues` emits the node's properties as a
297+ * separate union branch instead. Finally, the canonical nullable-oneOf object
298+ * shortcut emits only its inline object and `null`, dropping the node's own
299+ * properties.
300+ */
301+ function cannotGuaranteeOwnPropertyKeys (
302+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
303+ context : ContextSpec ,
304+ crossesComponentRefBoundary : boolean ,
305+ nullBranchesEliminated = false ,
306+ ) : boolean {
149307 return (
150- type === 'null' ||
151- ( Array . isArray ( type ) && ( type . length > 1 || type . includes ( 'null' ) ) )
308+ cannotGuaranteeAllOfPropertyKeys (
309+ schema ,
310+ crossesComponentRefBoundary ,
311+ nullBranchesEliminated ,
312+ ) ||
313+ hasAllEnumMembers ( schema , context ) ||
314+ usesCanonicalNullableOneOfObject ( schema )
152315 ) ;
153316}
154317
@@ -185,8 +348,8 @@ function derefComponentSchema(
185348 return undefined ;
186349 }
187350 if ( isReference ( target ) ) {
188- // Intermediate chain hops can carry union -producing siblings too
189- if ( emitsUnionType ( target ) ) {
351+ // Intermediate chain hops can carry non-object -producing siblings too
352+ if ( cannotGuaranteeAllOfPropertyKeys ( target , true ) ) {
190353 return undefined ;
191354 }
192355 current = target . $ref ;
@@ -197,40 +360,123 @@ function derefComponentSchema(
197360 return undefined ;
198361}
199362
363+ /** A guaranteed own property proves this member cannot emit null. */
364+ function guaranteesNonNullableObject (
365+ schema : OpenApiSchemaObject | OpenApiReferenceObject ,
366+ context : ContextSpec ,
367+ ) : boolean {
368+ const crossesComponentRefBoundary = isReference ( schema ) ;
369+ if (
370+ cannotGuaranteeOwnPropertyKeys ( schema , context , crossesComponentRefBoundary )
371+ ) {
372+ return false ;
373+ }
374+ const resolvedSchema = isReference ( schema )
375+ ? derefComponentSchema ( schema . $ref , context , new Set < string > ( ) )
376+ : schema ;
377+ if ( ! resolvedSchema ) {
378+ return false ;
379+ }
380+ const properties = resolvedSchema . properties as
381+ | Record < string , unknown >
382+ | undefined ;
383+ return (
384+ ! ! properties &&
385+ Object . keys ( properties ) . length > 0 &&
386+ ! cannotGuaranteeOwnPropertyKeys (
387+ resolvedSchema ,
388+ context ,
389+ crossesComponentRefBoundary ,
390+ )
391+ ) ;
392+ }
393+
200394/**
201395 * Collect the property keys reachable through a schema's `allOf` composition,
202396 * resolving component `$ref` members against the spec. Feeds the
203397 * pickable/unresolved split for required-override keys: a key found here is
204398 * provably in `keyof` of the emitted intersection, so a plain
205399 * `Required<Pick<T, 'k'>>` is safe even when an `additionalProperties` index
206- * signature would collapse the `Extract` guard to `never` (#3748). Unions are
207- * deliberately not walked — anyOf/oneOf members, nullable or enum nodes
208- * contribute keys that are not guaranteed in `keyof`, and skipping them only
209- * degrades to the compile-safe `Extract` guard.
400+ * signature would collapse the `Extract` guard to `never` (#3748). Union
401+ * members are deliberately not walked, while a node's own top-level properties
402+ * are collected when the generator intersects them into every anyOf/oneOf
403+ * branch. A node's own unsafe properties are skipped without discarding keys
404+ * from sibling `allOf` descendants that remain in the emitted intersection.
405+ * Nodes that can emit a branch outside that full intersection are skipped
406+ * entirely, degrading to the compile-safe `Extract` guard. The component-ref
407+ * boundary flag mirrors the only `resolveValue` path that lifts inline anyOf
408+ * nullability outside the alias intersection. Parent allOf traversal also uses
409+ * guaranteed properties on the parent or a sibling as proof that null cannot
410+ * survive the full intersection.
210411 */
211412function collectDeepPropertyKeys (
212413 schema : OpenApiSchemaObject | OpenApiReferenceObject ,
213414 context : ContextSpec ,
415+ crossesComponentRefBoundary = false ,
416+ nullBranchesEliminated = false ,
214417 seenRefs = new Set < string > ( ) ,
215418) : string [ ] {
419+ const resolvesComponentRef =
420+ crossesComponentRefBoundary || isReference ( schema ) ;
216421 // Checked before dereferencing: `$ref`-site siblings (`nullable: true`,
217- // `type: ['object', 'null']`) union the emission just like inline nodes.
218- if ( emitsUnionType ( schema ) ) {
422+ // scalar or mixed `type`) can change the emission just like inline nodes.
423+ if (
424+ cannotGuaranteeAllOfPropertyKeys (
425+ schema ,
426+ resolvesComponentRef ,
427+ nullBranchesEliminated ,
428+ )
429+ ) {
219430 return [ ] ;
220431 }
221432 if ( isReference ( schema ) ) {
222433 const target = derefComponentSchema ( schema . $ref , context , seenRefs ) ;
223- return target ? collectDeepPropertyKeys ( target , context , seenRefs ) : [ ] ;
434+ return target
435+ ? collectDeepPropertyKeys (
436+ target ,
437+ context ,
438+ true ,
439+ nullBranchesEliminated ,
440+ seenRefs ,
441+ )
442+ : [ ] ;
224443 }
225444 // Bridge assertion: properties is infected by AnyOtherAttribute
226445 const properties = schema . properties as Record < string , unknown > | undefined ;
227- const keys = properties ? Object . keys ( properties ) : [ ] ;
446+ const keys =
447+ properties &&
448+ ! cannotGuaranteeOwnPropertyKeys (
449+ schema ,
450+ context ,
451+ resolvesComponentRef ,
452+ nullBranchesEliminated ,
453+ )
454+ ? Object . keys ( properties )
455+ : [ ] ;
456+ const parentPropertiesEliminateNull = keys . length > 0 ;
228457 const members = ( schema . allOf ?? [ ] ) as (
229458 | OpenApiSchemaObject
230459 | OpenApiReferenceObject
231460 ) [ ] ;
232- for ( const member of members ) {
233- keys . push ( ...collectDeepPropertyKeys ( member , context , seenRefs ) ) ;
461+ const guaranteedObjectMembers = members . map ( ( member ) =>
462+ guaranteesNonNullableObject ( member , context ) ,
463+ ) ;
464+ for ( const [ index , member ] of members . entries ( ) ) {
465+ const hasObjectSibling = guaranteedObjectMembers . some (
466+ ( isGuaranteedObject , siblingIndex ) =>
467+ siblingIndex !== index && isGuaranteedObject ,
468+ ) ;
469+ keys . push (
470+ ...collectDeepPropertyKeys (
471+ member ,
472+ context ,
473+ false ,
474+ nullBranchesEliminated ||
475+ parentPropertiesEliminateNull ||
476+ hasObjectSibling ,
477+ seenRefs ,
478+ ) ,
479+ ) ;
234480 }
235481 return keys ;
236482}
@@ -358,9 +604,15 @@ function combineValues({
358604 }
359605
360606 if ( resolvedValue ) {
361- return `(${ values . join ( ` & ${ resolvedValue . value } ) | (` ) } & ${
362- resolvedValue . value
363- } )`;
607+ const resolvedValueStr = resolvedValue . value . includes ( ' | ' )
608+ ? `(${ resolvedValue . value } )`
609+ : resolvedValue . value ;
610+ return values
611+ . map ( ( value ) => {
612+ const valueStr = value . includes ( ' | ' ) ? `(${ value } )` : value ;
613+ return `(${ valueStr } & ${ resolvedValueStr } )` ;
614+ } )
615+ . join ( ' | ' ) ;
364616 }
365617
366618 return values . join ( ' | ' ) ;
@@ -491,7 +743,11 @@ export function combineSchemas({
491743 // unionAddMissingProperties (#935), which must only see each member's
492744 // own top-level keys.
493745 resolvedData . allProperties . push (
494- ...collectDeepPropertyKeys ( resolvedValue . originalSchema , context ) ,
746+ ...collectDeepPropertyKeys (
747+ resolvedValue . originalSchema ,
748+ context ,
749+ isReference ( subSchema ) ,
750+ ) ,
495751 ) ;
496752 } else {
497753 // Bridge: originalSchema.properties is infected by AnyOtherAttribute
0 commit comments