javascript - Literais de Template do TypeScript quebram restrições genéricas
Estou tentando escrever uma classe Sub-Pub que suporte eventos E subeventos denotados pela seguinte sintaxe:
publisher.on("message:general", ... ) // subscribe to all messages
publisher.on("message", ... ) // subscribe to messages in general
Para fazer isso, estou usando literais de template do TypeScript.
O problema é que, embora funcione de maneira geral, parece quebrar as restrições genéricas. Estou fazendo algo errado?
Aqui está como está até agora:
Link do TS Playground para conveniência
interface ChatEvents {
connect: {
user: string;
};
disconnect: {
user: string;
reason: "banned" | "timeout" | "leave";
};
}
declare type ChatEvent = keyof ChatEvents;
interface SubEvents extends Record<ChatEvent, string> {
connect: "general" | "watercooler" | "lobby";
disconnect: "voice" | "text";
}
declare type EventWithSubEvent<T extends ChatEvent> = `${T}${
| ""
| `:${SubEvents[T]}`}`;
// "connect" | "connect:general" | "connect:watercooler" | "connect:lobby"
declare type ChatConnectEvent = EventWithSubEvent<"connect">;
// "disconnect" | "disconnect:voice" | "disconnect:text"
declare type ChatDisconnectEvent = EventWithSubEvent<"disconnect">;
// So far so good!
// Let's write some generics:
declare function subscribeToEvent<E extends ChatEvent>(
event: E,
callback: (payload: ChatEvents[E]) => void
): void;
subscribeToEvent("connect", (payload) => {
// Correctly extracts matching type
type TPayload = typeof payload; // { user: string; }
});
declare function subscribeToSubEvent<E extends ChatEvent>(
event: EventWithSubEvent<E>,
callback: (payload: ChatEvents[E]) => void
): void;
subscribeToSubEvent("connect:general", (payload) => {
// Extracts all possible payload types instead of only the `connect` one
/*
{
user: string;
} | {
user: string;
reason: "banned" | "timeout" | "leave";
}
*/
type TPayload = typeof payload;
});
subscribeToSubEvent("connect:voice", () => {}) // Should fail but doesn't. `:voice` is a subevent of `disconnect`, not `connect`.
Resposta #1
Primeiro, a razão pela qual isso não funcionou é que EventWithSubEvent<ChatEvent> se expande de uma forma diferente do que você quer, SubEvents["connect" | "disconnect"] dá diretamente os subeventos de ambos os eventos principais, então retorna uma união de todos os subeventos possíveis e então os templates de string veem uma união de "connect" | "disconnect" concatenada com uma união de todos os subeventos possíveis, então acaba combinando todas as combinações, não apenas as que correspondem. Uma maneira de corrigir isso é, em vez disso, percorrer cada opção na união e aplicar essa lógica individualmente. link do playground
// what you were originally doing
declare type EventWithSubEventWorse<T extends ChatEvent> = `${T}${
| ""
| `:${SubEvents[T]}`}`;
// maps each element of the union in T to the events individually then takes union of all options after
// instead of expanding the whole set into a literal in one go.
declare type EventWithSubEventBetter<T extends ChatEvent> = {[K in T]: EventWithSubEventWorse<K>}[T]
type WHAT_YOU_HAD = EventWithSubEventWorse<ChatEvent>
type WHAT_YOU_WANTED = EventWithSubEventBetter<ChatEvent>
type EventWithSubEvent<T extends ChatEvent> = EventWithSubEventBetter<T>;
Isso significa que, se removêssemos o genérico da nossa função e apenas disséssemos que o primeiro argumento precisa ser EventWithSubEvent<ChatEvent>, ele listaria apenas as opções válidas, no entanto, não fornece o melhor suporte de intellisense porque, se você estiver no meio de escrever o código e houver um estado em que você não está passando uma string válida, o TypeScript simplesmente não tem ideia de qual deveria ser o genérico, já que ele precisa conhecer o genérico para gerar opções válidas para o primeiro argumento. Então, podemos fazer um pouco melhor.
De modo geral, descobri que quanto menos o TypeScript precisa trabalhar para deduzir um tipo genérico a partir dos argumentos, mais os genéricos simplesmente funcionam (especialmente para fornecer uma ajuda decente de intellisense). Neste caso, se você configurar o genérico para ser o texto exato passado como o primeiro argumento (restrito a todas as opções válidas usando o objeto literal) e depois usar um tipo auxiliar para extrair apenas a parte 'connect' | 'disconnect' para usar no callback, funcionará muito melhor. (playground)
interface ChatEvents {
connect: {
user: string;
};
disconnect: {
user: string;
reason: "banned" | "timeout" | "leave";
};
}
interface SubEvents extends Record<keyof ChatEvents, string> {
connect: "general" | "watercooler" | "lobby";
disconnect: "voice" | "text";
}
type AllChatEvents = {[K in keyof ChatEvents]: K | `${K}:${SubEvents[K]}`}[keyof ChatEvents]
// helper to extract the 'connect'|'disconnect' from an event
type ExtractEvt<T extends AllChatEvents> = T extends keyof ChatEvents ? T : T extends `${infer A}:${string}` ? A : never
declare function subscribeToSubEvent<E extends AllChatEvents>(
event: E,
callback: (payload: ChatEvents[ExtractEvt<E>]) => void
): void;
subscribeToSubEvent("disconnect:voice", (payload) => {
console.log(payload.reason) // detects that 'disconnect' is the payload type
});
subscribeToSubEvent("connect:voice", () => {}) // fails properly
Como bônus, devido à restrição genérica ser todas as strings válidas para serem passadas, o Intellisense fornecerá resultados muito úteis em comparação com outras configurações de genéricos, onde ele não consegue descobrir qual é o comportamento correto até que você já tenha digitado:
Resposta #2
Eu acho que encontrei a solução.
O problema é que aparentemente os tipos de template não se distribuem sobre uniões
Para contornar o problema, dê uma olhada neste playground simplificado
O truque é especificar o tipo de template com um tipo genérico que estenda de any (apenas para forçar a distribuição da união)
declare type EventWithSubEvent<T extends ChatEvent> = T extends any ? `${T}:${SubEvents[T]}` : never;
Este é o seu playground fixo
