In this article I will try to implement something similar to lodash.get
function.
This is what I want to implement:
// John
const result = deepPick({ user: { name: 'John' } }, 'user', 'name')
First of all, we should validate all arguments but first, because they should be in right order. Let's assume we have next object:
type Foo = {
user: {
description: {
name: string;
surname: string;
}
}
}
Now, we need to generate array of all allowed keys.In order to do that, we need to iterate through every key and replace type property with array of keys.
Does it make sense for You?
type FirstAttempt<T> = {
[P in keyof T]: [P]
}
Above code does not make any sense. We need to iterate through every nested property, hence, we need to make it recursively. Let's try again. But now, we need call recursion only if property is not primitive.
type SecondAttempt<Obj> = {
[Prop in keyof Obj]:
Obj[Prop] extends PropertyKey
? [Prop]
: SecondAttempt<Obj[Prop]>
}
// { name: ["name"]; surname: ["surname"]; }
type Result = SecondAttempt<Foo>['user']['description']
It is looks better now, but we did not receive full path to name and surname. We only have an array of last non primitive property.
Seems, we need some cache.
type ThirdAttempt<Obj, Cache extends Array<PropertyKey> = []> = {
[Prop in keyof Obj]: Obj[Prop] extends PropertyKey
? [...Cache, Prop]
: ThirdAttempt<Obj[Prop], [...Cache, Prop]>
}
// {
// name: ["user", "description", "name"];
// surname: ["user", "description", "surname"];
// }
type Result = ThirdAttempt<Foo>['user']['description']
Looks a way better now. But we need all possible values. User should be able to get not only primitive values. We should allow user to get ['user', 'description']
We can try to pass Cache
as a union of previous and next type.
type FourthAttempt<Obj, Cache extends Array<PropertyKey> = []> = {
[Prop in keyof Obj]: Obj[Prop] extends PropertyKey
? [...Cache, Prop]
: FourthAttempt<Obj[Prop], Cache | [...Cache, Prop]>
}
type Result = FourthAttempt<Foo>['user']['description']
Seems we are closer now. Pls keep in mind, we still need a union of arrays instead of some weird object. Btw, it is still does not meet our requirements
Let's move our condition statement one level up.
type FifthAttempt<Obj, Cache extends Array<PropertyKey> = []> =
Obj extends PropertyKey
? Cache
: {
[Prop in keyof Obj]:
FifthAttempt<Obj[Prop], Cache | [...Cache, Prop]>
}
type Result = FifthAttempt<Foo>
Assume we have our union of arrays in one place, as a value of deepest keys. How we can get it? I'd willing to bet that You are aware of famous utility type type Values<T>=T[keyof T]
type Values<Obj> = Obj[keyof Obj]
type SixthAttempt0<Obj, Cache extends Array<PropertyKey> = []> =
Obj extends PropertyKey
? Cache
: Values<{
[Prop in keyof Obj]:
SixthAttempt0<Obj[Prop], Cache | [...Cache, Prop]>
}>
type Result = SixthAttempt0<Foo>
We still have a logical error in our code. Let's fix it.
type FinalAttempt<Obj, Cache extends Array<PropertyKey> = []> =
Obj extends PropertyKey
? Cache
: {
[Prop in keyof Obj]:
| [...Cache, Prop]
| FinalAttempt<Obj[Prop], [...Cache, Prop]>
}[keyof Obj]
type Result = FinalAttempt<Foo>
Finally we did it. Are You bored? Feel free to make a break.
So far we did only 50% of our work. Let's test it.
type FinalAttempt<Obj, Cache extends Array<PropertyKey> = []> =
Obj extends PropertyKey ? Cache : {
[Prop in keyof Obj]:
| [...Cache, Prop]
| FinalAttempt<Obj[Prop], [...Cache, Prop]>
}[keyof Obj]
declare function deepPick<Obj,>(obj: Obj, ...keys: FinalAttempt<Obj>): void
declare var foo: Foo;
deepPick(foo, 'user'); // ok
deepPick(foo, 'user', 'description') // ok
deepPick(foo, 'description') // expected error
What about our ReturnType
?
Btw, if You will try to write implementation for this function, You will get an errors:
function deepPick<Obj >(obj: Obj, ...keys: FinalAttempt<Obj>){
return keys.reduce((acc,elem)=>acc[elem], obj) // <-- errors
}
In order to make it work, we should assure TS that keys
has not any problems with infinity recursion and it is an array of strings. Further more, how would you type reducer predicate? Since, every iteration it returns different type.
We can type it in same way we typed union of keys (FinalAttempt
), but this time let's make a union of values. It returns all combinations of Foo
values.
type ValuesUnion<Obj, Cache = Obj> =
Obj extends Primitives ? Obj : Values<{
[Prop in keyof Obj]:
| Cache | Obj[Prop]
| ValuesUnion<Obj[Prop], Cache | Obj[Prop]>
}>
I also used here simple hasProperty
typeguard.
const hasProperty = <Obj, Prop extends Primitives>(obj: Obj, prop: Prop)
: obj is Obj & Record<Prop, any> =>
Object.prototype.hasOwnProperty.call(obj, prop);
function deepPick<Obj, Keys extends FinalAttempt<Obj> & Array<string>>
(obj: ValuesUnion<Obj>, ...keys: Keys) {
return keys
.reduce(
(acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc,
obj
)
}
Let's write type for picking the object property. I decided to implement deep picking exactly how we did it in a function. I think it will help You to understand how does it work under the hood. Ok, ok, You cought me. I just don't know how to do it in other way :)
type Elem = string;
type Acc = Record<string, any>
// (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc
type Predicate<Accumulator extends Acc, El extends Elem> =
El extends keyof Accumulator ? Accumulator[El] : Accumulator
type Reducer<
Keys extends ReadonlyArray<Elem>,
Accumulator extends Acc = {}
> =
/**
* If Keys is empty array, no need to call recursion,
* just return Accumulator
*/
Keys extends []
? Accumulator
/**
* If keys is one element array,
*
*/
: Keys extends [infer H]
? H extends Elem
/**
* take this element and call predicate
*/
? Predicate<Accumulator, H>
: never
/**
* If Keys is an Array of more than one element
*/
: Keys extends readonly [infer H, ...infer Tail]
? Tail extends ReadonlyArray<Elem>
? H extends Elem
/**
* Call recursion with Keys Tail
* and call predicate with first element
*/
? Reducer<Tail, Predicate<Accumulator, H>>
: never
: never
: never;
If it is hard to understand what I did here, no worries, it is hard for me either.
Here you have pure js analogy:
const reducer = (keys: string[], accumulator: Record<string, any> = {}) => {
const predicate = (obj,prop)=>obj[prop]
if (keys.length === 0) {
return accumulator;
}
if (keys.length === 1) {
const [head] = keys;
return reducer([], predicate(accumulator, head))
}
if(keys.length>1){
const [head, ...tail]=keys;
return reducer(tail, predicate(accumulator, head))
}
}
Ok, we are done. Here is the full code:
type Foo = {
user: {
description: {
name: string;
surname: string;
}
}
}
declare var foo: Foo;
/**
* Common utils
*/
type Primitives = string | number | symbol;
type Values<T> = T[keyof T]
type Elem = string;
type Acc = Record<string, any>
// (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc
type Predicate<Accumulator extends Acc, El extends Elem> =
El extends keyof Accumulator ? Accumulator[El] : Accumulator
type Reducer<
Keys extends ReadonlyArray<Elem>,
Accumulator extends Acc = {}
> =
/**
* If Keys is empty array, no need to call recursion,
* just return Accumulator
*/
Keys extends []
? Accumulator
/**
* If keys is one element array,
*
*/
: Keys extends [infer H]
? H extends Elem
/**
* take this element and call predicate
*/
? Predicate<Accumulator, H>
: never
/**
* If Keys is an Array of more than one element
*/
: Keys extends readonly [infer H, ...infer Tail]
? Tail extends ReadonlyArray<Elem>
? H extends Elem
/**
* Call recursion with Keys Tail
* and call predicate with first element
*/
? Reducer<Tail, Predicate<Accumulator, H>>
: never
: never
: never;
const hasProperty = <Obj, Prop extends Primitives>(obj: Obj, prop: Prop)
: obj is Obj & Record<Prop, any> =>
Object.prototype.hasOwnProperty.call(obj, prop);
/**
* Fisrt approach
*
*/
type KeysUnion<T, Cache extends Array<Primitives> = []> =
T extends Primitives ? Cache : {
[P in keyof T]:
| [...Cache, P]
| KeysUnion<T[P], [...Cache, P]>
}[keyof T]
type ValuesUnion<T, Cache = T> =
T extends Primitives ? T : Values<{
[P in keyof T]:
| Cache | T[P]
| ValuesUnion<T[P], Cache | T[P]>
}>
function deepPickFinal<Obj, Keys extends KeysUnion<Obj> & ReadonlyArray<string>>
(obj: ValuesUnion<Obj>, ...keys: Keys): Reducer<Keys, Obj>
function deepPickFinal<Obj, Keys extends KeysUnion<Obj> & Array<string>>
(obj: ValuesUnion<Obj>, ...keys: Keys) {
return keys
.reduce(
(acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc,
obj
)
}
/**
* Ok
*/
const result = deepPickFinal(foo, 'user') // ok
const result2 = deepPickFinal(foo, 'user', 'description') // ok
const result3 = deepPickFinal(foo, 'user', 'description', 'name') // ok
const result4 = deepPickFinal(foo, 'user', 'description', 'surname') // ok
/**
* Expected errors
*/
const result5 = deepPickFinal(foo, 'surname')
const result6 = deepPickFinal(foo, 'description')
const result7 = deepPickFinal(foo)
There is another approach to do it. It requires to use validation technique
.
I know, naming is awful :) I don't know how to name it in more meaningful way.
type Foo = {
user: {
description: {
name: string;
surname: string;
}
}
}
declare var foo: Foo;
type Primitives = string | number | symbol;
type Util<Obj, Props extends ReadonlyArray<Primitives>> =
Props extends []
? Obj
: Props extends [infer First]
? First extends keyof Obj
? Obj[First]
: never
: Props extends [infer Fst, ...infer Tail]
? Fst extends keyof Obj
? Tail extends string[]
? Util<Obj[Fst], Tail>
: never
: never
: never
// credits https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887
type IsNeverType<T> = [T] extends [never] ? true : false;
type IsAllowed<T> = IsNeverType<T> extends true ? false : true;
type Validator<T extends boolean | string> = T extends true ? [] : [never]
type ValuesUnion<T, Cache = T> =
T extends Primitives ? T : {
[P in keyof T]:
| Cache | T[P]
| ValuesUnion<T[P], Cache | T[P]>
}[keyof T]
const hasProperty = <Obj, Prop extends Primitives>(obj: Obj, prop: Prop)
: obj is Obj & Record<Prop, any> =>
Object.prototype.hasOwnProperty.call(obj, prop);
function pick<
Obj,
Prop extends string,
Props extends ReadonlyArray<Prop>,
Result extends Util<Obj, Props>>
(
obj: ValuesUnion<Obj>,
props: [...Props],
..._: Validator<IsAllowed<Result>>
): Util<Obj, Props>;
function pick<
Obj,
Prop extends string,
Props extends ReadonlyArray<Prop>,
Result extends Util<Obj, Props>>(
obj: ValuesUnion<Obj>,
props: [...Props],
..._: Validator<IsAllowed<Result>>) {
return props.reduce(
(acc, prop) => hasProperty(acc, prop) ? acc[prop] : acc,
obj
)
}
/**
* Ok
*/
const result8 = pick(foo, ['user', 'description']) // ok
const result9 = pick(foo, ['user', 'description', 'name']) // ok
/**
* Expected errors
*/
const result10 = pick(foo, ['description']) // error
const result11 = pick(foo, ['name']) // ok
That's all. Thanks you
Top comments (0)