Skip to content

Paths: Add maxCircularDepth option #1079

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
66 changes: 66 additions & 0 deletions source/internal/array.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {IfNever} from '../if-never';
import type {IsEqual} from '../is-equal';
import type {UnknownArray} from '../unknown-array';

/**
Expand Down Expand Up @@ -124,3 +125,68 @@ export type IfArrayReadonly<T extends UnknownArray, TypeIfArrayReadonly = true,
IsArrayReadonly<T> extends infer Result
? Result extends true ? TypeIfArrayReadonly : TypeIfNotArrayReadonly
: never; // Should never happen

/**
Returns elements from the List that are equal to the SearchType.

@example
```
type StaticList = [string, 1, 'Hello', number, 2, 1, boolean, 4, 'bye'];
type B = FilterArrayIncludes<StaticList, number>;
//=> [1, number, 2, 1, 4]
type C = FilterArrayIncludes<StaticList, string>;
//=> [string, "Hello", "bye"]
type D = FilterArrayIncludes<StaticList, 1>;
//=> [1, 1]

// Note: Variable part in the array will discard all subsequent elements.
type VariableList = [string, 1, 'Hello', number, 2, ...string[], 1, boolean, 4, 'bye'];
type E = FilterArrayIncludes<VariableList, number>;
//=> [1, number, 2]
type F = FilterArrayIncludes<VariableList, string>;
//=> [string, "Hello"]
type G = FilterArrayIncludes<VariableList, 1>;
//=> [1]
```
@category Array
*/
export type FilterArrayIncludes<List extends unknown[], SearchType> = List extends []
? []
: StaticPartOfArray<List> extends [infer Head, ...infer Tail]
? FilterArrayIncludes<Tail, SearchType> extends infer Return extends unknown[]
? IsEqual<Head, SearchType> extends true
? [Head, ...Return]
: Return
: never
: never;

/**
Returns count of how many elements in the List are equal to the SearchType.

@uses
```
type CountInArray<...> = FilterArrayIncludes<List, SearchType>['length']
```

@example
```
type StaticList = [string, 1, 'Hello', number, 2, 1, boolean, 4, 'bye'];
type B = CountInArray<StaticList, number>;
//=> 5
type C = CountInArray<StaticList, string>;
//=> 3
type D = CountInArray<StaticList, 1>;
//=> 2

// Note: Variable part in the array will discard all subsequent elements.
type VariableList = [string, 1, 'Hello', number, 2, ...string[], 1, boolean, 4, 'bye'];
type E = CountInArray<VariableList, number>;
//=> 3
type F = CountInArray<VariableList, string>;
//=> 2
type G = CountInArray<VariableList, 1>;
//=> 1
```
@category Array
*/
export type CountInArray<List extends unknown[], SearchType> = FilterArrayIncludes<List, SearchType>['length'];
22 changes: 22 additions & 0 deletions source/internal/string.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {NegativeInfinity, PositiveInfinity} from '../numeric';
import type {Subtract} from '../subtract';
import type {Trim} from '../trim';
import type {Whitespace} from './characters';
import type {BuildTuple} from './tuple';
Expand Down Expand Up @@ -208,3 +209,24 @@ type PositiveNumericCharacterGt<A extends string, B extends string> = NumericStr
: false
: never
: never;

/**
Returns a string limited by the Delimiter to the given Depth

@example
```
type Limited0 = LimitStringDepth<'internal.parent.foo.foo.foo.foo.foo.foo.foo', '.', 0>;
//=> 'internal'

type Limited2 = LimitStringDepth<'internal.parent.foo.foo.foo.foo.foo.foo.foo', '.', 2>;
//=> 'internal.parent.foo'

type Limited3 = LimitStringDepth<'internal.parent.foo.foo.foo.foo.foo.foo.foo', '.', 3>;
//=> 'internal.parent.foo.foo'
```
*/
export type LimitStringDepth<S extends string, Delimiter extends string, Depth extends number> = S extends `${infer Head}${Delimiter}${infer Tail}`
? Depth extends 0
? Head
: `${Head}${Delimiter}${LimitStringDepth<Tail, Delimiter, Subtract<Depth, 1>>}`
: S;
61 changes: 47 additions & 14 deletions source/paths.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {StaticPartOfArray, VariablePartOfArray, NonRecursiveType, ToString, IsNumberLike} from './internal';
import type {StaticPartOfArray, VariablePartOfArray, NonRecursiveType, ToString, IsNumberLike, CountInArray} from './internal';
import type {EmptyObject} from './empty-object';
import type {IsAny} from './is-any';
import type {UnknownArray} from './unknown-array';
Expand All @@ -18,6 +18,32 @@ export type PathsOptions = {
*/
maxRecursionDepth?: number;

/**
The maximum depth to recurse circular objects when searching for paths.

Note: `maxCircularDepth: 0` will fully disable recursion into circular references.

@default 10

@example
```
type DeepWithCircular = {
a: {b: {c: {d: {e: string}}}};
foo: {circular: DeepWithCircular};
};

type Circular0 = Paths<DeepWithCircular, {maxCircularDepth: 0}>;
// => 'a' | 'foo' | 'a.b' | 'a.b.c' | 'a.b.c.d' | 'a.b.c.d.e' | 'foo.circular'

type Circular1 = Paths<DeepWithCircular, {maxCircularDepth: 1}>;
// => 'a' | 'foo' | 'a.b' | 'a.b.c' | 'a.b.c.d' | 'a.b.c.d.e' | 'foo.circular' | 'foo.circular.a' | 'foo.circular.foo' | 'foo.circular.a.b' | 'foo.circular.a.b.c' | 'foo.circular.a.b.c.d' | 'foo.circular.a.b.c.d.e' | 'foo.circular.foo.circular'

type Circular2 = Paths<DeepWithCircular, {maxCircularDepth: 2}>;
// => 'a' | 'foo' | 'a.b' | 'a.b.c' | 'a.b.c.d' | 'a.b.c.d.e' | 'foo.circular' | 'foo.circular.a' | 'foo.circular.foo' | 'foo.circular.a.b' | 'foo.circular.a.b.c' | 'foo.circular.a.b.c.d' | ... 8 more ... | 'foo.circular.foo.circular.foo.circular'
```
*/
maxCircularDepth?: number;

/**
Use bracket notation for array indices and numeric object keys.

Expand Down Expand Up @@ -128,8 +154,9 @@ export type PathsOptions = {
depth?: number;
};

type DefaultPathsOptions = {
export type DefaultPathsOptions = {
maxRecursionDepth: 10;
maxCircularDepth: 10;
bracketNotation: false;
leavesOnly: false;
depth: number;
Expand Down Expand Up @@ -179,6 +206,8 @@ open('listB.1'); // TypeError. Because listB only has one element.
export type Paths<T, Options extends PathsOptions = {}> = _Paths<T, {
// Set default maxRecursionDepth to 10
maxRecursionDepth: Options['maxRecursionDepth'] extends number ? Options['maxRecursionDepth'] : DefaultPathsOptions['maxRecursionDepth'];
// Set default maxCircularDepth to 10
maxCircularDepth: Options['maxCircularDepth'] extends number ? Options['maxCircularDepth'] : DefaultPathsOptions['maxCircularDepth'];
// Set default bracketNotation to false
bracketNotation: Options['bracketNotation'] extends boolean ? Options['bracketNotation'] : DefaultPathsOptions['bracketNotation'];
// Set default leavesOnly to false
Expand All @@ -187,22 +216,25 @@ export type Paths<T, Options extends PathsOptions = {}> = _Paths<T, {
depth: Options['depth'] extends number ? Options['depth'] : DefaultPathsOptions['depth'];
}>;

type _Paths<T, Options extends Required<PathsOptions>> =
type _Paths<T, Options extends Required<PathsOptions>, Seen extends unknown[] = []> =
T extends NonRecursiveType | ReadonlyMap<unknown, unknown> | ReadonlySet<unknown>
? never
: IsAny<T> extends true
? never
: T extends UnknownArray
? number extends T['length']
: GreaterThan<CountInArray<Seen, T>, Options['maxCircularDepth']> extends false
? T extends UnknownArray
? number extends T['length']
// We need to handle the fixed and non-fixed index part of the array separately.
? InternalPaths<StaticPartOfArray<T>, Options>
| InternalPaths<Array<VariablePartOfArray<T>[number]>, Options>
: InternalPaths<T, Options>
: T extends object
? InternalPaths<T, Options>
: never;

type InternalPaths<T, Options extends Required<PathsOptions>> =
? InternalPaths<StaticPartOfArray<T>, Options, Seen>
// For the variable part of the array we need to include the full array as Seen to prevent circular recursion.
| InternalPaths<Array<VariablePartOfArray<T>[number]>, Options, [...Seen, T]>
: InternalPaths<T, Options, Seen>
: T extends object
? InternalPaths<T, Options, Seen>
: never
: never;

type InternalPaths<T, Options extends Required<PathsOptions>, Seen extends unknown[]> =
Options['maxRecursionDepth'] extends infer MaxDepth extends number
? Required<T> extends infer T
? T extends EmptyObject | readonly []
Expand Down Expand Up @@ -245,9 +277,10 @@ type InternalPaths<T, Options extends Required<PathsOptions>> =
{
bracketNotation: Options['bracketNotation'];
maxRecursionDepth: Subtract<MaxDepth, 1>;
maxCircularDepth: Options['maxCircularDepth'];
leavesOnly: Options['leavesOnly'];
depth: Subtract<Options['depth'], 1>;
}> extends infer SubPath
}, [...Seen, T]> extends infer SubPath
? SubPath extends string | number
? (
Options['bracketNotation'] extends true
Expand Down
54 changes: 53 additions & 1 deletion test-d/paths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {expectAssignable, expectNotAssignable, expectType} from 'tsd';
import type {Paths} from '../index';
import type {DefaultPathsOptions, Paths} from '../source/paths';
import type {LimitStringDepth} from '../source/internal';

declare const normal: Paths<{foo: string}>;
expectType<'foo'>(normal);
Expand Down Expand Up @@ -119,6 +120,57 @@ expectType<'foo'>(recursion0);
declare const recursion1: Paths<RecursiveFoo, {maxRecursionDepth: 1}>;
expectType<'foo' | 'foo.foo'>(recursion1);

// Circular depth
type CircularFoo = {foo: CircularFoo; a: {b: {c: {d: {e: {f: {g: string}}}}}}; internal: {parent: CircularFoo}};

// Default circular limit should be respected
type CircularFooDefault = Paths<CircularFoo>;
expectAssignable<CircularFooDefault>('foo.foo.foo.foo.foo.foo.foo.foo');
expectAssignable<CircularFooDefault>('foo.foo.foo.a.b.c.d.e');
expectAssignable<CircularFooDefault>('foo.foo.a.b.c.d.e.f.g');
expectAssignable<CircularFooDefault>('foo.a.b.c.d.e.f.g');
expectAssignable<CircularFooDefault>('foo.foo.a.b.c');
expectAssignable<CircularFooDefault>('internal.parent.foo.foo.foo.foo.foo.foo.foo');
expectAssignable<CircularFooDefault>('internal.parent.foo.a.b.c');
expectAssignable<CircularFooDefault>('internal.parent.a.b.c');

type GetKeysAtNextLevel<PreviousLevelKeys extends string> = PreviousLevelKeys | `foo.${PreviousLevelKeys}` | `internal.parent.${PreviousLevelKeys}`;

type KeysAtLevel0 = 'foo' | 'a' | 'a.b' | 'a.b.c' | 'a.b.c.d' | 'internal' | 'internal.parent' | 'a.b.c.d.e' | 'a.b.c.d.e.f' | 'a.b.c.d.e.f.g';
expectType<KeysAtLevel0>({} as Paths<CircularFoo, {maxCircularDepth: 0}>);

type KeysAtLevel1 = GetKeysAtNextLevel<KeysAtLevel0>;
expectType<KeysAtLevel1>({} as Paths<CircularFoo, {maxCircularDepth: 1}>);

type KeysAtLevel2 = GetKeysAtNextLevel<KeysAtLevel1>;
expectType<KeysAtLevel2>({} as Paths<CircularFoo, {maxCircularDepth: 2}>);

// Level 3 will hit the max recursion depth, so we have to limit the expected keys to that depth for testing
type KeysAtLevel3 = GetKeysAtNextLevel<KeysAtLevel2>;
expectType<LimitStringDepth<KeysAtLevel3, '.', DefaultPathsOptions['maxRecursionDepth']>>({} as Paths<CircularFoo, {maxCircularDepth: 3}>);
// We can also test that it in fact doesn't go deeper
expectNotAssignable<Paths<CircularFoo, {maxCircularDepth: 3}>>({} as KeysAtLevel3);

// Ensure non-circular recurring structure works and is not flagged as circular
type ObjectWithRecurringStructure = {foo: string; bar: {foo: string; bar: {}}};
expectType<'foo' | 'bar' | 'bar.foo' | 'bar.bar'>({} as Paths<ObjectWithRecurringStructure, {maxCircularDepth: 0}>);

// Arrays
type StaticLengthArray = [number, 3, StaticLengthArray, false];
expectType<'0' | '1' | '2' | '3'>({} as Paths<StaticLengthArray, {maxCircularDepth: 0}>);
expectType<'0' | '1' | '2' | '3' | '2.0' | '2.1' | '2.2' | '2.3'>({} as Paths<StaticLengthArray, {maxCircularDepth: 1}>);
expectType<'0' | '1' | '2' | '3' | '2.0' | '2.1' | '2.2' | '2.3' | '2.2.0' | '2.2.1' | '2.2.2' | '2.2.3'>({} as Paths<StaticLengthArray, {maxCircularDepth: 2}>);

type VariableLengthArray = [1, ...VariableLengthArray[], string];
expectType<number | `${number}`>({} as Paths<VariableLengthArray, {maxCircularDepth: 0}>);
expectType<number | `${number}` | `${number}.${number}`>({} as Paths<VariableLengthArray, {maxCircularDepth: 1}>);
expectType<number | `${number}` | `${number}.${number}` | `${number}.${number}.${number}`>({} as Paths<VariableLengthArray, {maxCircularDepth: 2}>);

type VariableLengthArray2 = [boolean, ...number[], VariableLengthArray2, string];
expectType<number | `${number}`>({} as Paths<VariableLengthArray2, {maxCircularDepth: 0}>);
expectType<number | `${number}` | `${number}.${number}`>({} as Paths<VariableLengthArray2, {maxCircularDepth: 1}>);
expectType<number | `${number}` | `${number}.${number}` | `${number}.${number}.${number}`>({} as Paths<VariableLengthArray2, {maxCircularDepth: 2}>);

// Test a[0].b style
type Object1 = {
arr: [{a: string}];
Expand Down