Índice
- O problema que o código não mostra
- O que o engine realmente faz com cada declaração
- Hoisting na prática — o que muda entre var, let e const
- Temporal Dead Zone: recurso, não bug
- Os erros que aparecem em produção
- const não é imutável — e isso causa bugs reais
- var, let e const no TypeScript — o que muda
- Tabela comparativa completa
- Checklist de decisão
- FAQ — Perguntas frequentes
- Próximos passos
O problema que o código não mostra
Você provavelmente já passou por isso: um bug que some quando você coloca console.log para debugar, ou um valor que chega como undefined em um contexto onde você tem certeza que foi atribuído. Em boa parte dos casos, o culpado é var — e a razão exata não é o que a maioria dos artigos explica.
Escolher entre var, let e const com base em "vai reatribuir ou não" resolve apenas uma parte do problema. O que realmente importa é entender o que o engine JavaScript faz com cada uma dessas declarações antes mesmo de executar a primeira linha do seu código.
O que você vai encontrar aqui:
- Como o engine lê e processa cada tipo de declaração (não só o que elas fazem)
- Hoisting de variáveis e de funções — comportamentos diferentes que a maioria mistura
- Temporal Dead Zone explicada visualmente, com os erros que ela previne
- Os bugs que
varcausa em código assíncrono — com rastreamento e fix - A diferença real de
constpara readonly no TypeScript - Tabela comparativa completa: escopo, hoisting, TDZ, reassign e redeclaração
- Checklist de decisão e FAQ
Nível: intermediário. Se você sabe o que é uma função e já escreveu código assíncrono com
fetchousetTimeout, está pronto.
O que o engine realmente faz com cada declaração
Antes de executar qualquer linha, o V8 faz uma passagem pelo código para registrar declarações. É nessa fase que o comportamento de var, let e const diverge de forma fundamental.
var — escopo de função, inicializado como undefined
var é registrado no topo do escopo de função mais próximo (ou no escopo global se estiver fora de qualquer função) e inicializado imediatamente com undefined.
function exemplo() {
console.log(nome); // undefined — não lança erro
var nome = "Ana";
console.log(nome); // "Ana"
}
O engine lê esse código como:
function exemplo() {
var nome = undefined; // içado para o topo da função
console.log(nome); // undefined
nome = "Ana";
console.log(nome); // "Ana"
}
O detalhe que mata: var ignora blocos if, for, while. O escopo é sempre a função.
function processarUsuario(ativo) {
if (ativo) {
var mensagem = "Usuário ativo";
}
console.log(mensagem); // "Usuário ativo" OU undefined — depende do if
}
processarUsuario(false);
// undefined — sem ReferenceError, sem aviso. Bug silencioso.
let — escopo de bloco, não inicializado
let é registrado no topo do bloco {} mais próximo, mas não é inicializado. O acesso antes da linha de declaração lança ReferenceError.
{
console.log(nome); // ReferenceError: Cannot access 'nome' before initialization
let nome = "Ana";
}
let também não permite redeclaração no mesmo escopo:
let nome = "Ana";
let nome = "Carlos"; // SyntaxError: Identifier 'nome' has already been declared
Com var, isso passa silenciosamente:
var nome = "Ana";
var nome = "Carlos"; // sem erro — sobrescreve
console.log(nome); // "Carlos"
const — escopo de bloco, obriga inicialização imediata
const segue as mesmas regras de escopo e TDZ que let, com duas restrições extras: precisa ser inicializado na declaração e não permite reatribuição da referência.
const nome; // SyntaxError: Missing initializer in const declaration
const URL = "https://api.exemplo.com";
URL = "https://outro.com"; // TypeError: Assignment to constant variable
Hoisting na prática — o que muda entre var, let e const
Hoisting não é exclusividade do var. Os três são içados. A diferença está no que acontece com eles durante o içamento:
FASE DE COMPILAÇÃO (antes da execução)
┌──────────────────────────────────────────────────────────────────┐
│ var nome → registrado + inicializado como undefined │
│ let nome → registrado + NÃO inicializado (entra na TDZ) │
│ const nome → registrado + NÃO inicializado (entra na TDZ) │
│ function f → registrado + inicializado com a função completa │
└──────────────────────────────────────────────────────────────────┘
FASE DE EXECUÇÃO (linha por linha)
┌──────────────────────────────────────────────────────────────────┐
│ Acesso a var → undefined (já inicializado) │
│ Acesso a let → ReferenceError (ainda na TDZ) │
│ Acesso a const → ReferenceError (ainda na TDZ) │
│ Acesso a function f → funciona (já inicializada) │
└──────────────────────────────────────────────────────────────────┘
Hoisting de funções — o que muda tudo
Declarações de função são içadas completas, não apenas o nome:
// Funciona — a função foi içada inteira
saudar("Ana");
function saudar(nome) {
console.log(`Olá, ${nome}!`);
}
Mas function expressions com var seguem a regra do var:
saudar("Ana"); // TypeError: saudar is not a function
var saudar = function(nome) {
console.log(`Olá, ${nome}!`);
};
O engine içou var saudar como undefined. Chamar undefined() lança TypeError. Se fosse let ou const, seria ReferenceError — mais claro sobre o que aconteceu.
Temporal Dead Zone: recurso, não bug
A TDZ é o período entre o início do bloco e a linha de declaração de um let ou const. Qualquer acesso nesse intervalo lança ReferenceError.
{
// ← TDZ começa aqui para "config"
console.log(config); // ReferenceError
inicializar(); // ReferenceError — mesmo dentro de função
const config = { timeout: 3000 }; // ← TDZ termina aqui
console.log(config); // { timeout: 3000 }
}
A TDZ existe por design. Sem ela, código mal estruturado rodaria sem reclamação — como o var faz. O ReferenceError na linha errada é infinitamente melhor que undefined aparecendo 40 linhas abaixo sem contexto.
TDZ em class fields — onde devs experientes se queimam
class Servico {
url = this.gerarUrl(); // ❌ ReferenceError se gerarUrl usa um field declarado depois
gerarUrl() {
return `${this.baseUrl}/api`; // baseUrl ainda está na TDZ
}
baseUrl = "https://api.exemplo.com";
}
A ordem de inicialização de class fields segue a ordem de declaração. this.gerarUrl() é chamado antes de baseUrl ser inicializado. Para corrigir: declare baseUrl antes de url, ou inicialize url no construtor.
Os erros que aparecem em produção
Erro 1: var em loops com callbacks assíncronos
Esse bug passa em todos os testes síncronos e só aparece em produção:
// ❌ Relatório chega com índices errados
function buscarRelatorios(ids) {
for (var i = 0; i < ids.length; i++) {
fetch(`/api/relatorio/${ids[i]}`)
.then(res => res.json())
.then(data => {
console.log(`Relatório ${i}:`, data);
// i sempre vale ids.length quando o .then executa
// todos os logs mostram o mesmo índice final
});
}
}
O que o engine faz: var i existe em escopo de função. Quando os callbacks do .then executam (após o loop terminar), todos acessam o mesmo i — que já chegou ao valor final.
// ✓ let cria um binding novo por iteração
function buscarRelatorios(ids) {
for (let i = 0; i < ids.length; i++) {
fetch(`/api/relatorio/${ids[i]}`)
.then(res => res.json())
.then(data => {
console.log(`Relatório ${i}:`, data); // i correto para cada iteração
});
}
}
Erro 2: var dentro de blocos condicionais
// ❌ Vazamento de escopo
function processarPedido(pedido) {
if (pedido.tipo === "urgente") {
var prioridade = "alta";
var prazo = calcularPrazoUrgente(pedido);
}
// prioridade e prazo existem aqui — mesmo se o if não executou
// Se o if não executou: undefined. Se executou: o valor.
notificar(pedido.id, prioridade, prazo);
}
// ✓ let limita ao bloco
function processarPedido(pedido) {
if (pedido.tipo === "urgente") {
let prioridade = "alta";
let prazo = calcularPrazoUrgente(pedido);
notificar(pedido.id, prioridade, prazo);
}
// prioridade e prazo não existem aqui — ReferenceError se tentar acessar
}
Erro 3: Redeclaração silenciosa com var
Em arquivos grandes ou após merge de código, var permite redeclaração sem aviso:
// ❌ Dois devs adicionaram variáveis com o mesmo nome — sem erro
var resultado = calcularTotalPedidos();
// ... 150 linhas de código ...
var resultado = validarEstoque(); // sobrescreve silenciosamente
salvarRelatorio(resultado); // usa o segundo valor, mas ninguém percebeu
Com let ou const, o SyntaxError aparece na hora — em desenvolvimento, não em produção.
const não é imutável — e isso causa bugs reais
Esse é o equívoco mais comum entre devs com 1-3 anos de experiência. const protege a referência, não o conteúdo.
// ❌ Pensamento errado: const = não pode mudar
const config = {
apiUrl: "https://api.producao.com",
timeout: 5000,
debug: false,
};
config.debug = true; // Funciona. Sem erro.
config.apiUrl = ""; // Funciona. Sem erro.
config.novaChave = "ops"; // Funciona. Sem erro.
console.log(config);
// { apiUrl: "", timeout: 5000, debug: true, novaChave: "ops" }
O que const realmente bloqueia:
const config = { apiUrl: "https://api.producao.com" };
config = { apiUrl: "outro" }; // TypeError: Assignment to constant variable
Para imutabilidade real de primeiro nível, use Object.freeze():
// ✓ Imutável em profundidade 1
const config = Object.freeze({
apiUrl: "https://api.producao.com",
timeout: 5000,
});
config.timeout = 9999; // Ignorado silenciosamente (TypeError em strict mode)
console.log(config.timeout); // 5000
Object.freeze() é shallow — objetos aninhados não são congelados:
const config = Object.freeze({
db: { host: "localhost", porta: 5432 }, // não congelado
timeout: 5000,
});
config.db.host = "producao.db.com"; // Funciona — db não foi congelado
console.log(config.db.host); // "producao.db.com"
Para deep freeze, você precisa de uma função recursiva ou de uma biblioteca como immer.
var, let e const no TypeScript — o que muda
TypeScript adiciona uma camada extra que confunde ainda mais o conceito de imutabilidade:
// const em TypeScript não implica readonly
const usuario = { nome: "Ana", role: "admin" };
usuario.role = "user"; // sem erro no TypeScript — mesma regra do JS
// Para readonly em TypeScript, use Readonly<T>
const usuario: Readonly<{ nome: string; role: string }> = {
nome: "Ana",
role: "admin",
};
usuario.role = "user"; // ❌ Erro de compilação: Cannot assign to 'role' because it is read-only property
Para arrays:
// ❌ const não impede mutação de array
const ids = [1, 2, 3];
ids.push(4); // funciona
ids[0] = 99; // funciona
// ✓ readonly array em TypeScript
const ids: readonly number[] = [1, 2, 3];
ids.push(4); // ❌ Erro de compilação
ids[0] = 99; // ❌ Erro de compilação
O as const vai além — infere o tipo literal e torna tudo readonly em profundidade:
const config = {
env: "producao",
timeout: 5000,
} as const;
// Tipo inferido: { readonly env: "producao"; readonly timeout: 5000 }
config.env = "desenvolvimento"; // ❌ Erro de compilação
Tabela comparativa completa
| Característica | var | let | const |
|---|---|---|---|
| Escopo | Função (ou global) | Bloco {} | Bloco {} |
| Hoisting | Sim — inicializado como undefined | Sim — não inicializado (TDZ) | Sim — não inicializado (TDZ) |
| Temporal Dead Zone | Não | Sim | Sim |
| Reatribuição | Sim | Sim | Não |
| Redeclaração | Sim (silenciosa) | Não | Não |
| Inicialização obrigatória | Não | Não | Sim |
| Mutação de objeto/array | Sim | Sim | Sim |
| Uso recomendado | Evitar em código novo | Valores que mudam | Padrão — use sempre |
Checklist de decisão
| Situação | Decisão |
|---|---|
| Qualquer declaração nova | Comece com const |
| Precisa reatribuir (contador, acumulador, estado) | Use let |
| Loop com callback assíncrono | Use let — nunca var |
| Objeto que só muda internamente | const (sem freeze) |
| Objeto que não deve mudar de forma alguma | const + Object.freeze() |
| TypeScript — propriedades somente leitura | Readonly<T> ou as const |
| TypeScript — array somente leitura | readonly T[] |
| Código legado — refatoração incremental | Substitua var por let/const por arquivo |
| ESLint para reforçar o padrão | Ative a regra no-var |
FAQ — Perguntas frequentes
let é mais lento que var em JavaScript?
A diferença de performance entre var e let em engines modernas (V8, SpiderMonkey) é irrelevante para qualquer aplicação real — estamos falando de nanossegundos. A escolha deve ser baseada em segurança de escopo e legibilidade, não em performance. Benchmarks que mostram diferença significativa geralmente estão testando padrões artificiais que não refletem código de produção.
Por que var ainda existe se let e const são melhores?
Compatibilidade retroativa. Código JavaScript escrito na década de 90 ainda roda em browsers modernos — remover var quebraria boa parte da web. O ES6 (2015) introduziu let e const como alternativas mais seguras, não como substituição forçada. Em código novo, não há razão técnica para usar var.
const em JavaScript é a mesma coisa que final no Java ou val no Kotlin?
Parecido, mas não igual. final e val em suas respectivas linguagens impedem reatribuição da referência, assim como const — mas a semântica de imutabilidade real depende do tipo. Em JavaScript, const não oferece nenhuma proteção contra mutação interna de objetos e arrays. Object.freeze() é necessário para isso, e mesmo assim é shallow.
Devo usar let ou const em loops for...of?
const funciona em for...of porque cada iteração cria um novo binding (diferente do for tradicional onde a variável é compartilhada):
// ✓ const funciona em for...of
for (const usuario of usuarios) {
console.log(usuario.nome);
}
// ❌ const não funciona no for clássico (precisa reatribuir i)
for (const i = 0; i < 10; i++) { // TypeError na segunda iteração
console.log(i);
}
Use const em for...of e for...in. Use let no for clássico.
Como migrar uma codebase que usa var em todo lugar?
Migração incremental por arquivo:
- Ative
"no-var": "error"no ESLint para novos arquivos - Por arquivo legado: substitua
varporconstem tudo - O linter vai apontar onde
constnão funciona (reatribuição) — troque porlet - Rode os testes. Bugs que aparecerem eram bugs silenciosos do
varantes.
Não faça find-and-replace global de var por let — você vai perder o benefício de const onde ele se aplica.
var dentro de função é diferente de var global?
Sim. var dentro de uma função fica restrito àquela função. O problema de escopo de bloco só se manifesta dentro da função — var em if ou for vaza para o escopo da função, não para o escopo global. var no nível de módulo vira propriedade do objeto global (window no browser, global no Node.js) — o que é outro bom motivo para evitá-lo.
Próximos passos
Entender var, let e const em profundidade desbloqueia o próximo nível: closures e o modelo de execução do JavaScript. A razão pelo qual o bug do var em loops acontece é exatamente o mesmo mecanismo que torna closures poderosos — e perigosos quando mal entendidos.
Se o seu projeto usa var em código novo, o passo imediato é ativar a regra no-var no ESLint:
// .eslintrc.json
{
"rules": {
"no-var": "error",
"prefer-const": "error"
}
}
Com prefer-const ativo, o ESLint vai apontar todo let que poderia ser const — você refatora conforme o linter indica, sem risco.
Na Parte 2 desta série: Closures no JavaScript — como funções lembram do escopo onde foram criadas, por que isso causa memory leaks em eventos e como o padrão de módulo IIFE surgiu exatamente para contornar o comportamento do var.