RobertoCuba
RobertoCuba

Reputation: 901

TypeScript Generic Constraint and 'keyof'

I have a class where I want the end-user to be able to specify a map of (arbitrary, user-defined) names for (arbitrary, user-defined) configuration objects that will type different properties and methods inside the class through generics.

My current working implementation looks like this:

// Configs should be objects. Primitives not allowed
// Incidentally, is there a better way to constrain a generic to be an object?
type ModConfig = Record<string, any>

type Operation<N extends string, C extends ModConfig> = {
    mod: N,
    config: C,
    status: string,
}

class Controller<N extends string, M extends Record<string, ModConfig>> {
    activeModule: N;

    private opsByMod: Operation<N, M[N]>[] = []

    private configs: { [K in N]?: M[K] } = {}

    constructor(startMod: N) {
        this.activeModule = startMod
    }

    addConfig<K extends N>(mod: K, config: M[K]) {
        this.configs[mod] = config;
    }
}

// Sample usage

interface ModAConfig {
    aNumber: number,
    aString: string,
}

interface ModBConfig {
    bBoolean: boolean,
    bObject: {
        bNested: string,
    }
}

type myModules = 'moduleA' | 'moduleB'

const cInstance = new Controller<myModules, { moduleA: ModAConfig, moduleB: ModBConfig }>('moduleA')

While this works, I would like to remove the generic param N as it is redundant if I could just use keyof M. Like this:

class Controller<M extends Record<string, ModConfig>> {
    // This is no longer 'string' but 'string | number | symbol'
    activeModule: keyof M;

    // TS Error:
    //  Type 'keyof M' does not satisfy the constraint 'string'.
    //  Type 'string | number | symbol' is not assignable to type 'string'
    private opsByMod: Operation<keyof M, M[keyof M]>[] = []

    // This also now takes not only 'string' but 'string | number | symbol' as keys
    private configs: { [K in keyof M]?: M[K] } = {}

    constructor(startMod: keyof M) {
        this.activeModule = startMod
    }

    // Same deal
    addConfig<K extends keyof M>(mod: K, config: M[K]) {
        this.configs[mod] = config;
    }
}

However as I commented above, keyof M produces string | number | symbol. This seems to be because M extends Record<string, ModConfig> which I guess is interpreted as numbers/symbols potentially being added as keys by the extension being supplied as parameter for M.

Is there a way to do this so that M is constrained strictly to only being keyed by strings so I can just substitute N with keyof M?

Upvotes: 0

Views: 1689

Answers (1)

Linda Paiste
Linda Paiste

Reputation: 42188

You can use this type to extract only keys of M which are strings.

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

Everywhere in your code that you were using keyof M, replace it with StringKeys<M>.

Now it's still theoretically possible for M to contain number keys, but those keys can't be used for setting the activeModule or getting an Operation.

class Controller<M extends Record<string, ModConfig>> {
    activeModule: StringKeys<M>;

    private opsByMod: Operation<StringKeys<M>, M[StringKeys<M>]>[] = []

    private configs: { [K in StringKeys<M>]?: M[K] } = {}

    constructor(startMod: StringKeys<M>) {
        this.activeModule = startMod
    }

    addConfig<K extends StringKeys<M>>(mod: K, config: M[K]) {
        this.configs[mod] = config;
    }
}

Playground Link

Upvotes: 4

Related Questions