Owen Allen
Owen Allen

Reputation: 11968

Create dot pathed type definition based on recursive object

In TypeScript I have a have a nested object of functions of type:

type CallbackFn = (args: any) => any
type CallbackObj = {
  [key: string]: CallbackFn | CallbackObj
}
const callbacks = {
  foo: function(args: { x: num }): string {
    return "test";
  },
  bar: {
    bar1: function(): boolean {
      return true;
    },
    bar2: function(): number {
      return 10;
    }
  },
  baz: {
    baz1: {
      baz2: function(args: { key: string }): string {
        return "test";
      }
    }
  }
}

In another part of the system I have an interface definition that looks like this:

interface FieldDef {
  name: string
  type: string
  callback: CallbackDef
}

interface CallbackDef {
  fn: string
  settings: any
}

The ultimate goal is that when a user declares what callback to use for a specific FieldDef it auto-completes the possible callback fns, and then auto-completes the settings that callback might take. So in the above cases the possible fn entries are "foo" | "bar.bar1" | "bar.bar2" | "baz.baz1.baz2" and the settings depends on specific fn referenced in the definition. You can see the fn names are the concantenated dot paths of the nesting of the callbacks. My current attempts have been to build a discriminated union. For example, if I could generate the following union, it should work, I think.

type CallbackDef = {
  name: "foo",
  settings: {
    x: num
  }
} | {
  name: "bar.bar1"
} | {
  name: "bar.bar2"
} | {
  name: "baz.baz1.baz2",
  settings: {
    key: string
  }
}

I can't figure out how to generate that union dynamically based on the code-declared callbacks object. I'm hitting two problems. First, it's clear I need a recursive type here to make this possible, since the nested levels can go many deep. Second, a normal { [key in keyof T]: something } hasn't worked out great because when processing each given object you either return 1 function possibility or, if it's an object, return MULTIPLE function. So it's almost like I want to a spread type of type definition, or have each level return a union of the possibilities at that level. The closest I have is the following:

type CallbackFn = (args: any) => any
type CallbackObj = {
    [key: string]: CallbackFn | CallbackObj
}
const callbacks = {
    foo: function(args: { x: number }): string {
        return "test";
    },
    bar: {
        bar1: function(): boolean {
            return true;
        },
        bar2: function(): number {
            return 10;
        }
    },
    baz: {
        baz1: {
            baz2: function(args: { key: string }): string {
            return "test";
            }
        }
    }
}

type StringKeys<T> = Extract<keyof T, string>;

type Process<T> = {
    [key in StringKeys<T>]: T[key] extends CallbackFn
    ? { [k in key]: T[key] }
    : {
        [k in StringKeys<T[key]> as `${key}.${k}`]: T[key][k]
    }
}

type GetValues<T> = T[keyof T];

type A = Process<typeof callbacks>
type B = GetValues<A>

Playground

There might be an easier way to approach this problem. Any help would be greatly appreciated.

Upvotes: 1

Views: 64

Answers (1)

Tobias S.
Tobias S.

Reputation: 23865

type DerivePathAndSettingsUnion<
  T, 
  Path extends string = "", 
  K extends keyof T = keyof T
> = 
  K extends K
    ? T[K] extends (arg: any) => any
      ? Parameters<T[K]>[0] extends undefined
        ? {
            name: `${Path}${K & string}`
          }
        : {
            name: `${Path}${K & string}`,
            settings: Parameters<T[K]>[0]
          }
      : T[K] extends object 
        ? DerivePathAndSettingsUnion<T[K], `${Path}${K & string}.`>
        : never
    : never

The type DerivePathAndSettingsUnion recursively traverses the object type and takes three generic parameters.

  • T is the current object type

  • Path is the current path represented by a dotted string. We use a default value of "" for the initial call.

  • K represents the keys of T and is always defaulted to keyof T. It will never be explicitly provided and is just a shortcut for getting the keys into a generic type.

We distribute over the keys of T with K extends K and check if K is a function type.

  • If it is, we can check if the Parameters<T[K]>[0] are undefined and create the object with name and settings property based on the result.

  • If not, T[K] must be an object and we can recursively call DerivePathAndSettingsUnion with T[K] and the concatenated Path, K and a . at the end. I also added an additional check T extends object here which prevents infinite loops if there are any primitives in the object type.


We end up with the following result:

type Result = DerivePathAndSettingsUnion<typeof callbacks>

// type Result = {
//     name: "foo";
//     settings: {
//         x: number;
//     };
// } | {
//     name: "bar.bar1";
// } | {
//     name: "bar.bar2";
// } | {
//     name: "baz.baz1.baz2";
//     settings: {
//         key: string;
//     };
// }

Playground

Upvotes: 1

Related Questions