Search code examples
typescripttypescript-typingstypescript-generics

Cannot use other string indexes when using a generic type inside of a type


Here is my code:

export type CommandOptionType = string | number | boolean;
export type CommandOption = Record<string, CommandOptionType>;

export type LeafCommand<T extends CommandOption> = {
    name: string;
    handle: (
        interactionOptionValues: { [K in keyof T]: T[K] },
    ) => Promise<void>;
};

export type LeafCommandGroup = {
    name: string;
    commands: Record<string, LeafCommand<CommandOption>>;
};

export type RootCommand = {
    name: string;
    commands: Record<string, LeafCommandGroup | LeafCommand<CommandOption>>;
};

Usage of these types:

type OPTIONS = {
    option1: "type1" | "type2" | "type3" | "type4";
    option2: string;
    option3: boolean;
};

const Type = {
    name: "type",
    handle: async (interactionOptionValues) => {
        // business logic here
    },
} satisfies LeafCommand<OPTIONS>;

The error occurs below code snippet:

const Root = {
    name: "root",
    commands: { // error occurs here
        [Type.name]: Type,
    },
} satisfies RootCommand;

Here, I'm getting the error which says Type { [x: string]: CommandOptionType } is missing the following properties from type. option1, option2, option3. I don't know why Typescript complains about other index keys which are strings as well. I couldn't find a work around.

Desired result is that I should be able to add commands which has type of LeafCommand without a hustle.

If you have any other design patterns that is cleverer, I'm down for it.

Thanks for your time.


Solution

  • Your LeafCommand<T> type is contravariant in T (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript) because T appears as the parameter type of a function. That means LeafCommand<T> extends LeafCommand<U> if U extends T, and not vice versa. So since OPTIONS extends CommandOption, you can only conclude that LeafCommand<CommandOption> extends LeafCommand<OPTIONS>. The opposite, which is what you're doing, is not true... because it's not safe. For example:

    let lcO: LeafCommand<OPTIONS> =
      { name: "", handle(o) { o.option2.toUpperCase(); return Promise.resolve() } };
    let lcCO: LeafCommand<CommandOption> = lcO; // error, for good reason!
    lcCO.handle({}); // error at runtime, o.option2 is undefined
    

    If you assign a LeafCommand<OPTIONS> to a LeafCommand<CommandOption>, then when you call the handle() method and give it a CommandOption, the implementation will assume that it got an OPTIONS, and if this assumption is wrong you can easily get runtime errors.


    If you're looking for a type to which all LeafCommand<T>s are assignable no matter what T is, then you need a type which is assignable to all Ts. The only way that will work is to use the never type:

    type RootCommand = {
      name: string;
      commands: Record<string, LeafCommandGroup | LeafCommand<never>>;
    };
    
    const Root = {
      name: "root",
      commands: { // okay now
        [Type.name]: Type,
      },
    } satisfies RootCommand;
    

    This might or might not end up satisfying your underlying use case (you'll find yourself unhappy if you try to call a handle() on LeafCommand<never>, but that's just a consequence of contravariance), but it's at least type safe.

    Playground link to code