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
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export type {ArrayElement} from './source/array-element.d.ts';
export type {ArrayLength} from './source/array-length.d.ts';
export type {SetFieldType, SetFieldTypeOptions} from './source/set-field-type.d.ts';
export type {Paths, PathsOptions} from './source/paths.d.ts';
export type {ConditionalPaths, ConditionalPathsOptions} from './source/conditional-paths.d.ts';
export type {AllUnionFields} from './source/all-union-fields.d.ts';
export type {SharedUnionFields} from './source/shared-union-fields.d.ts';
export type {SharedUnionFieldsDeep, SharedUnionFieldsDeepOptions} from './source/shared-union-fields-deep.d.ts';
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ Click the type names for complete docs.
- [`ArrayTail`](source/array-tail.d.ts) - Extract the type of an array or tuple minus the first element.
- [`SetFieldType`](source/set-field-type.d.ts) - Create a type that changes the type of the given keys.
- [`Paths`](source/paths.d.ts) - Generate a union of all possible paths to properties in the given object.
- [`ConditionalPaths`](source/conditional-paths.d.ts) - Generate a union of all the paths in an object whose value type matches the given `Condition`.
- [`SharedUnionFields`](source/shared-union-fields.d.ts) - Create a type with shared fields from a union of object types.
- [`SharedUnionFieldsDeep`](source/shared-union-fields-deep.d.ts) - Create a type with shared fields from a union of object types, deeply traversing nested structures.
- [`AllUnionFields`](source/all-union-fields.d.ts) - Create a type with all fields from a union of object types.
Expand Down
189 changes: 189 additions & 0 deletions source/conditional-paths.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import type {NonRecursiveType, ToString, IsNumberLike, ApplyDefaultOptions, MapsSetsOrArrays} from './internal/index.d.ts';
import type {IsAny} from './is-any.d.ts';
import type {UnknownArray} from './unknown-array.d.ts';
import type {GreaterThan} from './greater-than.d.ts';
import type {Sum} from './sum.d.ts';
import type {And} from './and.d.ts';
import type {IsEqual} from './is-equal.d.ts';
import type {PathsOptions} from './paths.d.ts';

/**
ConditionalPaths options.

@see {@link ConditionalPaths}
*/
export type ConditionalPathsOptions = Omit<PathsOptions, 'leavesOnly'> & {
/**
The condition assertion mode.

- `'extends'`: include a path when its value type is assignable to `Condition`.
- `'equality'`: include a path only when its value type is exactly `Condition`.

@default 'extends'

@example
```
import type {ConditionalPaths} from 'type-fest';

type Example = {
a: string;
b: string | number;
};

type ExtendsPaths = ConditionalPaths<Example, string>;
//=> 'a'

type EqualityPaths = ConditionalPaths<Example, string | number, {condition: 'equality'}>;
//=> 'b'
```
*/
condition?: 'extends' | 'equality';
};

type DefaultConditionalPathsOptions = {
maxRecursionDepth: 5;
bracketNotation: false;
depth: number;
condition: 'extends';
};

/**
Generate a union of all the paths in an object whose value type matches the given `Condition`.

This is the path-aware counterpart of {@link ConditionalKeys}, and it accepts the same {@link PathsOptions | options} as {@link Paths} (such as `maxRecursionDepth`, `bracketNotation`, and `depth`) plus a {@link ConditionalPathsOptions.condition | `condition`} mode.

Use-case: You want a type-safe way to refer only to the nested properties of a specific value type, such as every `string` path or every `number` path in an object.

Note: Use `any` as the `Condition` to match every path, in which case it behaves like {@link Paths}.

@example
```
import type {ConditionalPaths} from 'type-fest';

type User = {
id: number;
name: string;
email: string;
age: number;
isActive: boolean;
};

type StringPaths = ConditionalPaths<User, string>;
//=> 'name' | 'email'

type NumberPaths = ConditionalPaths<User, number>;
//=> 'id' | 'age'
```

@example
```
import type {ConditionalPaths} from 'type-fest';

type Post = {
id: number;
title: string;
author: {
id: number;
name: string;
email: string;
};
metadata: {
views: number;
likes: number;
};
};

type AllStringPaths = ConditionalPaths<Post, string>;
//=> 'title' | 'author.name' | 'author.email'

type AllNumberPaths = ConditionalPaths<Post, number>;
//=> 'id' | 'author.id' | 'metadata.views' | 'metadata.likes'

// Use `any` to get every path, like `Paths`.
type AllPaths = ConditionalPaths<Post, any>;
//=> 'id' | 'title' | 'author' | 'author.id' | 'author.name' | 'author.email' | 'metadata' | 'metadata.views' | 'metadata.likes'
```

@example
```
import type {ConditionalPaths} from 'type-fest';

// Works with arrays and tuples.
type Data = {
tags: string[];
scores: [number, number, number];
items: Array<{name: string; count: number}>;
};

type StringPaths = ConditionalPaths<Data, string>;
//=> `tags.${number}` | `items.${number}.name`

type NumberPaths = ConditionalPaths<Data, number>;
//=> 'scores.0' | 'scores.1' | 'scores.2' | `items.${number}.count`
```

@see {@link Paths}
@see {@link ConditionalKeys}

@remarks
Traversal mirrors {@link Paths}: every key is recursed into regardless of whether the current key matches `Condition`, so deeper matching paths are always reachable. At each key the value type is tested against `Condition` via a non-distributive (tuple-wrapped) extends check — `[Value] extends [Condition]` — so union value types are compared as a whole. Optional properties are normalised by the internal `Required<T>` call and match their base type (e.g. `name?: string` matches `string`), but explicitly nullable fields do not (e.g. `name: string | null` does not match `string`).

@see https://github.com/sindresorhus/type-fest/issues/1328

@category Object
@category Array
*/
export type ConditionalPaths<Type, Condition, Options extends ConditionalPathsOptions = {}> =
_ConditionalPaths<Type, Condition, ApplyDefaultOptions<ConditionalPathsOptions, DefaultConditionalPathsOptions, Options>>;

type _ConditionalPaths<ObjectType, Condition, Options extends Required<ConditionalPathsOptions>, CurrentDepth extends number = 0> =
ObjectType extends NonRecursiveType | Exclude<MapsSetsOrArrays, UnknownArray>
? never
: IsAny<ObjectType> extends true
? never
: ObjectType extends object
? InternalConditionalPaths<Required<ObjectType>, Condition, Options, CurrentDepth>
: never;

// Only emits a path when the value at the current key matches `Condition`. Recursion still
// descends through every key so that deeper matching paths are reachable even when the
// intermediate value itself does not match.
type InternalConditionalPaths<ObjectType, Condition, Options extends Required<ConditionalPathsOptions>, CurrentDepth extends number> =
{[Key in keyof ObjectType]: Key extends string | number // Limit `Key` to `string | number`
? (
And<Options['bracketNotation'], IsNumberLike<Key>> extends true
? `[${Key}]`
: CurrentDepth extends 0
// Return both `Key` and `ToString<Key>` because for number keys, like `1`, both `1` and `'1'` are valid keys.
? Key | ToString<Key>
: `.${(Key | ToString<Key>)}`
) extends infer TransformedKey extends string | number
? (
// If `depth` is provided, the condition becomes truthy only when it matches `CurrentDepth`.
// Otherwise, since `depth` defaults to `number`, the condition is always truthy, returning paths at all depths.
CurrentDepth extends Options['depth']
// Only emit this path when the value at `Key` matches `Condition`.
? AssertCondition<ObjectType[Key], Condition, Options> extends true
? TransformedKey
: never
: never
)
// Recursively generate paths for the current key
| (GreaterThan<Options['maxRecursionDepth'], CurrentDepth> extends true // Limit the depth to prevent infinite recursion
? `${TransformedKey}${_ConditionalPaths<ObjectType[Key], Condition, Options, Sum<CurrentDepth, 1>> & (string | number)}`
: never)
: never
: never
}[keyof ObjectType & (ObjectType extends UnknownArray ? number : unknown)];

// Assert the value type against the condition.
// The `extends` check is non-distributive (tuple-wrapped) so union value types are
// compared as a whole — keeping `Condition = any` equivalent to `Paths`.
type AssertCondition<Value, Condition, Options extends Required<ConditionalPathsOptions>> =
Options['condition'] extends 'equality'
? IsEqual<Value, Condition>
: [Value] extends [Condition]
? true
: false;

export {};
184 changes: 184 additions & 0 deletions test-d/conditional-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {expectAssignable, expectNotAssignable, expectType} from 'tsd';
import type {ConditionalPaths, Paths} from '../index.d.ts';

// Basic flat object — filter by value type.
type User = {
id: number;
name: string;
email: string;
age: number;
isActive: boolean;
};

expectType<'name' | 'email'>({} as ConditionalPaths<User, string>);
expectType<'id' | 'age'>({} as ConditionalPaths<User, number>);
expectType<'isActive'>({} as ConditionalPaths<User, boolean>);
expectType<never>({} as ConditionalPaths<User, bigint>);

// Nested objects.
type Post = {
id: number;
title: string;
author: {
id: number;
name: string;
email: string;
};
metadata: {
views: number;
likes: number;
};
};
expectType<'title' | 'author.name' | 'author.email'>({} as ConditionalPaths<Post, string>);
expectType<'id' | 'author.id' | 'metadata.views' | 'metadata.likes'>({} as ConditionalPaths<Post, number>);

// Intermediate object-typed paths are included when the condition matches them.
expectType<'author' | 'metadata'>({} as ConditionalPaths<Post, object>);

// `Condition = any` keeps every path, like `Paths`.
expectType<Paths<Post>>({} as ConditionalPaths<Post, any>);
expectType<Paths<User>>({} as ConditionalPaths<User, any>);

// `Condition = unknown` also keeps every path.
expectType<Paths<User>>({} as ConditionalPaths<User, unknown>);

// `Condition = never` matches nothing.
expectType<never>({} as ConditionalPaths<User, never>);

// Arrays and tuples.
type Data = {
tags: string[];
scores: [number, number, number];
items: Array<{name: string; count: number}>;
};

expectType<`tags.${number}` | `items.${number}.name`>({} as ConditionalPaths<Data, string>);
expectType<'scores.0' | 'scores.1' | 'scores.2' | `items.${number}.count`>({} as ConditionalPaths<Data, number>);

// Optional fields count as their base type (`string | undefined` → `string` after `Required`).
expectType<'name'>({} as ConditionalPaths<{name?: string; age: number}, string>);

// Nullable fields are NOT treated as their base type: `string | null` ≠ `string`.
expectType<'b'>({} as ConditionalPaths<{a: string | null; b: string}, string>);

// Null-typed fields do not match non-null conditions.
expectType<never>({} as ConditionalPaths<{a: null; b: string}, number>);

// Null conditions work correctly and distinguish null from undefined.
expectType<'a'>({} as ConditionalPaths<{a: null; b: string}, null>);
expectType<'a'>({} as ConditionalPaths<{a: null; b: undefined}, null>);

// Union-typed values are compared non-distributively (like `ConditionalKeys`).
expectType<never>({} as ConditionalPaths<{a: string | number}, string>);
expectType<'a'>({} as ConditionalPaths<{a: string | number}, string | number>);

// Union root types distribute — each member is checked independently.
expectType<'a'>({} as ConditionalPaths<{a: string} | {b: number}, string>);

// Number keys yield both numeric and string forms, like `Paths`.
expectType<1 | '1'>({} as ConditionalPaths<Record<1, string>, string>);

// `condition: 'equality'` requires an exact value-type match.
type Mixed = {
a: string;
b: string | number;
};
expectType<'a'>({} as ConditionalPaths<Mixed, string>);
expectType<'a' | 'b'>({} as ConditionalPaths<Mixed, string | number>);
expectType<'b'>({} as ConditionalPaths<Mixed, string | number, {condition: 'equality'}>);
expectType<'a'>({} as ConditionalPaths<Mixed, string, {condition: 'equality'}>);

// `condition: 'equality'` works with object-shaped conditions.
expectType<'author'>({} as ConditionalPaths<Post, {id: number; name: string; email: string}, {condition: 'equality'}>);

// Enums are treated as their underlying primitive type (recursion does not descend into them).
enum NumericEnum {
A,
B,
}

enum StringEnum {
X = 'x',
Y = 'y',
}

type WithEnums = {
num: NumericEnum;
str: StringEnum;
plain: string;
count: number;
};

// A string enum matches `string`; a numeric enum matches `number`.
expectType<'str' | 'plain'>({} as ConditionalPaths<WithEnums, string>);
expectType<'num' | 'count'>({} as ConditionalPaths<WithEnums, number>);

// Filtering by the enum type itself.
expectType<'str'>({} as ConditionalPaths<WithEnums, StringEnum>);
// Under `extends`, a numeric enum and `number` are mutually assignable, so a plain `number`-valued path matches too.
expectType<'num' | 'count'>({} as ConditionalPaths<WithEnums, NumericEnum>);

// `condition: 'equality'` distinguishes a numeric enum from plain `number`.
expectType<'num'>({} as ConditionalPaths<WithEnums, NumericEnum, {condition: 'equality'}>);
expectType<'count'>({} as ConditionalPaths<WithEnums, number, {condition: 'equality'}>);

// Enums nested inside objects.
expectType<'theme.primary'>(
{} as ConditionalPaths<{theme: {primary: NumericEnum; label: StringEnum}}, NumericEnum>,
);

// Options pass through to `Paths`.
type Product = {
name: string;
prices: [number, number];
variants: Array<{color: string}>;
};
expectType<'prices[0]' | 'prices[1]'>({} as ConditionalPaths<Product, number, {bracketNotation: true}>);
expectType<'name' | `variants[${number}].color`>({} as ConditionalPaths<Product, string, {bracketNotation: true}>);

type Company = {
name: string;
ceo: {
name: string;
contact: {
email: string;
phone: string;
};
};
};
expectType<'name'>({} as ConditionalPaths<Company, string, {depth: 0}>);
expectType<'ceo.name'>({} as ConditionalPaths<Company, string, {depth: 1}>);
expectType<'ceo.contact.email' | 'ceo.contact.phone'>({} as ConditionalPaths<Company, string, {depth: 2}>);

// With `bracketNotation`, numeric/index keys become `[n]` while string keys keep dots.
expectType<'label' | `tags[${number}]`>({} as ConditionalPaths<{label: string; tags: string[]}, string, {bracketNotation: true}>);
// String-only key paths are unaffected — `[n]` syntax only applies to numeric keys.
expectType<'name' | 'ceo.name' | 'ceo.contact.email' | 'ceo.contact.phone'>({} as ConditionalPaths<Company, string, {bracketNotation: true}>);

// `maxRecursionDepth` caps traversal depth — paths deeper than the cap are not emitted.
type Deep = {a: {b: string; c: {d: string}}};
expectType<'a.b'>({} as ConditionalPaths<Deep, string, {maxRecursionDepth: 1}>);

// Edge cases: `any`, `never`, `unknown` as field VALUE types.
// `any`- and `never`-typed fields vacuously match every `extends`-mode condition
// because `[any] extends [C]` and `[never] extends [C]` are both `true` in the
// non-distributive tuple form (known limitation).
expectType<'data' | 'name'>({} as ConditionalPaths<{data: any; name: string}, string>);
expectType<'data' | 'name'>({} as ConditionalPaths<{data: never; name: string}, string>);
// `unknown`-typed fields only match conditions that `unknown` satisfies.
expectType<'name'>({} as ConditionalPaths<{data: unknown; name: string}, string>);
expectType<'data' | 'name'>({} as ConditionalPaths<{data: unknown; name: string}, unknown>);

// Edge cases for the base type.
expectType<never>({} as ConditionalPaths<any, string>);
expectType<never>({} as ConditionalPaths<never, string>);
expectType<never>({} as ConditionalPaths<unknown, string>);
expectType<never>({} as ConditionalPaths<{}, string>);
expectType<never>({} as ConditionalPaths<[], string>);

// Assignability sanity checks.
declare function open<Path extends ConditionalPaths<Post, string>>(path: Path): void;
expectAssignable<Parameters<typeof open>[0]>('title');
expectAssignable<Parameters<typeof open>[0]>('author.name');
expectNotAssignable<Parameters<typeof open>[0]>('id');
expectNotAssignable<Parameters<typeof open>[0]>('metadata');