ricardo-dlc
ricardo-dlc

Reputation: 486

Generic function to get a nested object value

The problem itself is more complex than this but I'll try to explain in a simple way.

I have an object (it could be a different object, lets say a car o user or whatever).

const car = {
  model: 'Model A',
  specs: {
    motor: {
      turbo: true
    }
  }
};

Then I have a function to get a value from a property name.

interface Data<T, K extends keyof T> {
  keynames: string[];
  values: Array<T[K] | null>;
}

const getValues = <T, K extends keyof T>(keynames: string[], data: T): Data<T, K> => {
  const values: Array<T[K] | null> = [];

  keynames.forEach(keyname => {
    const value: T[K] | undefined = getObjectValue(data, keyname);
    if (typeof value === 'undefined') {
      values.push(null);
    } else {
      values.push(value);
    }
  });

  return {
    keynames,
    values
  }
};

// This works with key.name syntax, specs.motor.turbo returns true for example
const getObjectValue = <T, K extends keyof T>(
  object: T,
  keyName: string
): T[K] | undefined => {
  const keys = keyName.split('.');
  if (keys.length === 1) {
    if (typeof object === 'object') {
      if (keys[0] in (object as T)) {
        return (object as T)[keys[0] as K];
      }
      return undefined;
    }
    return undefined;
  } else {
    const [parentKey, ...restElements] = keys;
    if (!object) return undefined;
    return (getObjectValue(
      (object as T)[parentKey as K],
      restElements.join('.')
    ) as unknown) as T[K];
  }
};

The problem becomes when I write some tests for example:

// ...
const data = getValues(['model', 'specs.motor.turbo'], car);
assert.deepEqual(data, {
  keynames: ['model', 'specs.motor.turbo'],
  values: ['Model A', true],
});
// ...

data is what I'm expecting, my function returns the same object as expected but I'm getting an error:

Type 'boolean' is not assignable to type 'string | { motor: { turbo: boolean; }; }'.ts(2322)

which is obvious since I'm only inferring T[K] in my function and not the type of the nested properties.

How to achieve that, so my function works with return types string | { motor: { turbo: boolean; }; } | { turbo: boolean; } | boolean. Or maybe there is a simpler way to return a nested property value.

Upvotes: 2

Views: 848

Answers (1)

jcalz
jcalz

Reputation: 329598

I'm going to preserve your implementation as much as possible, even though there might be some improvements there that someone would suggest. I'm primarily taking this question as "how can I tell the TypeScript compiler what getValues() is doing?".

First, we need to represent what comes out when you deeply index into a type T with a dotted keypath K. This is only going to be possible in TypeScript 4.1 and later, since the following implementation relies both on template literal types as implemented in microsoft/TypeScript#40336, and recursive conditional types as implemented in microsoft/TypeScript#40002:

type DeepIndex<T, K extends string> = T extends object ? (
  string extends K ? never :
  K extends keyof T ? T[K] :
  K extends `${infer F}.${infer R}` ? (F extends keyof T ?
    DeepIndex<T[F], R> : never
  ) : never
) : never

The general plan here is to check if K is a key of T; if so, we do the lookup with T[K]. Otherwise we split K at the first dot into F and R, and recurse downward, indexing into T[F] with the key R. If at any point something goes wrong (F is not a valid key of T, for example), this returns never instead of property type. We will use this; if something becomes never then we assume that there was a bad index:

type ValidatePath<T, K> =
  K extends string ? DeepIndex<T, K> extends never ? never : K : never;

The type ValidatePath<T, K> will extract from the (possible union of keys) K just those members which are valid paths.


Before we get into it, let's also represent what you do when you replace undefined with null:

type UndefinedToNull<T> = T extends undefined ? null : T;

For your code, I'm going to go ahead and use tuple types to represent the keynames and values arrays in Data. This should fall back to unordered arrays if necessary, but it seems weird to throw away information you know; for example, on your car example, you know that the first element of the values array is a string and the second is a boolean. A type like [string, boolean] has more information than Array<string | boolean | null>:

interface Data<T, K extends string[]> {
  keynames: K;
  values: { [I in keyof K]: UndefinedToNull<DeepIndex<T, Extract<K[I], string>>> };
}

The values property uses a mapped array/tuple to convert the tuple of keynames in K to a tuple of deep indexed values.

Now we need to give strong types to your function signatures. The compiler won't be able to verify a lot of the type safety inside of the implementations, so I'll be doing a lot of type assertions with as to force the compiler to accept what we're doing:

const getValues = <T, K extends string[]>(keynames: (K & { [I in keyof K]: ValidatePath<T, K[I]> }) | [], data: T): Data<T, K> => {

  const values = [] as { [I in keyof K]: UndefinedToNull<DeepIndex<T, Extract<K[I], string>>> };
  const _keynames = keynames as K;
  _keynames.forEach(<I extends number>(keyname: K[I]) => {
    const value: DeepIndex<T, Extract<K[I], string>> | undefined = getObjectValue(data, keyname as any);
    if (typeof value === 'undefined') {
      values.push(null as UndefinedToNull<DeepIndex<T, K[I]>>);
    } else {
      values.push(value as UndefinedToNull<DeepIndex<T, K[I]>>);
    }
  });

  return {
    keynames: _keynames,
    values
  }
};

const getObjectValue = <T, K extends string>(
  object: T,
  keyName: K & ValidatePath<T, K>
): DeepIndex<T, K> | undefined => {
  const keys = keyName.split('.');
  if (keys.length === 1) {
    if (typeof object === 'object') {
      if (keys[0] in (object as T)) {
        return (object as T)[keys[0] as keyof T] as DeepIndex<T, K>;
      }
      return undefined;
    }
    return undefined;
  } else {
    const [parentKey, ...restElements] = keys;
    if (!object) return undefined;
    return (getObjectValue(
      (object as T)[parentKey as keyof T],
      restElements.join('.') as any as never
    ) as unknown) as DeepIndex<T, K>;
  }
};

I wouldn't worry too much about the assertions inside the implementation. The important piece here is the call signature to getValues():

<T, K extends string[]>(keynames: (K & { [I in keyof K]: ValidatePath<T, K[I]> }) | [], data: T) => Data<T, K>

We are interpreting the keynames as something assignable to an array of string values. The | [] at the end is just a hint that the compiler should prefer viewing, say, ['model', 'specs.motor.turbo'] as an ordered pair instead of an unordered array. The intersection with {[I in keyof K]: ValidatePath<T, K[I]>}, a mapped tuple, should be a no-op is all the elements of K are valid paths into T. If any element of K is an invalid path, though, that element of the mapped tuple will be never and the validation will fail.


Let's test it:

const car = {
  model: 'Model A',
  specs: {
    motor: {
      turbo: true
    }
  }
}

const data = getValues(['model', 'specs.motor.turbo'], car);
data.keynames; // ["model", "specs.motor.turbo"]
data.values; //  [string, boolean]
console.log(data);

This looks like exactly what you want with your example. What happens if we misspell a key?

getValues(['model', 'specs.motar.turbo'], car); // error!
// ---------------> ~~~~~~~~~~~~~~~~~~~
// Type 'string' is not assignable to type 'undefined' 🤷‍♂️

We get an error on the offending key. The error message isn't that useful, unfortunately. When I tried to use the answer to this question to give the compiler a full list of exactly which dotted paths to accept, it caused a massive slowdown and even some "type instantiation is excessively deep or infinite" errors. So while it would be nice to see "specs.motar.turbo" is not assignable to "model" | "specs" | "specs.motor" | "specs.motor.turbo"`, sadly I can't get that to happen in a reasonable way.

What if there could be an undefined value at the property in question?

const d2 = getValues(['a.b'], { a: Math.random() < 0.5 ? {} : { b: "hello" } });
d2.keynames; // ["a.b"]
d2.values; // [string | null]
console.log(d2);

It comes out as | null instead of | undefined, which is good.


So that works as well as I could get it to. There are undoubtedly limitations and edge cases. Deep indexing with dotted keys is kind of near the edge of what works in TypeScript (before 4.1 is was significantly past the edge, so that's something, right?). For example, I'd expect weird/bad things to happen with optional or union-typed properties at non-leaf nodes of the object tree. Or objects with string index signatures, for that matter. These might be addressable, but it would take some effort and a lot of testing. The point is: tread carefully.


Playground link to code

Upvotes: 4

Related Questions