Issue
I’m trying to replace the string types in the following function with more specific types that ensure type safe property access:
import {get} from 'lodash';
const obj = {
foo: 'foo',
bar: {
a: 'Hello',
b: {c: 'World'}
}
};
function factory(namespace?: string) {
return function getter(key: string) {
return get(obj, [namespace, key].filter((part) => part != null).join('.'));
};
}
const getter = factory('bar');
getter('b.c'); // 'World'
The dot notation indicates a nested property access. It can be present both in the namespace
as well as in the key
.
So far I’ve found out that I can type namespace
with this utility:
type NestedKeyOf<ObjectType extends object> =
{
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: `${Key}`
}[keyof ObjectType & (string | number)];
Usage: namespace?: NestedKeyOf<typeof obj>
.
I’m struggling however to come up with a dynamic type that is automatically assigned to key
.
Two additional requirements would be:
- A namespace should only ever resolve to an object (no leaf strings).
- A getter should always resolve to a leaf string (no objects).
It can be assumed that only objects and strings are present in the object, nothing else.
// Test cases
// Should pass
factory()('foo')
factory('bar')('a')
factory('bar')('b.c')
// Invalid property access
// @ts-expect-error
factory('baz')
// @ts-expect-error
factory('bar')('d')
// Only partial namespaces are allowed
// @ts-expect-error
factory('foo')
// Getter calls need to resolve to a leaf string
// @ts-expect-error
factory('bar')('b')
Any help would be really appreciated! Many thanks!
Solution
I can’t believe this abomination actually works:
function factory<NestedKey extends NestedKeyOf<typeof obj>>(namespace?: NestedKey) {
return function getter<
TargetKey extends
(NestedKey extends undefined
? NestedKeyOf<typeof obj>
: NestedKeyOf<Get<typeof obj, NestedKey>>)
>(key: TargetKey): NestedKey extends undefined ? Get<typeof obj, TargetKey> : Get<typeof obj, `${NestedKey}.${TargetKey}`> {
return get(obj, [namespace, key].filter((part) => part != null).join('.'));
};
}
Allow me to explain. First of all, I had a lot of trouble with your implementation of NestedKeyOf 🙁
I had to rewrite it because it reported that type instantiation was too deep, so here is my version:
type NestedKeyOf<O> = O extends object ? {
[K in keyof O]: `${K & string}` | `${K & string}.${NestedKeyOf<O[K]>}`;
}[keyof O] : never;
It does the same thing; just written differently. Next we need to be able to "deep get" a property (mimicking lodash’s get function):
type Get<O, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof O ? Get<O[Key], Rest> : never
: P extends keyof O ? O[P] : never;
The extra extends keyof O
are there to prevent errors on invalid property access.
And finally the monstrosity I showed above.
We need to store what namespace
is, so we use a generic. With this generic aptly named NestedKey
we can now use it in the definition of getter
.
getter
also takes a nested key. However its type differs when namespace
is not provided.
That is why NestedKey extends undefined
is there. If it is not present, then key
should be a nested key of the original object. Otherwise, it is a nested key of the value that namespace
points to, using Get
.
Finally in the return value, we do the same thing. If NestedKey
is not present, then we deep get the target key, otherwise, we deep get the nested key and the target key.
The as const
assertion is to verify that it is actually deep getting the correct value.
Update according to the new requirements. We’ll need some new types to tell us which keys are objects and which are strings:
type GetObjectKeys<O, K extends string> = {
[P in K]: Get<O, P> extends string ? never : P;
}[K];
type GetStringKeys<O, K extends string> = {
[P in K]: Get<O, P> extends string ? P : never;
}[K];
They take an object type, and some potential keys. It checks each key’s type. In the case of objects, if it is a string, it is never
, otherwise P
. We use never
because below we will get a union of all the remaining keys with [K]
and T | never
simplifies to T
.
Then because of namespace
is optional it introduces some difficulties. An error on my part was believing that if namespace
was not provided, NestedKey
would be undefined (so the previous answer is actually incorrect). This is corrected later.
To address the optional namespace
, we make the original factory
function’s parameter namespace
not optional and rename it to _factory
(internal/private). Then we create a new factory
function that calls it like so:
function factory<NestedKey extends GetObjectKeys<typeof obj, NestedKeyOf<typeof obj>>>(namespace?: NestedKey) {
return _factory<
{ __private: typeof obj },
GetObjectKeys<typeof obj, NestedKeyOf<typeof obj>> extends NestedKey ? "__private" : `__private.${NestedKey}`
//@ts-ignore Unfortunately I don't think there is a good way to prevent this error
>({ __private: obj }, namespace ? `__private.${namespace}` : "__private");
}
It expects any object keys, but if the namespace was not provided it will create default to __private
, because we wrap the target object in another object with a property __private
to get around the fact that the namespace is optional. Think of this as a delegator.
Now for the modified _factory
function:
function _factory<Obj extends unknown, NestedKey extends NestedKeyOf<Obj>>(obj: Obj, namespace: NestedKey) {
return function getter<
TargetKey extends GetStringKeys<Get<Obj, NestedKey>, NestedKeyOf<Get<Obj, NestedKey>>>
>(key: TargetKey): Get<Obj, `${NestedKey}.${TargetKey}`> {
return get(obj, [namespace, key].filter((part) => part != null).join('.'));
};
}
This function is mostly the same as the original except now it only expects keys that result in strings. The part that handles the optional namespace
is moved into the new factory
function.
I could’ve chosen better names for factory
and _factory
to avoid confusion, but hopefully you’ve followed me through this well enough.
btw this is overengineered to ****; it always is when you start making types act like real code 😛
Answered By – kellys
Answer Checked By – Candace Johnson (BugsFixing Volunteer)