Home > front end >  Infer dot notation of nested property
Infer dot notation of nested property

Time:08-25

I want to achieve type safety on the deps array below. So the only allowed strings are dot notation of ${moduleX}.${moduleX service}

// Modules each have a factory function that can return a services object (async)
createModules({
  data: {
    factory: () => Promise.resolve(() => {
      return {
        services: {
          createResource: () => {}
        },
      };
    })
  },
  dependentModule: {
    deps: ['data.createResource'], // <!-- should allow this
    factory: () => Promise.resolve(() => ({}))
  },
  incorrectConfig: {
    deps: ['data.serviceDoesNotExist'], // <-- should error here
    factory: () => Promise.resolve(() => ({}))
  }
});

function createModules<M>(config: Config<M>) {}

export type Config<M> = {
  [K in keyof M]: ModuleConfig<M>
}

export type Config<M> = {
  [K in keyof M]: {
    deps?: (`${keyof M & string}.${keyof (Awaited<M[keyof M]['factory']>['services']) & string}`)[]
    factory: () => Promise<(...args: any) => { services?: { [key: string]: any } }>
  }
}

As I have it now, data.serviceDoesNotExist is allowed because it's just being inferred as a string:

deps?: (`data.${string}` | `dependentModule.${string}` | `incorrectConfig.${string}`)[] | undefined

How can I get the actual property name as the inference here?

Playground

CodePudding user response:

There a probably multiple solutions to solve this. Here is my way.

I would use two generic types M and S to store the module strings and service strings respectively.

function createModules<
  M extends string, 
  S extends string
>(config: Config<M, S>) {}

The Config type will then be a Record where the possible keys are M and the values are a ModuleConfig. We will pass both M and S to the ModuleConfig.

export type Config<
  M extends string, 
  S extends string
> = Record<M, ModuleConfig<M, S>>

In the ModuleConfig, we can infer S as the keys of the services object.

export type ModuleConfig<
  M extends string, 
  S extends string,
> = {
  deps?: /* ... */
  factory: () => Promise<(...args: any) => { services?: Record<S, any> }>
};

For the deps property it gets more technical. We have a bit of a chicken-egg problem going on here. If we just use M and S in the template string literal, TypeScript will actual prefer to use the contents of the passed strings to infer M and S instead of the keys of the objects. Which is totally correct but not what we want to happen since this would restrict the keys of the objects to whatever strings you put in the arrays and not the other way around.

I created a Copy utility type to prevent this.

type Copy<S extends string> = S extends infer U extends string ? U : never

export type ModuleConfig<
  M extends string, 
  S extends string,
> = {
  deps?: (`${Copy<M>}.${Copy<S>}`)[]
  factory: () => Promise<(...args: any) => { services?: Record<S, any> }>
};

This should do the trick.

Playground


Edit:

Here is a different solution which adresses the issues mentioned in the comments.

type Factory = () => Promise<(...args: any) => { services?: Record<string, any> }>

type ExtractServiceKeys<T extends Factory> = 
  keyof ReturnType<Awaited<ReturnType<T>>>["services"] & string

function createModules<
  T extends Record<string, {
    deps?: ({ 
      [K in keyof T]: `${K & string}.${ExtractServiceKeys<T[K]["factory"]>}`
    }[keyof T])[],
    factory: Factory
  }>,
>(config: T) {}

Playground

  • Related