@@ -295,6 +295,59 @@ export const isRecursiveType = (
295295 * // For type {user: {name: string, age: number}} with expectedType = string
296296 * // Returns: [{path: [{path: 'user'}, {path: 'name'}], type: stringType }]
297297 */
298+ /**
299+ * Determines whether a candidate type matches an expected parameter type.
300+ *
301+ * A plain type matches when it is (non-nullably) assignable to the expected type.
302+ * A union matches when ANY of its constituents matches: a union such as
303+ * `string | { deep: string }` is not assignable to `string` as a whole, yet its
304+ * string branch makes the union key a valid suggestion for a string parameter.
305+ *
306+ * @param type - The candidate type (already non-nullable)
307+ * @param checker - TypeScript TypeChecker for type operations
308+ * @param expectedType - The target parameter type to match against
309+ * @returns True if the type (or one of its union branches) matches the expected type
310+ */
311+ const matchesExpectedType = (
312+ type : ts . Type ,
313+ checker : ts . TypeChecker ,
314+ expectedType : ts . Type
315+ ) : boolean => {
316+ if ( type . isUnion ( ) ) {
317+ return type . types . some ( ( constituent ) =>
318+ matchesExpectedType ( checker . getNonNullableType ( constituent ) , checker , expectedType )
319+ ) ;
320+ }
321+
322+ return (
323+ ( type . flags & ts . TypeFlags . Never ) === 0 &&
324+ checker . isTypeAssignableTo ( type , expectedType )
325+ ) ;
326+ } ;
327+
328+ /**
329+ * Resolves the object types whose properties should be traversed for a candidate.
330+ *
331+ * For a plain object type this is simply the type itself. For a union it is each
332+ * object-typed branch (non-nullable), since getProperties() on the union only
333+ * exposes properties shared by every branch — the distinct keys of individual
334+ * object branches (e.g. `deep` in `string | { deep: string }`) would otherwise be
335+ * unreachable. Primitive branches are dropped.
336+ *
337+ * @param type - The candidate type (already non-nullable)
338+ * @param checker - TypeScript TypeChecker for type operations
339+ * @returns The object branches to traverse into
340+ */
341+ const getObjectBranches = ( type : ts . Type , checker : ts . TypeChecker ) : ts . Type [ ] => {
342+ if ( type . isUnion ( ) ) {
343+ return type . types
344+ . map ( ( constituent ) => checker . getNonNullableType ( constituent ) )
345+ . filter ( isRealObjectType ) ;
346+ }
347+
348+ return isRealObjectType ( type ) ? [ type ] : [ ] ;
349+ } ;
350+
298351const extractObjectProperties = (
299352 type : ts . Type ,
300353 checker : ts . TypeChecker ,
@@ -311,32 +364,41 @@ const extractObjectProperties = (
311364 // `string` parameter — strict assignability would reject it under strictNullChecks.
312365 // A purely nullish candidate strips down to `never` (assignable to anything),
313366 // so it must be excluded explicitly.
367+ //
368+ // A union candidate (e.g. `TEXT | { deep: TEXT }`) matches when ANY of its
369+ // constituents is assignable to the expected type: the whole union is not
370+ // assignable to `string`, but its string branch is, so the union key is still a
371+ // valid string suggestion.
314372 const nonNullableType = checker . getNonNullableType ( type ) ;
315- if (
316- ( nonNullableType . flags & ts . TypeFlags . Never ) === 0 &&
317- checker . isTypeAssignableTo ( nonNullableType , expectedType )
318- ) {
373+ if ( matchesExpectedType ( nonNullableType , checker , expectedType ) ) {
319374 results . push ( { path : currentPath , type } ) ;
320375 }
321376
322377 // Recursively traverse into object properties. Traversal also runs on the
323378 // non-nullable type: a nullable object in the chain (e.g. `{bla?: TEXT} | null`)
324379 // is a union whose getProperties() is empty, which would cut off all nested paths.
380+ // Union candidates (e.g. `TEXT | { deep: TEXT }`) are traversed per object branch:
381+ // getProperties() on the union itself only exposes properties common to every
382+ // branch (none here), so `flexible.deep` would otherwise be unreachable.
325383 // Recursive data types (e.g. Order.delivery.order) are cut off via the visited
326384 // set — the checker caches type identities, so a cycle revisits the same ts.Type
327385 // object. The set only tracks the current branch (backtracked below) so the same
328386 // type may still appear on sibling paths. The depth cap only applies to types
329387 // that are part of a reference cycle (checked last, it's the expensive test);
330388 // purely nested non-recursive objects are traversed to arbitrary depth.
331- if (
332- isRealObjectType ( nonNullableType ) &&
333- ! visited . has ( nonNullableType ) &&
334- ( currentPath . length < MAX_REFERENCE_DEPTH ||
335- ! isRecursiveType ( nonNullableType , checker , recursionCache ) )
336- ) {
337- const properties = nonNullableType . getProperties ( ) ;
389+ const objectBranches = getObjectBranches ( nonNullableType , checker ) ;
390+ objectBranches . forEach ( ( branch ) => {
391+ if (
392+ visited . has ( branch ) ||
393+ ( currentPath . length >= MAX_REFERENCE_DEPTH &&
394+ isRecursiveType ( branch , checker , recursionCache ) )
395+ ) {
396+ return ;
397+ }
398+
399+ const properties = branch . getProperties ( ) ;
338400 if ( properties && properties . length > 0 ) {
339- visited . add ( nonNullableType ) ;
401+ visited . add ( branch ) ;
340402 properties . forEach ( ( property ) => {
341403 const propType = checker . getTypeOfSymbolAtLocation ( property , property . valueDeclaration ! ) ;
342404 const propName = property . getName ( ) ;
@@ -345,9 +407,9 @@ const extractObjectProperties = (
345407 // Recurse into nested properties
346408 results . push ( ...extractObjectProperties ( propType , checker , expectedType , newPath , visited , recursionCache ) ) ;
347409 } ) ;
348- visited . delete ( nonNullableType ) ;
410+ visited . delete ( branch ) ;
349411 }
350- }
412+ } ) ;
351413
352414 return results ;
353415} ;
0 commit comments