[SOLVED] Resolve object recursively with type safe property access in two steps

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:

  1. A namespace should only ever resolve to an object (no leaf strings).
  2. 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.

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 😛

Answered By – kellys

Answer Checked By – Candace Johnson (BugsFixing Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *