var, let e const no JavaScript: Escopo, Hoisting e TDZ com Exemplos Reais
← Voltar para Codeshort

var, let e const no JavaScript: Escopo, Hoisting e TDZ com Exemplos Reais

Além do 'vai mudar o valor ou não': como escopo, hoisting e Temporal Dead Zone se comportam de verdade — com os bugs que aparecem em produção e a tabela comparativa completa.

DC
Dev Code Software
30 de março de 2026·10 min de leitura

Índice


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 var causa em código assíncrono — com rastreamento e fix
  • A diferença real de const para 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 fetch ou setTimeout, 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ísticavarletconst
EscopoFunção (ou global)Bloco {}Bloco {}
HoistingSim — inicializado como undefinedSim — não inicializado (TDZ)Sim — não inicializado (TDZ)
Temporal Dead ZoneNãoSimSim
ReatribuiçãoSimSimNão
RedeclaraçãoSim (silenciosa)NãoNão
Inicialização obrigatóriaNãoNãoSim
Mutação de objeto/arraySimSimSim
Uso recomendadoEvitar em código novoValores que mudamPadrão — use sempre

Checklist de decisão

SituaçãoDecisão
Qualquer declaração novaComece com const
Precisa reatribuir (contador, acumulador, estado)Use let
Loop com callback assíncronoUse let — nunca var
Objeto que só muda internamenteconst (sem freeze)
Objeto que não deve mudar de forma algumaconst + Object.freeze()
TypeScript — propriedades somente leituraReadonly<T> ou as const
TypeScript — array somente leiturareadonly T[]
Código legado — refatoração incrementalSubstitua var por let/const por arquivo
ESLint para reforçar o padrãoAtive 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:

  1. Ative "no-var": "error" no ESLint para novos arquivos
  2. Por arquivo legado: substitua var por const em tudo
  3. O linter vai apontar onde const não funciona (reatribuição) — troque por let
  4. Rode os testes. Bugs que aparecerem eram bugs silenciosos do var antes.

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.