From 996171b65ffaa726467e9563c6dea0ca8fb66d26 Mon Sep 17 00:00:00 2001 From: Haozheng Li Date: Tue, 7 Nov 2023 21:51:39 +0800 Subject: [PATCH] Add `Paths` type (#741) --- index.d.ts | 1 + readme.md | 1 + source/paths.d.ts | 108 ++++++++++++++++++++++++++++++++++++++++++++++ test-d/paths.ts | 75 ++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 source/paths.d.ts create mode 100644 test-d/paths.ts diff --git a/index.d.ts b/index.d.ts index 3d6002840..8cf8569e1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -103,6 +103,7 @@ export type {IfUnknown} from './source/if-unknown'; export type {ArrayIndices} from './source/array-indices'; export type {ArrayValues} from './source/array-values'; export type {SetFieldType} from './source/set-field-type'; +export type {Paths} from './source/paths'; // Template literal types export type {CamelCase} from './source/camel-case'; diff --git a/readme.md b/readme.md index 1530c76a5..092d6e269 100644 --- a/readme.md +++ b/readme.md @@ -177,6 +177,7 @@ Click the type names for complete docs. - [`ArrayIndices`](source/array-indices.d.ts) - Provides valid indices for a constant array or tuple. - [`ArrayValues`](source/array-values.d.ts) - Provides all values for a constant array or tuple. - [`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. ### Type Guard diff --git a/source/paths.d.ts b/source/paths.d.ts new file mode 100644 index 000000000..2e00acf00 --- /dev/null +++ b/source/paths.d.ts @@ -0,0 +1,108 @@ +import type {EmptyObject} from './empty-object'; +import type {IsAny} from './is-any'; +import type {IsNever} from './is-never'; +import type {UnknownArray} from './unknown-array'; +import type {UnknownRecord} from './unknown-record'; + +type ToString = T extends string | number ? `${T}` : never; + +/** +Return the part of the given array with a fixed index. + +@example +``` +type A = [string, number, boolean, ...string[]]; +type B = FilterFixedIndexArray; +//=> [string, number, boolean] +``` +*/ +type FilterFixedIndexArray = + number extends T['length'] ? + T extends readonly [infer U, ...infer V] + ? FilterFixedIndexArray + : Result + : T; + +/** +Return the part of the given array with a non-fixed index. + +@example +``` +type A = [string, number, boolean, ...string[]]; +type B = FilterNotFixedIndexArray; +//=> string[] +``` +*/ +type FilterNotFixedIndexArray = +T extends readonly [...FilterFixedIndexArray, ...infer U] + ? U + : []; + +/** +Generate a union of all possible paths to properties in the given object. + +It also works with arrays. + +Use-case: You want a type-safe way to access deeply nested properties in an object. + +@example +``` +import type {Paths} from 'type-fest'; + +type Project = { + filename: string; + listA: string[]; + listB: [{filename: string}]; + folder: { + subfolder: { + filename: string; + }; + }; +}; + +type ProjectPaths = Paths; +//=> 'filename' | 'listA' | 'listB' | 'folder' | `listA.${number}` | 'listB.0' | 'listB.0.filename' | 'folder.subfolder' | 'folder.subfolder.filename' + +declare function open(path: Path): void; + +open('filename'); // Pass +open('folder.subfolder'); // Pass +open('folder.subfolder.filename'); // Pass +open('foo'); // TypeError + +// Also works with arrays +open('listA.1'); // Pass +open('listB.0'); // Pass +open('listB.1'); // TypeError. Because listB only has one element. +``` + +@category Object +@category Array +*/ +export type Paths = + IsAny extends true + ? never + : T extends UnknownArray + ? number extends T['length'] + // We need to handle the fixed and non-fixed index part of the array separately. + ? InternalPaths> + | InternalPaths[number]>> + : InternalPaths + : InternalPaths; + +export type InternalPaths<_T extends UnknownRecord | UnknownArray, T = Required<_T>> = + T extends EmptyObject | readonly [] + ? never + : { + [Key in keyof T]: + Key extends string | number // Limit `Key` to string or number. + ? T[Key] extends UnknownRecord | UnknownArray + ? ( + IsNever> extends false + // If `Key` is a number, return `Key | `${Key}``, because both `array[0]` and `array['0']` do not work. + ? Key | ToString | `${Key}.${Paths}` + : Key | ToString + ) + : Key | ToString + : never + }[keyof T & (T extends UnknownArray ? number : unknown)]; diff --git a/test-d/paths.ts b/test-d/paths.ts new file mode 100644 index 000000000..7756f2f7d --- /dev/null +++ b/test-d/paths.ts @@ -0,0 +1,75 @@ +import {expectType} from 'tsd'; +import type {Paths} from '../index'; + +declare const normal: Paths<{foo: string}>; +expectType<'foo'>(normal); + +type DeepObject = { + a: { + b: { + c: { + d: string; + }; + }; + b2: number[]; + b3: boolean; + }; +}; +declare const deepObject: Paths; +expectType<'a' | 'a.b' | 'a.b2' | 'a.b3' | 'a.b.c' | 'a.b.c.d' | `a.b2.${number}`>(deepObject); + +declare const emptyObject: Paths<{}>; +expectType(emptyObject); + +declare const emptyArray: Paths<[]>; +expectType(emptyArray); + +declare const symbol: Paths<{[Symbol.iterator]: string}>; +expectType(symbol); + +declare const never: Paths; +expectType(never); + +declare const date: Paths<{foo: Date}>; +expectType<'foo'>(date); + +declare const mixed: Paths<{foo: boolean} | {bar: string}>; +expectType<'foo' | 'bar'>(mixed); + +declare const array: Paths>; +expectType(array); + +declare const tuple: Paths<[{foo: string}]>; +expectType<'0' | '0.foo'>(tuple); + +declare const deeplist: Paths<{foo: Array<{bar: boolean[]}>}>; +expectType<'foo' | `foo.${number}` | `foo.${number}.bar` | `foo.${number}.bar.${number}`>(deeplist); + +declare const readonly: Paths<{foo: Readonly<{bar: string}>}>; +expectType<'foo' | 'foo.bar'>(readonly); + +declare const readonlyArray: Paths<{foo: readonly string[]}>; +expectType<'foo' | `foo.${number}`>(readonlyArray); + +declare const optional: Paths<{foo?: {bar?: number}}>; +expectType<'foo' | 'foo.bar'>(optional); + +declare const record: Paths>; +expectType<'a'>(record); + +declare const record2: Paths>; +expectType<1 | '1'>(record2); + +// Test for unknown length array +declare const trailingSpreadTuple: Paths<[{a: string}, ...Array<{b: number}>]>; +expectType(trailingSpreadTuple); + +declare const trailingSpreadTuple1: Paths<[{a: string}, {b: number}, ...Array<{c: number}>]>; +expectType(trailingSpreadTuple); +expectType(trailingSpreadTuple1); + +declare const leadingSpreadTuple: Paths<[...Array<{a: string}>, {b: number}]>; +expectType(leadingSpreadTuple); + +declare const leadingSpreadTuple1: Paths<[...Array<{a: string}>, {b: number}, {c: number}]>; +expectType(leadingSpreadTuple1);