Skip to content

Paths: Fix behavior with generic types#1343

Merged
sindresorhus merged 3 commits intomainfrom
fix/paths-with-generic-types
Jan 31, 2026
Merged

Paths: Fix behavior with generic types#1343
sindresorhus merged 3 commits intomainfrom
fix/paths-with-generic-types

Conversation

@som-sm
Copy link
Collaborator

@som-sm som-sm commented Jan 29, 2026

Fixes #1340


Reproducing the problem

This is an interesting one.

Let's consider this really simplified implementation of Paths:

type SimplifiedPaths<T> = T extends object
	? {
		[P in keyof T]: P | `${P & string}.${SimplifiedPaths<T[P]> & string}`;
	}[keyof T]
	: never;

type T = SimplifiedPaths<{a: {b: {c: string}; d: number}; e: boolean}>;
//=> 'a' | 'e' | 'a.b' | 'a.d' | 'a.b.c'

Now, let's recreate the problem in #1340 using this simplified implementation:

type Foo<T> = {bar: {baz: T}};
type Test<T> = SomeTypeWithConstraint<Foo<T>, 'bar.baz'>; // ✅ Works

type SomeTypeWithConstraint<T, _U extends SimplifiedPaths<T>> = never;

Everything works fine here. SimplifiedPaths is able to figure out that 'bar.baz' is a valid path for Foo<T>.

And, if you intentionally instantiate SomeTypeWithConstraint with some incorrect path, then you'd see that the complete value for SimplifiedPaths<Foo<T>> is 'bar' | 'bar.baz' | `bar.baz.${SimplifiedPaths<T> & string}`, which is perfect!

image

simplified-paths-gif

A small change that breaks it

Now let’s make a small change to SimplifiedPaths:

type SimplifiedPaths<T> = T extends object
	? {
		[P in keyof T]: P | (SimplifiedPaths<T[P]> extends infer SubPaths
			? `${P & string}.${SubPaths & string}`
			: never);
	}[keyof T]
	: never;

type T = SimplifiedPaths<{a: {b: {c: string}; d: number}; e: boolean}>;
//=> 'a' | 'e' | 'a.b' | 'a.d' | 'a.b.c'

The only difference is that we first capture SimplifiedPaths<T[P]> into SubPaths and then use that.

Looks harmless, but this now causes an error in our SomeTypeWithConstraint instantiation:

type Foo<T> = {bar: {baz: T}};
type Test<T> = SomeTypeWithConstraint<Foo<T>, 'bar.baz'>; // 💥 Errors

type SomeTypeWithConstraint<T, _U extends SimplifiedPaths<T>> = never;
image

If you inspect the error, SimplifiedPaths<Foo<T>> now looks like this:

'bar' | ('baz' | (SimplifiedPaths<T> extends infer SubPaths
    ? `baz.${SubPaths & string}`
    : never) extends infer SubPaths 
        ? `bar.${SubPaths & string}`
        : never)

simplified-paths-gif-2

What changed?

Now, the entire subpath chain is wrapped in conditionals that depend on SimplifiedPaths<T>. That makes even 'bar.baz' indirectly dependent on T.

So, 'bar.baz' is no longer a clearly known union member. It is buried inside generic-dependent conditionals, which is why the constraint fails.

Why did Paths extract the recursive result in the first place?

This was done to handle bracketNotation correctly.

If a subpath starts with bracket notation, we must not insert a dot before it. So the caller needs to know what the recursive result looks like before deciding whether to join with . or not. And that is what introduced the generic dependency problem.

Refer to the following lines in the existing implementation:

type-fest/source/paths.d.ts

Lines 256 to 268 in 77672ac

}> extends infer SubPath
? SubPath extends string | number
? (
Options['bracketNotation'] extends true
? SubPath extends `[${any}]` | `[${any}]${string}`
? `${TranformedKey}${SubPath}` // If next node is number key like `[3]`, no need to add `.` before it.
: `${TranformedKey}.${SubPath}`
: never
) | (
Options['bracketNotation'] extends false
? `${TranformedKey}.${SubPath}`
: never
)

The fix

This PR moves dot handling into the recursive step itself.

Instead of the caller deciding whether to add a dot, each recursive subpath now returns paths that are already correctly prefixed.

Because of that:

  • The caller no longer needs to extract the recursive result into an intermediate type.
  • The recursive call can be used directly.
  • The full path chain is no longer wrapped in generic-dependent conditionals.

As a result, TS can simplify known prefixes like 'bar.baz', and the constraint works as expected.


Additional improvement

Additionally, this also uncollapses some paths that were previously getting collapsed.

For example, earlier:

Paths<{a: {[x: string]: {b: string; c: number}}}>

returned:

'a' | `a.${string}`

This effectively collapsed `a.${string}.b` and `a.${string}.c` into just `a.${string}`. But those paths do not actually collapse into `a.${string}`. This was only happening because of a limitation in the previous implementation.

type T = `a.${string}` | `a.${string}.c` | `a.${string}.b` // TS doesn't collapse this
//=> `a.${string}` | `a.${string}.c` | `a.${string}.b`

The issue was that the subpath being returned for 'a' was string | `${string}.b` | `${string}.c` which collapses to just string. When that gets joined with 'a', the final result becomes only 'a' | `a.${string}`.

So even though the entire result didn't require any collapsing, it happened because the subpath had already collapsed.

With the updated logic, subpaths are prefixed with a dot. In this case, the subpath now becomes:

`.${string}` | `.${string}.b` | `.${string}.c`

This union does not collapse. So when joined with 'a', we correctly get:

'a' | `a.${string}` | `a.${string}.b` | `a.${string}.c`

So this PR also removes any unnecessary collapsing.

Note

This does not eliminate collapsing entirely. Something like:

Paths<{[x: string]: string; a: string}>

still resolves to just string, not (string & {}) | 'a'. So #1190 still remains valid.

@som-sm som-sm force-pushed the fix/paths-with-generic-types branch 2 times, most recently from 3b76ac9 to bd57022 Compare January 30, 2026 13:04
Repository owner deleted a comment from claude bot Jan 30, 2026
Comment on lines 46 to +51
export type IsNumberLike<N> =
IsAnyOrNever<N> extends true ? N
: N extends number | `${number}`
IfNotAnyOrNever<N,
N extends number | `${number}`
? true
: false;
: false,
boolean, false>;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsNumberLike should always return a boolean, but currently it returns any for any, which makes it harder to instantiate at places that have a boolean constraint, like:

And<Options['bracketNotation'], IsNumberLike<Key>>

@som-sm som-sm force-pushed the fix/paths-with-generic-types branch from bd57022 to c4685a7 Compare January 30, 2026 15:48
Repository owner deleted a comment from claude bot Jan 30, 2026
Repository owner deleted a comment from claude bot Jan 30, 2026
@som-sm som-sm marked this pull request as ready for review January 30, 2026 15:55
@som-sm som-sm requested a review from sindresorhus January 30, 2026 16:07
@sindresorhus
Copy link
Owner

I added a couple of more tests just to make it comprehensive (not directly related to this change): 2f93f4b

Repository owner deleted a comment from claude bot Jan 31, 2026
Copy link
Owner

@sindresorhus sindresorhus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Thanks for the comprehensive explanation of the changes.

@som-sm
Copy link
Collaborator Author

som-sm commented Jan 31, 2026

I added a couple of more tests just to make it comprehensive (not directly related to this change): 2f93f4b

Cool, looks good!

@sindresorhus sindresorhus merged commit 8f0419c into main Jan 31, 2026
7 checks passed
@sindresorhus sindresorhus deleted the fix/paths-with-generic-types branch January 31, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SetRequiredDeep does not work with generics

2 participants