Skip to content

Commit

Permalink
Add Paths type (#741)
Browse files Browse the repository at this point in the history
  • Loading branch information
Emiyaaaaa committed Nov 7, 2023
1 parent 30aa0ad commit 996171b
Show file tree
Hide file tree
Showing 4 changed files with 185 additions and 0 deletions.
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
108 changes: 108 additions & 0 deletions source/paths.d.ts
Original file line number Diff line number Diff line change
@@ -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> = 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<A>;
//=> [string, number, boolean]
```
*/
type FilterFixedIndexArray<T extends UnknownArray, Result extends UnknownArray = []> =
number extends T['length'] ?
T extends readonly [infer U, ...infer V]
? FilterFixedIndexArray<V, [...Result, U]>
: Result
: T;

/**
Return the part of the given array with a non-fixed index.
@example
```
type A = [string, number, boolean, ...string[]];
type B = FilterNotFixedIndexArray<A>;
//=> string[]
```
*/
type FilterNotFixedIndexArray<T extends UnknownArray> =
T extends readonly [...FilterFixedIndexArray<T>, ...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<Project>;
//=> 'filename' | 'listA' | 'listB' | 'folder' | `listA.${number}` | 'listB.0' | 'listB.0.filename' | 'folder.subfolder' | 'folder.subfolder.filename'
declare function open<Path extends ProjectPaths>(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<T extends UnknownRecord | UnknownArray> =
IsAny<T> 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<FilterFixedIndexArray<T>>
| InternalPaths<Array<FilterNotFixedIndexArray<T>[number]>>
: InternalPaths<T>
: InternalPaths<T>;

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<Paths<T[Key]>> extends false
// If `Key` is a number, return `Key | `${Key}``, because both `array[0]` and `array['0']` do not work.
? Key | ToString<Key> | `${Key}.${Paths<T[Key]>}`
: Key | ToString<Key>
)
: Key | ToString<Key>
: never
}[keyof T & (T extends UnknownArray ? number : unknown)];
75 changes: 75 additions & 0 deletions test-d/paths.ts
Original file line number Diff line number Diff line change
@@ -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<DeepObject>;
expectType<'a' | 'a.b' | 'a.b2' | 'a.b3' | 'a.b.c' | 'a.b.c.d' | `a.b2.${number}`>(deepObject);

declare const emptyObject: Paths<{}>;
expectType<never>(emptyObject);

declare const emptyArray: Paths<[]>;
expectType<never>(emptyArray);

declare const symbol: Paths<{[Symbol.iterator]: string}>;
expectType<never>(symbol);

declare const never: Paths<never>;
expectType<never>(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<Array<{foo: string}>>;
expectType<number | `${number}` | `${number}.foo`>(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<Record<'a', any>>;
expectType<'a'>(record);

declare const record2: Paths<Record<1, unknown>>;
expectType<1 | '1'>(record2);

// Test for unknown length array
declare const trailingSpreadTuple: Paths<[{a: string}, ...Array<{b: number}>]>;
expectType<number | `${number}` | '0.a' | `${number}.b`>(trailingSpreadTuple);

declare const trailingSpreadTuple1: Paths<[{a: string}, {b: number}, ...Array<{c: number}>]>;
expectType<number | `${number}` | '0.a' | `${number}.b`>(trailingSpreadTuple);
expectType<number | `${number}` | '0.a' | '1.b' | `${number}.c`>(trailingSpreadTuple1);

declare const leadingSpreadTuple: Paths<[...Array<{a: string}>, {b: number}]>;
expectType<number | `${number}` | `${number}.b` | `${number}.a`>(leadingSpreadTuple);

declare const leadingSpreadTuple1: Paths<[...Array<{a: string}>, {b: number}, {c: number}]>;
expectType<number | `${number}` | `${number}.b` | `${number}.c` | `${number}.a`>(leadingSpreadTuple1);

0 comments on commit 996171b

Please sign in to comment.