Deixe de definir os eventos DOM como «any»: Um guia prático sobre TypeScript
Se você já escreveu event: any num handler só pra fazer o erro do compilador calar a boca, não precisa se sentir mal. Quase todo mundo que trabalha com TypeScript e DOM passou por isso pelo menos uma vez. O problema é que essa saída rápida costuma voltar pra te morder depois, geralmente numa sexta à tarde, quando alguém clica num botão e o event.target.value simplesmente não existe porque o TypeScript nunca soube que aquele alvo era um input.
Vamos pegar um exemplo bem comum. Você tem um listener de clique e quer ler o valor de um campo de texto:
document.querySelector('input')?.addEventListener('click', (event) => {
console.log(event.target.value); // erro: Property 'value' does not exist on type 'EventTarget'
});
O TypeScript reclama porque event.target é tipado como EventTarget, uma interface bem genérica que não sabe nada sobre inputs, botões ou qualquer elemento específico. Faz sentido quando você para pra pensar: o alvo de um evento pode ser praticamente qualquer coisa no DOM, então o compilador não tem como adivinhar que ali é um <input>. Só que isso pega muita gente de surpresa, porque no JavaScript puro isso simplesmente funcionava sem reclamar de nada.
A boa notícia é que o TypeScript já vem com uma hierarquia de tipos de evento bastante completa, e ela é a base de tudo. Event é a raiz. Dali derivam UIEvent, que por sua vez origina MouseEvent, KeyboardEvent, FocusEvent, InputEvent e outros. Cada um carrega propriedades específicas do contexto: MouseEvent tem clientX e clientY, KeyboardEvent tem key e code, e por aí vai. Usar o tipo certo em vez de Event genérico já resolve boa parte dos problemas, porque o autocomplete passa a te mostrar exatamente o que está disponível naquele evento.
button.addEventListener('click', (event: MouseEvent) => {
console.log(event.clientX, event.clientY); // funciona sem reclamação
});
Vale um parêntese aqui pra quem vem de React: o ChangeEvent que você importa de 'react' não é um tipo do DOM nativo, é um tipo sintético do próprio React, que envolve o evento real numa camada própria (o SyntheticEvent). Isso confunde bastante gente que está migrando de projetos puros pra JSX, porque os nomes parecem os mesmos mas as origens são completamente diferentes. Em código DOM puro você não vai encontrar ChangeEvent em lugar nenhum — o equivalente nativo geralmente é tratar o evento input ou change como Event mesmo, e fazer o cast do target manualmente.
Falando em target, esse é provavelmente o ponto que mais gera bug silencioso no dia a dia. event.target é o elemento que efetivamente disparou o evento, e event.currentTarget é o elemento no qual o listener foi anexado. Numa árvore com delegação de evento, isso faz muita diferença: se você tem um listener num <ul> escutando cliques que borbulham dos <li>, o target vai ser o <li> clicado, mas o currentTarget continua sendo o <ul>. O TypeScript geralmente já infere um tipo mais específico pro currentTarget dentro do próprio handler, porque ele sabe em qual elemento o listener foi registrado. Já o target continua genérico como EventTarget, porque não tem como saber de antemão quem vai disparar o clique.
list.addEventListener('click', (event) => {
console.log(event.currentTarget); // TypeScript já sabe que é a <ul>
console.log(event.target); // ainda é EventTarget, precisa de narrowing
});
Uma coisa interessante é que, quando você usa addEventListener com uma string literal reconhecida pelo TypeScript, tipo 'click', 'keydown' ou 'submit', o compilador consegue inferir automaticamente o tipo certo do evento pra você, sem precisar anotar nada. Isso é mérito de uma sobrecarga de tipos que existe internamente na definição do HTMLElement. O problema aparece quando você extrai esse handler pra uma função separada, ou quando escreve um wrapper genérico pra registrar vários listeners de uma vez. Nesses casos a inferência se perde e o parâmetro volta a ser Event puro, então você precisa anotar manualmente de novo.
Pra resolver o problema do target sem tipo específico, existem basicamente dois caminhos, e vale entender quando usar cada um. O mais rápido é a type assertion:
input.addEventListener('input', (event) => {
const target = event.target as HTMLInputElement;
console.log(target.value);
});
Funciona, mas é uma promessa que você está fazendo ao compilador sem prova nenhuma. Se por algum motivo o elemento não for realmente um HTMLInputElement, o TypeScript não vai te avisar em lugar nenhum, e o erro só vai aparecer em runtime. O caminho mais seguro é escrever uma função de type guard, que faz a checagem de verdade antes de afirmar o tipo:
function isInputElement(el: EventTarget | null): el is HTMLInputElement {
return el instanceof HTMLInputElement;
}
input.addEventListener('input', (event) => {
if (isInputElement(event.target)) {
console.log(event.target.value); // aqui dentro já é seguro de verdade
}
});
É um pouco mais de código, sim, mas em bases maiores, onde o mesmo handler pode acabar sendo reaproveitado em contextos diferentes, essa checagem evita um tipo de bug bem chato de rastrear, porque o erro em runtime costuma acontecer longe de onde a asserção errada foi escrita originalmente.
Se o projeto usa React, Vue ou Svelte, cada framework tem sua própria camada de tipagem de eventos, e é bom não misturar os conceitos. No React, o padrão é usar os tipos genéricos com o elemento como parâmetro, tipo React.MouseEvent<HTMLButtonElement> ou React.ChangeEvent<HTMLInputElement>. No Vue com TypeScript, geralmente você tipa direto no template ou usa os tipos do DOM nativo dentro do <script setup>, já que o Vue não envolve o evento numa camada sintética como o React faz. O Svelte tende a ficar mais próximo do DOM puro também. Não dá pra aprofundar todos aqui sem o post virar um livro, mas já ajuda saber que essas diferenças existem antes de copiar um exemplo do Stack Overflow que foi escrito pensando em outro framework.
Alguns erros se repetem bastante e valem ser citados só pra reforçar o que evitar. Usar any no parâmetro do evento resolve o erro na hora mas joga fora toda a segurança de tipo que o TypeScript oferece. Fazer cast forçado sem checagem, tipo as HTMLInputElement, sem nunca ter validado que aquilo é verdade, é basicamente reintroduzir o problema do JavaScript puro só que escondido atrás de uma anotação. E ignorar as sobrecargas genéricas do addEventListener, escrevendo tipos manuais que já existem prontos na lib padrão do TypeScript, tende a gerar inconsistência quando a definição da lib muda numa atualização.
Pra fechar com algo mais completo, pensa num formulário simples de cadastro, com um campo de texto e um botão de envio, onde você quer validar o valor antes de submeter:
const form = document.querySelector('form') as HTMLFormElement;
const input = form.querySelector('input[name="email"]') as HTMLInputElement;
form.addEventListener('submit', (event: SubmitEvent) => {
event.preventDefault();
if (isInputElement(input) && input.value.includes('@')) {
console.log('email válido:', input.value);
} else {
console.log('corrige aí o email antes de mandar');
}
});
function isInputElement(el: EventTarget | null): el is HTMLInputElement {
return el instanceof HTMLInputElement;
}
Repara que aqui misturamos praticamente tudo que passamos: o tipo específico SubmitEvent em vez de Event genérico, um cast controlado no querySelector, e a função de narrowing pra validar o target antes de mexer nele. Na minha experiência, é mais ou menos assim que a tipagem de eventos DOM acaba se resolvendo na prática — não com uma regra única, mas com esse conjunto de pequenas decisões repetidas em cada handler que você escreve.