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?
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.
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) {}