diff --git a/index.d.ts b/index.d.ts index 9d4940235..4ab3aad20 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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'; diff --git a/readme.md b/readme.md index cefb8df87..2231d0461 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/source/conditional-paths.d.ts b/source/conditional-paths.d.ts new file mode 100644 index 000000000..e30379865 --- /dev/null +++ b/source/conditional-paths.d.ts @@ -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 & { + /** + 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; + //=> 'a' + + type EqualityPaths = ConditionalPaths; + //=> '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; +//=> 'name' | 'email' + +type NumberPaths = ConditionalPaths; +//=> '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; +//=> 'title' | 'author.name' | 'author.email' + +type AllNumberPaths = ConditionalPaths; +//=> 'id' | 'author.id' | 'metadata.views' | 'metadata.likes' + +// Use `any` to get every path, like `Paths`. +type AllPaths = ConditionalPaths; +//=> '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; +//=> `tags.${number}` | `items.${number}.name` + +type NumberPaths = ConditionalPaths; +//=> '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` 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 = + _ConditionalPaths>; + +type _ConditionalPaths, CurrentDepth extends number = 0> = + ObjectType extends NonRecursiveType | Exclude + ? never + : IsAny extends true + ? never + : ObjectType extends object + ? InternalConditionalPaths, 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, CurrentDepth extends number> = + {[Key in keyof ObjectType]: Key extends string | number // Limit `Key` to `string | number` + ? ( + And> extends true + ? `[${Key}]` + : CurrentDepth extends 0 + // Return both `Key` and `ToString` because for number keys, like `1`, both `1` and `'1'` are valid keys. + ? Key | ToString + : `.${(Key | ToString)}` + ) 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 extends true + ? TransformedKey + : never + : never + ) + // Recursively generate paths for the current key + | (GreaterThan extends true // Limit the depth to prevent infinite recursion + ? `${TransformedKey}${_ConditionalPaths> & (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> = + Options['condition'] extends 'equality' + ? IsEqual + : [Value] extends [Condition] + ? true + : false; + +export {}; diff --git a/test-d/conditional-paths.ts b/test-d/conditional-paths.ts new file mode 100644 index 000000000..e5fab6aa2 --- /dev/null +++ b/test-d/conditional-paths.ts @@ -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); +expectType<'id' | 'age'>({} as ConditionalPaths); +expectType<'isActive'>({} as ConditionalPaths); +expectType({} as ConditionalPaths); + +// 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); +expectType<'id' | 'author.id' | 'metadata.views' | 'metadata.likes'>({} as ConditionalPaths); + +// Intermediate object-typed paths are included when the condition matches them. +expectType<'author' | 'metadata'>({} as ConditionalPaths); + +// `Condition = any` keeps every path, like `Paths`. +expectType>({} as ConditionalPaths); +expectType>({} as ConditionalPaths); + +// `Condition = unknown` also keeps every path. +expectType>({} as ConditionalPaths); + +// `Condition = never` matches nothing. +expectType({} as ConditionalPaths); + +// 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); +expectType<'scores.0' | 'scores.1' | 'scores.2' | `items.${number}.count`>({} as ConditionalPaths); + +// 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({} 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({} 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, string>); + +// `condition: 'equality'` requires an exact value-type match. +type Mixed = { + a: string; + b: string | number; +}; +expectType<'a'>({} as ConditionalPaths); +expectType<'a' | 'b'>({} as ConditionalPaths); +expectType<'b'>({} as ConditionalPaths); +expectType<'a'>({} as ConditionalPaths); + +// `condition: 'equality'` works with object-shaped conditions. +expectType<'author'>({} as ConditionalPaths); + +// 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); +expectType<'num' | 'count'>({} as ConditionalPaths); + +// Filtering by the enum type itself. +expectType<'str'>({} as ConditionalPaths); +// Under `extends`, a numeric enum and `number` are mutually assignable, so a plain `number`-valued path matches too. +expectType<'num' | 'count'>({} as ConditionalPaths); + +// `condition: 'equality'` distinguishes a numeric enum from plain `number`. +expectType<'num'>({} as ConditionalPaths); +expectType<'count'>({} as ConditionalPaths); + +// 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); +expectType<'name' | `variants[${number}].color`>({} as ConditionalPaths); + +type Company = { + name: string; + ceo: { + name: string; + contact: { + email: string; + phone: string; + }; + }; +}; +expectType<'name'>({} as ConditionalPaths); +expectType<'ceo.name'>({} as ConditionalPaths); +expectType<'ceo.contact.email' | 'ceo.contact.phone'>({} as ConditionalPaths); + +// 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); + +// `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); + +// 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({} as ConditionalPaths); +expectType({} as ConditionalPaths); +expectType({} as ConditionalPaths); +expectType({} as ConditionalPaths<{}, string>); +expectType({} as ConditionalPaths<[], string>); + +// Assignability sanity checks. +declare function open>(path: Path): void; +expectAssignable[0]>('title'); +expectAssignable[0]>('author.name'); +expectNotAssignable[0]>('id'); +expectNotAssignable[0]>('metadata');