Home > Blockchain >  Define a function return value as a type of reading a path in object
Define a function return value as a type of reading a path in object

Time:12-07

I would like to create a simple helper function to read a path from an object like this:

interface Human {
  address: {
    city: {
      name: string;
    }
  }
}

const human: Human = { address: { city: { name: "Town"}}};
getIn<Human>(human, "address.city.name"); // Returns "Town"

This helper is easy to create in JS, but making it type safe in TS is a bit more complicated. I have got this far:

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, ...0[]];

type Join<K, P> = K extends string | number
  ? P extends string | number
    ? `${K}${"" extends P ? "" : "."}${P}`
    : never
  : never;

type Path<T, D extends number = 4> = [D] extends [never]
  ? never
  : T extends object
  ? {
      [K in keyof T]-?: K extends string | number
        ? `${K}` | Join<K, Path<T[K], Prev[D]>>
        : never;
    }[keyof T]
  : "";

function getIn<T extends Record<string, any>>(object: T, path: Path<T>): T {
  const parts = path.split(".");
  return parts.reduce<T>((result, key) => {
    if (result !== undefined && result[key]) {
      return result[key];
    }

    return undefined;
  }, object);
}

This works, but what is wrong here is that the return type of getIn should not be T, but something inside T, depending on the given path. So if called like this:

getIn<Human>(human, "address.city.name"); // Returns "Town"

TypeScript should know that the return value is a string, as defined in the Human interface. If given "address.city", the return type should be City etc.

Is there any way to make it type safe?

CodePudding user response:

I'm mostly going to be concerned with getting the typings right for the call signature of getIn(). It'll be a generic call signature involving recursive conditional types that use template literal types to parse and manipulate string literal types. There's no way for the compiler to verify that the return value will be assignable to such a complicated type, so the implementation is going to need one or more type assertions to avoid errors. All this means: take care when implementing the function to make sure you're doing it right; the compiler won't catch a mistake because you're going to throw as any in there with abandon until it compiles.

Here's the basic plan:

declare function getIn<T extends object, K extends ValidatePath<T, K>>(
  object: T,
  path: K
): DeepIdx<T, K>;

The idea is that we will define two utility types:

  • ValidatePath<T, K> will take an object type T and a string type K representing a dotted path to a property of T. If K is a valid path for T, then ValidatePath<T, K> will be equivalent to K. If it is an invalid path for T, then ValidatePath<T, K> will be some valid path which is "close" to K, for some definition of "close". The hope is that we could constrain K extends ValidatePath<T, K> so that valid paths will be accepted, and invalid paths will generate an error message that suggests a valid path.

  • DeepIdx<T, K> will take an object type T and a string type K representing a dotted path to a property of T, and then DeepIdx<T, K> will be the type of the property of T at path K.

Before we define those, we have to fix the call signature. The compiler will almost certainly complain that K extends ValidatePath<T, K> is an invalid circular constraint. We can work around that by only constraining K to string and then writing a (fairly ugly) conditional type for the path parameter which will evaluate to the desired ValidatePath<T, K>. It looks like this:

declare function getIn<T extends object, K extends string>(
  object: T,
  path: K extends ValidatePath<T, K> ? K : ValidatePath<T, K>
): DeepIdx<T, K>;

Okay, now for the implementations:

type ValidatePath<T, K extends string> =
  K extends keyof T ? K :
  K extends `${infer K0}.${infer KR}` ?
  K0 extends keyof T ? `${K0}.${ValidatePath<T[K0], KR>}` : Extract<keyof T, string>
  : Extract<keyof T, string>

type DeepIdx<T, K extends string> =
  K extends keyof T ? T[K] :
  K extends `${infer K0}.${infer KR}` ?
  K0 extends keyof T ? DeepIdx<T[K0], KR> : never
  : never

In both cases, we walk through K. If K is a key of T, then it's a valid path, and we're looking at the T[K] property. If K is a dotted path, then we look at the part K0 before the first dot. If that is a key of T, then the first part is a valid path, and we need to recurse into T[K0] with a path of the part KR after the first dot. If K0 is not a key of T, then we have an invalid path, and so Extract<keyof T, string> is the "close" valid path (using the Extract<T, U> utility type to discard any non-string keys). And if K is neither a key of T nor a dotted path, then it's invalid, an so Extract<keyof T, string> is the "close" valid path also.


All right, let's test it out:

const human: Human = { address: { city: { name: "Town" } } };
const addr = getIn(human, "address");
// const addr: { city: { name: string; }; }
console.log(addr) // {city: {name: "Town"}}
const city = getIn(human, "address.city");
// const city: { name: string; }
console.log(city) // {name: "Town"}
const town = getIn(human, "address.city.name");
// const town: string
console.log(town) // "Town"

getIn(human, "address.city.neam"); // error!
// Argument of type '"address.city.neam"' is not 
// assignable to parameter of type '"address.city.name"'

getIn(human, ""); // error!
// Argument of type '""' is not assignable to 
// parameter of type '"address"'

getIn({ a: 1, b: 2, c: 3 }, "z") // error!
// Argument of type '"z"' is not assignable to 
// parameter of type '"a" | "b" | "c"'.

Looks good. All the valid paths are accepted, and the output type is correct. Meanwhile the invalid paths all generate errors which make a suggestion about what the correct path should have been.

Playground link to code

  • Related