Por que o TypeScript não consegue inferir o tipo do argumento de callback com base em genéricos
Eu esperava que o TypeScript inferisse o tipo do argumento de callback como número no seguinte exemplo, mas estou recebendo um erro.
type EventWatch = {
<K extends keyof User>(eventName: `${string & K}Changed`, callback: (e: User[K]) => void): void;
};
type User = {
firstname: string;
lastname: string;
age: number;
on: EventWatch;
}
let john: User = {
firstname: 'John',
lastname: 'Doe',
age: 33,
on(eventName, callback) {
if (eventName == 'ageChanged') {
callback(133) //ERROR:
/*
TS2345: Argument of type 'number' is not assignable to parameter of type 'User[K]'.
Type 'number' is not assignable to type 'never'.
*/
}
}
};
Ele infere corretamente o tipo eventName e reconhece que deve ser um dos três valores: "firstnameChanged", "lastnameChanged", "ageChanged". Mas ele não consegue descobrir seu valor.
Os argumentos que eu passo para a função são resolvidos corretamente e ela dá um erro se eu enviar um argumento errado, como deveria.
john.on('ageChanged', e => {}); //No Error
john.on('salaryChanged', e => {}); //Error: There is no such a key named salary.
Por que ele não irá inferir o tipo do argumento de callback?
Resposta #1
O problema é que o TypeScript não usa análise de fluxo de controle para restringir ou estreitar parâmetros de tipos genéricos. Portanto, quando você verifica eventName == 'ageChanged', embora o tipo da variável eventName possa ser estreitado, nada acontecerá com K. Antes da verificação, o compilador sabe que K extends keyof User, e a verificação não faz nada para mudar isso. Em particular, K não é re-restrito para K extends "age" (e de fato tal estreitamento não seria seguro, já que talvez K seja o tipo de união completo keyof User). E, portanto, callback de tipo (e: User[K]) => void não é conhecido como sendo do tipo (e: number) => void, e você recebe um erro:
on(eventName, callback) {
// (parameter) eventName: `${K}Changed`
// (parameter) callback: (e: User[K]) => void
if (eventName == 'ageChanged') {
// (parameter) eventName: `${K}Changed` still
// (parameter) callback: (e: User[K]) => void still
callback(133) // error
}
}
Existem várias solicitações de recursos no GitHub para permitir algum modo de restringir parâmetros de tipo genérico com base no fluxo de controle. Veja microsoft/TypeScript#24085 e microsoft/TypeScript#27808 para alguns deles (e eles fazem referência a outros). Quem sabe quando ou mesmo se isso será implementado. Por enquanto, a mudança mais fácil é suprimir o erro com uma asserção de tipo para informar ao compilador o que ele não sabe:
on(eventName, callback) {
if (eventName == 'ageChanged') {
(callback as (e: number)=>void)(133) // okay
}
}
Mas isso não é mais seguro quanto ao tipo.
Em vez de usar genéricos aqui, você pode considerar dar ao on() um parâmetro rest de uma união de tuplas, porque há apenas uma lista finita de tipos de pares eventName/callback permitidos. E como o parâmetro eventName é um tipo literal de string, isso significa que sua união será uma união discriminada em que verificar eventName automaticamente restringirá callback também.
A ideia é mais ou menos assim:
type EventWatchParams =
[eventName: "firstnameChanged", callback: (e: string) => void] |
[eventName: "lastnameChanged", callback: (e: string) => void] |
[eventName: "ageChanged", callback: (e: number) => void] |
[eventName: "onChanged", callback: (e: EventWatch) => void];
type EventWatch = {
(...args: EventWatchParams): void;
};
Observe como isso significa que você só pode chamar um EventWatch com dois parâmetros, onde os tipos do primeiro e do segundo parâmetro dependem um do outro. Claro, você não quer escrever EventWatchParams manualmente; você gostaria que o compilador o calculasse em termos de User automaticamente. Bem, você pode fazer isso através de um tipo de objeto distributivo (como cunhado em ms/TS#47109), onde você imediatamente indexa em um tipo mapeado, assim:
type EventWatchParams = {
[K in keyof User]: [eventName: `${K}Changed`, callback: (e: User[K]) => void]
}[keyof User]
Você pode verificar que EventWatchParams definido desta forma é equivalente à união de antes.
Então, como funciona com esta versão de união discriminada de tuplas-resto? Bem, com o TypeScript 4.5 e versões anteriores você precisará usar ...args e indexar nele com índices numéricos. É um pouco feio, mas é verificado como seguro em termos de tipo:
on(...args) {
if (args[0] == 'ageChanged') {
args[1](133) // okay
}
}
O estreitamento funciona; se args[0] for ageChanged, então args[1] é estreitado para (e: number) => void.
Quando o TypeScript 4.6 chegar, você pode melhorar isso um pouco destruindo args nos nomes de variáveis desejados e a análise de fluxo de controle ainda funcionará:
// TS4.6+
on(...args) {
const [eventName, callback] = args;
if (eventName == 'ageChanged') {
callback(133) // okay
}
}
Isso é praticamente o melhor que você pode conseguir, no entanto. A versão ideal em que você escreve on(eventName, callback) em vez de on(...args) está aguardando no microsoft/TypeScript#46680 e não está claro se isso será resolvido em breve.
Link do playground para o código
