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
.
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.