Home > database >  How can I make a type that returns a type that accepts paths to string or string[] for that type?
How can I make a type that returns a type that accepts paths to string or string[] for that type?

Time:05-29

I am trying to define a type that can receive any type that is an object and it returns a type that only accepts dotted paths (prop1.prop2.prop3) to string or string[]. So far this is what I have:

type MyType<ObjectType extends Record<string, unknown>> = {
  [Key in keyof ObjectType & string]: ObjectType[Key] extends Record<
    string,
    unknown
  >
    ? MyType<ObjectType[Key]> extends ""
      ? ""
      : `${Key}.${MyType<ObjectType[Key]>}`
    : ObjectType[Key] extends Array<Record<string, unknown>>
    ? MyType<Unpacked<ObjectType[Key]>> extends ""
      ? ""
      : `${Key}.${MyType<Unpacked<ObjectType[Key]>>}`
    : ObjectType[Key] extends Array<string> | string
    ? `${Key}`
    : "";
}[keyof ObjectType & string];

The bad thing is that this type also returns the parent prop of a prop that is of type string or string[]. For example, if a have the following type:

type ExampleType = {
  detail: {
    prop1: string;
    subDetail: {
      field1: string;
      field2: number;
      field3: {
        inner: string[];
      };
      field4: number[];
    };
  };
  arrOfObjs: {
    name: string;
    age: number;
    ages: number[];
  }[];
};

Then MyType<ExampleType> incorrectly accepts arrOfObjs. and detail.subDetail., but it accepts, as expected: arrOfObjs.name, detail.prop1, detail.subDetail.field1 and detail.subDetail.field3.inner.

Can anyone point out what I'm missing? Thank you in advance for your time.

CodePudding user response:

I could not fix your type definition since it contains the type Unpacked that you did not specify in the question. But I could write a type that seems to achieve what you want.

type PathOfString<T, P extends string = ""> = {
    [K in keyof T & string]: T[K] extends Record<string, unknown> 
      ? PathOfString<T[K], `${P}${K}.`> extends infer S 
        ? `${S & string}` 
        : never 
      : T[K] extends string | string[] 
        ? `${P}${K}`
        : T[K] extends Record<string, unknown>[] 
          ? PathOfString<T[K][number], `${P}${K}.`> extends infer S 
            ? `${S & string}` 
            : never 
          : never
}[keyof T & string]

The logic of this type is quite simple: Its a recursive type that traverses the type T to find string or string[] leaves. We also have the type P which stores the Path of each branch.

We start by mapping over the keys of T and indexing the mapped type with [keyof T & string] to create a union.

{
  [K in keyof T & string]: /* ... */
}[keyof T & string]

For T[K] there are three different cases we have to handle.

  1. T[K] is an object. Here we recurively call PathOfString by passing T[K] and appending K to P with a dot at the end.
T[K] extends Record<string, unknown> 
  ? PathOfString<T[K], `${P}${K}.`> extends infer S 
    ? `${S & string}` 
    : never
  : /* ... */ 
  1. T[K] is either string of string[]. Here we can just append K to P and return it.
T[K] extends string | string[] 
  ? `${P}${K}`
  : /* ... */
  1. T[K] is an array of objects. We basically do the same thing we did for the object case but we pass all properties of the object inside the array to PathOfString with T[K][number].
T[K] extends Record<string, unknown>[] 
  ? PathOfString<T[K][number], `${P}${K}.`> extends infer S 
    ? `${S & string}` 
    : never 
  : never

You might have wondered why the extends infer S is necessary. Without it, the type does not fully evaluate for some reason. If you remove it, you can see that the type is technically still correct but not really human readable. Hover over the type here to see what I mean.


Let's see if it works:

type T0 = PathOfString<ExampleType>
// type T0 = "detail.prop1" | "detail.subDetail.field1" | "detail.subDetail.field3.inner" | "arrOfObjs.name"

Looks right to me.

Playground

  • Related