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.
Playground
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.
Playground
btw this is overengineered to ****; it always is when you start making types act like real code