Skip to content

Commit 33205d4

Browse files
authored
feat(object/path): improve object path types (#66)
1 parent 1354ab0 commit 33205d4

File tree

5 files changed

+243
-34
lines changed

5 files changed

+243
-34
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"recursive-readdir-sync": "^1.0.6",
5353
"ts-expect": "^1.1.0",
5454
"ts-jest": "^24.1.0",
55-
"typescript": "^3.6.3",
55+
"typescript": "^4.1.6",
5656
"walker": "^1.0.7"
5757
},
5858
"peerDependencies": {

src/is/__tests__/equal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('utils/is/equal', () => {
2424
expect(isEqual(new Date(), new Date(123))).toBe(false);
2525
expect(isEqual(/123/, /123/)).toBe(true);
2626
expect(isEqual(/123/, /1234/)).toBe(false);
27-
expect(isEqual(() => 3, () => 3)).toBe(true);
27+
expect(isEqual(() => 3, () => 3)).toBe(false);
2828
expect(isEqual(() => 3, () => 4)).toBe(false);
2929
});
3030

src/is/__tests__/promise.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe('utils/is/promise', () => {
99
expect(isPromise(() => {})).toBe(false);
1010
expect(isPromise(Promise.resolve())).toBe(true);
1111
expect(isPromise(Promise.reject())).toBe(true);
12-
expect(isPromise(new Promise((res) => res()))).toBe(true);
12+
expect(isPromise(new Promise<void>((res) => res()))).toBe(true);
1313
const f = () => {};
1414

1515
expect(isPromise(f)).toBe(false);

src/object/__tests__/path.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { expectType } from 'ts-expect';
12
import path from '../path';
23

34
describe('utils/object/path', () => {
@@ -6,4 +7,102 @@ describe('utils/object/path', () => {
67
expect(path(['a', 'b'], { a: { c: 3 } })).toBeUndefined();
78
expect(path(['test'])({ test: 'test' })).toBe('test');
89
});
10+
11+
it('should perform dirty cast when type parameter is provided', () => {
12+
const obj = { firstName: 'john' } as const;
13+
const result1 = path<number>(['firstName', 'some-non-existent-prop'], obj);
14+
const result2 = path<number>(['firstName', 'some-non-existent-prop'])(obj);
15+
16+
expectType<number | undefined>(result1);
17+
expectType<number | undefined>(result2);
18+
});
19+
20+
it('should infer type as `unknown` by default', () => {
21+
const obj = { firstName: 'john' } as const;
22+
const result1 = path(['firstName'], obj);
23+
const result2 = path(['firstName'])(obj);
24+
25+
expectType<unknown>(result1);
26+
expectType<unknown>(result2);
27+
});
28+
29+
it('should infer type with a tuple of single value', () => {
30+
const obj = { firstName: 'john' } as const;
31+
const result1 = path(['firstName'] as const, obj);
32+
const result2 = path(['firstName'] as const)(obj);
33+
34+
expectType<'john'>(result1);
35+
expectType<'john'>(result2);
36+
});
37+
38+
it('should infer type with a tuple of multiple values', () => {
39+
const obj = { user: { firstName: 'john' } } as const;
40+
const result1 = path(['user', 'firstName'] as const, obj);
41+
const result2 = path(['user', 'firstName'] as const)(obj);
42+
43+
expectType<'john'>(result1);
44+
expectType<'john'>(result2);
45+
});
46+
47+
it('should infer type with a tuple containing arbitrary id for a record', () => {
48+
const users: Record<string, { name: string }> = {
49+
'some-id': { name: 'john' }
50+
}
51+
const obj = { users } as const;
52+
const result1 = path(['users', 'some-id', 'name'] as const, obj);
53+
const result2 = path(['users', 'some-other-id', 'name'] as const)(obj);
54+
55+
expectType<string | undefined>(result1);
56+
expectType<string | undefined>(result2);
57+
});
58+
59+
it('should infer type with a tuple containing arbitrary index for an array', () => {
60+
const users = [{ name: 'john' }]
61+
const obj = { users } as const;
62+
const result1 = path(['users', 0, 'name'] as const, obj);
63+
const result2 = path(['users', 1, 'name'] as const)(obj);
64+
65+
expectType<string | undefined>(result1);
66+
expectType<string | undefined>(result2);
67+
});
68+
69+
it('should infer type with a tuple containing a field with an optional value', () => {
70+
type User = {
71+
fio?: {
72+
firstName: string;
73+
lastName: string;
74+
},
75+
}
76+
const obj: User = {
77+
fio: {
78+
firstName: 'john',
79+
lastName: 'doe'
80+
}
81+
};
82+
const result1 = path(['fio', 'lastName'] as const, obj);
83+
const result2 = path(['fio', 'lastName'] as const)(obj);
84+
85+
expectType<string | undefined>(result1);
86+
expectType<string | undefined>(result2);
87+
});
88+
89+
it('should infer type with a tuple containing a field with a nullable value', () => {
90+
type User = {
91+
fio: {
92+
firstName: string;
93+
lastName: string;
94+
} | null,
95+
}
96+
const obj: User = {
97+
fio: {
98+
firstName: 'john',
99+
lastName: 'doe'
100+
}
101+
};
102+
const result1 = path(['fio', 'lastName'] as const, obj);
103+
const result2 = path(['fio', 'lastName'] as const)(obj);
104+
105+
expectType<string | undefined>(result1);
106+
expectType<string | undefined>(result2);
107+
});
9108
});

src/object/path.ts

Lines changed: 141 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,145 @@
1-
import { Prop, Paths } from '../typings/types';
1+
import type { Prop, Paths } from "../typings/types";
2+
3+
type If<B, F, S> = B extends true ? F : S;
4+
type Or<B1, B2> = B1 extends true ? true : B2;
5+
type Not<B> = B extends true ? false : true;
6+
type Has<U extends any, U1 extends any> = [U1] extends [U] ? true : false;
7+
8+
type IsExactKey<T> = string extends T
9+
? false
10+
: number extends T
11+
? false
12+
: symbol extends T
13+
? false
14+
: true;
15+
16+
type ValueByPath<P, O, U = false> = P extends readonly [infer F, ...(infer R)]
17+
? /**
18+
* In case we accessed optional property of O on the previous step
19+
* O can be `undefined`. keyof operator won't work on a union
20+
*/
21+
Or<Has<O, null>, Has<O, undefined>> extends infer HasNullOrUndefined
22+
? Exclude<O, undefined | null> extends infer O2
23+
? O2 extends object
24+
? F extends keyof O2
25+
? R extends []
26+
? If<Or<U, HasNullOrUndefined>, O2[F] | undefined, O2[F]>
27+
: ValueByPath<
28+
R,
29+
O2[F],
30+
Or<
31+
Or<U, HasNullOrUndefined>,
32+
/**
33+
* In case we run into some kind of dynamic dictionary
34+
* something like Record<string, ..> or Record<number, ..>
35+
* We want to make sure that we get T | undefined instead of T as a result
36+
*/
37+
Not<IsExactKey<keyof O2>>
38+
>
39+
>
40+
: undefined
41+
: never
42+
: never
43+
: never
44+
: never;
245

346
export interface Path {
4-
<K extends Prop, O extends Record<K, any>>(path: [K], obj: O): O[K];
5-
<K extends Prop>(path: [K]): <O extends Record<K, any>>(obj: O) => O[K];
6-
<T>(path: Paths, obj): T | undefined;
7-
<T>(path: Paths): (obj) => T | undefined;
8-
}
9-
10-
import curryN from '../function/curryN';
11-
12-
/**
13-
* Retrieve the value at a given path.
14-
*
15-
* @param {[String]} paths The path to use.
16-
* @param {Object} obj The object to retrieve the nested property from.
17-
* @return {*} The data at `path`.
18-
* @example
19-
*
20-
* path(['a', 'b'], {a: {b: 2}}); //=> 2
21-
* path(['a', 'b'], {c: {b: 2}}); //=> undefined
22-
*/
23-
export default curryN(2, <K extends Prop, O extends Record<K, any>>(paths: Paths = [], obj: O = {} as any) => {
24-
let val = obj;
25-
26-
for (let i = 0; i < paths.length; i++) {
27-
if (val == null) {
28-
return;
29-
}
30-
31-
val = val[paths[i]];
47+
/**
48+
* Retrieve the value at a given path.
49+
* **Note:** Use `as const` cast on the `paths` for type inference.
50+
*
51+
* @param {[String]} paths The path to use.
52+
* @param {Object} obj The object to retrieve the nested property from.
53+
* @return {*} The data at `path`.
54+
* @example
55+
*
56+
* path(['a', 'b'], {a: {b: 2}}); //=> 2
57+
* path(['a', 'b'], {c: {b: 2}}); //=> undefined
58+
*/
59+
(pathToProp: Prop[], obj: object): unknown;
60+
/**
61+
* Retrieve the value at a given path.
62+
* **Note:** Use `as const` cast on the `paths` for type inference.
63+
*
64+
* @param {[String]} paths The path to use.
65+
* @return {*} function to get data at `path` for a given object
66+
* @example
67+
*
68+
* path(['a', 'b'])({a: {b: 2}}); //=> 2
69+
* path(['a', 'b'])({c: {b: 2}}); //=> undefined
70+
*/
71+
(pathToProp: Prop[]): (obj: object) => unknown;
72+
/**
73+
* @deprecated
74+
* Please use `path` without type parameters instead.
75+
* Make sure to use `as const` cast for props array for type inference
76+
* @example
77+
*
78+
* path(['a', 'b'] as const, { a: { b: 2 } });
79+
*/
80+
<T>(pathToProp: Prop[], obj: object): T | undefined;
81+
/**
82+
* @deprecated
83+
* Please use `path` without type parameters instead.
84+
* Make sure to use `as const` cast for props array for type inference
85+
* @example
86+
*
87+
* path(['a', 'b'] as const)({ a: { b: 2 } });
88+
*/
89+
<T>(pathToProp: Prop[]): (obj: object) => T | undefined;
90+
/**
91+
* Retrieve the value at a given path.
92+
*
93+
* @param {[String]} paths The path to use.
94+
* @param {Object} obj The object to retrieve the nested property from.
95+
* @return {*} The data at `path`.
96+
* @example
97+
*
98+
* const johnDoe = {
99+
* fio: {
100+
* firstName: 'John',
101+
* lastName: 'Doe',
102+
* },
103+
* };
104+
* const firstName = path(['fio', 'firstName'] as const, johnDoe); // => 'John'
105+
*/
106+
<P extends Paths, O>(pathToProp: P, obj: O): ValueByPath<P, O>;
107+
/**
108+
* Retrieve the value at a given path.
109+
*
110+
* @param {[String]} paths The path to use.
111+
* @return {*} function to get data at `path` for a given object
112+
* @example
113+
*
114+
* const johnDoe = {
115+
* fio: {
116+
* firstName: 'John',
117+
* lastName: 'Doe',
118+
* },
119+
* };
120+
* const getLastName = path(['fio', 'lastName'] as const);
121+
* const lastName = getLastName(johnDoe); // => 'Doe'
122+
*/
123+
<P extends Paths>(pathToProp: P): <O>(obj: O) => ValueByPath<P, O>;
124+
};
125+
126+
import curryN from "../function/curryN";
127+
128+
const _path = <K extends Prop, O extends Record<K, any>>(
129+
paths: Paths = [],
130+
obj: O = {} as any
131+
) => {
132+
let val = obj;
133+
134+
for (let i = 0; i < paths.length; i++) {
135+
if (val == null) {
136+
return undefined;
32137
}
33138

34-
return val;
35-
}) as Path;
139+
val = val[paths[i]];
140+
}
141+
142+
return val;
143+
};
144+
145+
export default curryN(2, _path) as Path;

0 commit comments

Comments
 (0)