Search code examples
typescript

Is it possible to inherit type based on keyof template in typescript?


I was trying to do a little hack. I can see how for example DOM ts files map different element names to their types.

I was wondering if I could make it so that a type MyType extends a different set of fields based on what a string value is. This is my attempt:

interface MessagesToContentMap {
   "error": {message: string},
   "success": {result: number}
}

Now I want to inherit this in a general message, sort of backwards of what you'd normally do:

interface GenericMessage<KType extends keyof MessagesToContentMap> extends MessagesToContentMap[KType] {
    type: KType
}

The main intention behind is that I hoped Javascript would infer the message type for if statements like this:

const msg: GenericMessage<any> = {};
if(msg.type === "error") {
    // type system should know this is {message: string, type: "error"}
}

This already works with typeof, so I was trying to see how fat can I push it. However the code I made does not work, it will not hint anything except for type property. It does hint that property correctly with options "error" and "success".

I also tried this, which works but does not match the actual messages I will be receiving:

interface GenericMessage<KType extends keyof MessagesToContentMap> {
    type: KType;
    data: WorkersInternal.MessagesToContentMap[KType];
}

It still did require explicit cast too. Ie (testMessage as GenericMessage<"wrk">).data.


Solution

  • You're not really looking for a generic type; instead you want a discriminated union of the form

    type Message = { type: "error"; message: string; } | { type: "success"; result: number; }
    

    That will give you the narrowing behavior you're looking for:

    declare const msg: Message;
    if (msg.type === "error") {
        msg.message.toUpperCase();
    } else {
        msg.result.toFixed();
    }
    

    All that remains is to compute Message from the definition of MessagesToContentMap so that you don't have to write it out manually. Given

    interface MessagesToContentMap {
        "error": { message: string },
        "success": { result: number }
    }
    

    you can write Message as a distributive object type (as coined in microsoft/TypeScript#47109) like this:

    type Message = { [K in keyof MessagesToContentMap]:
        { type: K } & MessagesToContentMap[K]
    }[keyof MessagesToContentMap]
    

    The part in the middle, { type: K } & MessagesToContentMap[K] is essentially the generic thing you were trying to make. You can't use interface extension to combine {type: K} with MessagesToContentMap[K] because the latter is not a type with statically known keys. But you can use intersection to get that behavior. Note that {type: "error"} & {message: "string"} is equivalent to {type: "error"; message: "string"}.

    Then this is wrapped with {[K in keyof MessagesToContentMap]: ⋯}[keyof MessagesToContentMap]. That's what makes it a distributive object type, where the result is the union of the part in the middle for each member K of the keyof MessagesToContentMap]. We're distributing the part in the middle over the union. (This works by mapping over each property to a new object type and then indexing into that object type with all the keys, getting a union).

    You can verify that the if/else behavior above is the same with the new definition of Message. And now if you modify MessagesToContentMap, the Message type will automatically update.


    Playground link to code