Problem
For now there are two problems with object/path
types:
path
doesn't infer type of a prop based on the strings inside array in all cases except a single value array
const user = {
nestedProp: {
name: 'John'
},
};
const userName = path(['nestedProp', 'name'], user); // results in `unknown` type
- using a type argument is not typesafe and repetative
// can do the trick
// but we throw all the typesafety away, because type of `name` can change in the future
// and this dirty cast will only error out in runtime.
// Also it feels like we gave compiler all the information it needs to infer this type
// and it feels weird that we have to add types ourselves
const userName1 = path<string>(['nestedProp', 'name'], user);
Solutions
I see two solutions here: implement ValueByPath
helper type ourselves or use https://github.com/millsp/ts-toolbelt util
Both solutions are available in codesandbox: https://codesandbox.io/s/relaxed-lewin-e3njhm?file=/src/index.ts
Manual
import type { Prop, Paths } from "@tinkoff/utils/typings/types";
import curryN from "@tinkoff/utils/function/curryN";
type IsExactKey<T> = string extends T
? false
: number extends T
? false
: symbol extends T
? false
: true;
type ValueByPath<P extends Paths, O, U = true> = P extends readonly [
infer F,
...(infer R)
]
? F extends keyof O
? R extends []
? U extends true
? O[F] | undefined
: O[F]
: R extends Paths // In case we run into some kind of dynamic dictionary // something like Record<string, ..> or Record<number, ..> // We want to make sure that we get T | undefined instead of T as a result
? IsExactKey<keyof O> extends true
? ValueByPath<R, O[F], U>
: ValueByPath<R, O[F], true>
: undefined
: undefined
: undefined;
type Path = {
(pathToProp: Prop[]): (obj: object) => unknown;
(pathToProp: Prop[], obj: object): unknown;
<P extends Paths>(pathToProp: P): <O>(obj: O) => ValueByPath<P, O>;
<P extends Paths, O>(pathToProp: P, obj: O): ValueByPath<P, O>;
};
const _path = <K extends Prop, O extends Record<K, any>>(
paths: Paths = [],
obj: O = {} as any
) => {
let val = obj;
for (let i = 0; i < paths.length; i++) {
if (val == null) {
return undefined;
}
val = val[paths[i]];
}
return val;
};
export const path = curryN(2, _path) as Path;
TS-toolbelt
import type { Any, Object } from "ts-toolbelt";
import curryN from "@tinkoff/utils/function/curryN";
type Path = {
(pathToProp: Any.Key[]): (obj: object) => unknown;
(pathToProp: Any.Key[], obj: object): unknown;
<P extends readonly Any.Key[]>(pathToProp: P): <O>(
obj: O
) => Object.Path<O, P>;
<P extends readonly Any.Key[], O>(pathToProp: P, obj: O): Object.Path<O, P>;
};
const _path = <K extends Any.Key, O extends Record<K, any>>(
paths: readonly Any.Key[] = [],
obj: O = {} as any
) => {
let val = obj;
for (let i = 0; i < paths.length; i++) {
if (val == null) {
return undefined;
}
val = val[paths[i]];
}
return val;
};
export const pathT = curryN(2, _path) as Path;
Migration problem
In case we implement any of these solutions some of the library dependants can have migration issues, because they could have leaned on the usage of type parameters and now the generic is completely different. To provide a more smooth migration story we can add two more overloads with a single type parameter, mark them as deprecated and remove them completely in future versions
type Path = {
(pathToProp: Prop[]): (obj: object) => unknown;
(pathToProp: Prop[], obj: object): unknown;
/** @deprecated please use `path` without type parameters instead */
<T>(pathToProp: Prop[]): (obj: object) => T;
/** @deprecated please use `path` without type parameters instead */
<T>(pathToProp: Prop[], obj: object): T;
<P extends Paths>(pathToProp: P): <O>(obj: O) => ValueByPath<P, O>;
<P extends Paths, O>(pathToProp: P, obj: O): ValueByPath<P, O>;
};