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
28 changes: 22 additions & 6 deletions source/get.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {Paths} from './paths.d.ts';
import type {Split} from './split.d.ts';
import type {KeyAsString} from './key-as-string.d.ts';
import type {DigitCharacter} from './characters.d.ts';
import type {IsUnion} from './is-union.d.ts';

export type GetOptions = {
/**
Expand Down Expand Up @@ -124,10 +125,25 @@ type UncheckedIndex<T, U extends string | number> = [T] extends [Record<string |
Get a property of an object or array. Works when indexing arrays using number-literal-strings, for example, `PropertyOf<number[], '0'> = number`, and when indexing objects with number keys.

Note:
- Returns `unknown` if `Key` is not a property of `BaseType`, since TypeScript uses structural typing, and it cannot be guaranteed that extra properties unknown to the type system will exist at runtime.
- Returns `undefined` from nullish values, to match the behaviour of most deep-key libraries like `lodash`, `dot-prop`, etc.
- Returns `undefined` if `Key` is not a property of `BaseType`, to match the behaviour of most deep-key libraries like `lodash`, `dot-prop`, etc.
*/
type PropertyOf<BaseType, Key extends string, Options extends Required<GetOptions>> =
IsUnion<BaseType> extends true
// Distribute over the union so that a key which is missing on *some*
// members resolves to `undefined` for those members instead of
// collapsing the whole result. For example,
// `Get<{a: number} | {b: string}, 'a'>` is `number | undefined`.
? BaseType extends unknown
? SinglePropertyOf<BaseType, Key, Options>
: never
: SinglePropertyOf<BaseType, Key, Options>;

/**
Get a property of a single (non-union) object or array.

A `Key` that is not present on `BaseType` resolves to `undefined`.
*/
type SinglePropertyOf<BaseType, Key extends string, Options extends Required<GetOptions>> =
BaseType extends null | undefined
? undefined
: Key extends keyof BaseType
Expand All @@ -142,9 +158,9 @@ type PropertyOf<BaseType, Key extends string, Options extends Required<GetOption
: Key extends keyof BaseType
? Strictify<BaseType[Key & keyof BaseType], Options>
// Out-of-bounds access for tuples
: unknown
: undefined
// Non-numeric string key for arrays/tuples
: unknown
: undefined
// Handle array-like objects
: BaseType extends {
[n: number]: infer Item;
Expand All @@ -153,11 +169,11 @@ type PropertyOf<BaseType, Key extends string, Options extends Required<GetOption
? (
ConsistsOnlyOf<Key, DigitCharacter> extends true
? Strictify<Item, Options>
: unknown
: undefined
)
: Key extends keyof WithStringKeys<BaseType>
? StrictPropertyOf<WithStringKeys<BaseType>, Key, Options>
: unknown;
: undefined;

// This works by first splitting the path based on `.` and `[...]` characters into a tuple of string keys. Then it recursively uses the head key to get the next property of the current object, until there are no keys left. Number keys extract the item type from arrays, or are converted to strings to extract types from tuples and dictionaries with number keys.
/**
Expand Down
46 changes: 32 additions & 14 deletions test-d/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ expectTypeOf(get(apiResponse, 'hits.hits.0._source.name')).toEqualTypeOf<Array<{

expectTypeOf(get(apiResponse, 'hits.hits[0]._source.name[0].given[0]')).toBeString();

// TypeScript is structurally typed. It's *possible* this value exists even though it's not on the parent interface, so the type is `unknown`.
expectTypeOf(get(apiResponse, 'hits.someNonsense.notTheRightPath')).toBeUnknown();
// A key that is not present resolves to `undefined`, matching the behaviour of deep-key libraries like lodash.
expectTypeOf(get(apiResponse, 'hits.someNonsense.notTheRightPath')).toEqualTypeOf<undefined>();

type WithDictionary = {
foo: Record<string, {
Expand Down Expand Up @@ -64,16 +64,16 @@ expectTypeOf<Get<WithTuples, 'foo[0].bar', NonStrict>>().toBeNumber();
expectTypeOf<Get<WithTuples, 'foo.0.bar', NonStrict>>().toBeNumber();

expectTypeOf<Get<WithTuples, 'foo[1].baz', NonStrict>>().toBeBoolean();
expectTypeOf<Get<WithTuples, 'foo[1].bar', NonStrict>>().toBeUnknown();
expectTypeOf<Get<WithTuples, 'foo[1].bar', NonStrict>>().toEqualTypeOf<undefined>();

expectTypeOf<Get<WithTuples, 'foo[-1]', NonStrict>>().toBeUnknown();
expectTypeOf<Get<WithTuples, 'foo[999]', NonStrict>>().toBeUnknown();
expectTypeOf<Get<WithTuples, 'foo[-1]', NonStrict>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<WithTuples, 'foo[999]', NonStrict>>().toEqualTypeOf<undefined>();

type EmptyTuple = Parameters<() => {}>;

expectTypeOf<Get<EmptyTuple, '-1', NonStrict>>().toBeUnknown();
expectTypeOf<Get<EmptyTuple, '0', NonStrict>>().toBeUnknown();
expectTypeOf<Get<EmptyTuple, '1', NonStrict>>().toBeUnknown();
expectTypeOf<Get<EmptyTuple, '-1', NonStrict>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<EmptyTuple, '0', NonStrict>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<EmptyTuple, '1', NonStrict>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<EmptyTuple, 'length', NonStrict>>().toEqualTypeOf<0>();

type WithNumberKeys = {
Expand All @@ -87,8 +87,8 @@ type WithNumberKeys = {
expectTypeOf<Get<WithNumberKeys, 'foo[1].bar', NonStrict>>().toBeNumber();
expectTypeOf<Get<WithNumberKeys, 'foo.1.bar', NonStrict>>().toBeNumber();

expectTypeOf<Get<WithNumberKeys, 'foo[2].bar', NonStrict>>().toBeUnknown();
expectTypeOf<Get<WithNumberKeys, 'foo.2.bar', NonStrict>>().toBeUnknown();
expectTypeOf<Get<WithNumberKeys, 'foo[2].bar', NonStrict>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<WithNumberKeys, 'foo.2.bar', NonStrict>>().toEqualTypeOf<undefined>();

// Test `readonly`, `ReadonlyArray`, optional properties, and unions with null.

Expand All @@ -112,9 +112,9 @@ expectTypeOf<Get<WithModifiers, 'foo[0].abc.def.ghi', NonStrict>>().toEqualTypeO
// Test bracket notation
expectTypeOf<Get<number[], '[0]', NonStrict>>().toBeNumber();
// NOTE: This would fail if `[0][0]` was converted into `00`:
expectTypeOf<Get<number[], '[0][0]', NonStrict>>().toBeUnknown();
expectTypeOf<Get<number[], '[0][0]', NonStrict>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<number[][][], '[0][0][0]', NonStrict>>().toBeNumber();
expectTypeOf<Get<number[][][], '[0][0][0][0]', NonStrict>>().toBeUnknown();
expectTypeOf<Get<number[][][], '[0][0][0][0]', NonStrict>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<{a: {b: Array<Array<Array<{id: number}>>>}}, 'a.b[0][0][0].id', NonStrict>>().toBeNumber();
expectTypeOf<Get<{a: {b: Array<Array<Array<{id: number}>>>}}, ['a', 'b', '0', '0', '0', 'id'], NonStrict>>().toBeNumber();

Expand All @@ -133,8 +133,26 @@ expectTypeOf<Get<WithDictionary, 'baz.whatever.qux[3].x'>>().toEqualTypeOf<boole
expectTypeOf<Get<WithDictionary, ['baz', 'whatever', 'qux', '3', 'x']>>().toEqualTypeOf<boolean | undefined>();

// Test array index out of bounds
expectTypeOf<Get<{a: []}, 'a[0]'>>().toEqualTypeOf<unknown>();
expectTypeOf<Get<{a: readonly []}, 'a[0]'>>().toEqualTypeOf<unknown>();
expectTypeOf<Get<{a: []}, 'a[0]'>>().toEqualTypeOf<undefined>();
expectTypeOf<Get<{a: readonly []}, 'a[0]'>>().toEqualTypeOf<undefined>();

// Test union base types: a key missing on *some* members resolves to `undefined`
// for those members, instead of collapsing the whole result to `unknown`.
// https://github.com/sindresorhus/type-fest/issues/1205
type DiscriminatedUnion = {
data:
| {type: 'number'; someValue: number}
| {type: 'string'; someValue: string}
| {type: 'none'};
};
// Key present on every member of the union.
expectTypeOf<Get<DiscriminatedUnion, 'data.type'>>().toEqualTypeOf<'number' | 'string' | 'none'>();
// Key present on some members, absent on others.
expectTypeOf<Get<DiscriminatedUnion, 'data.someValue'>>().toEqualTypeOf<number | string | undefined>();
// Union as the base type directly.
expectTypeOf<Get<{a: number} | {b: string}, 'a'>>().toEqualTypeOf<number | undefined>();
// A genuinely missing key on a non-union type resolves to `undefined`, consistent with the union case.
expectTypeOf<Get<{a: number}, 'b'>>().toEqualTypeOf<undefined>();

// Test empty path array
expectTypeOf<WithDictionary>().toEqualTypeOf<Get<WithDictionary, []>>();
Expand Down