As Lacunas de JavaScript que Todo Dev Ignora (e Paga Caro Depois)

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O Problema Real
O salto de júnior pra pleno não é sobre saber mais framework. É sobre entender o que o framework esconde. Quase todo dev web moderno aprendeu JavaScript dentro de um tutorial de React, pulou o runtime, pulou o modelo de tipos, pulou o event loop. Funciona até o dia em que um bug de comparação de tipo derruba o formulário em produção e o stack trace não ajuda.
Este post lista as lacunas que eu vejo com mais frequência em code review. Cada uma tem código pra reproduzir, explicação do porquê, e quando possível uma comparação com como outras linguagens resolvem o mesmo problema.
Lacuna 1: Tipagem Fraca vs Coerção Implícita
Quase todo material em português trata essas duas coisas como sinônimo. Não são. Essa confusão é o primeiro lugar onde você perde credibilidade com quem vem de linguagens tipadas.
Tipagem forte vs fraca é sobre o que acontece quando você tenta operar em tipos incompatíveis: a linguagem reclama (forte) ou não reclama (fraca)?
Coerção implícita é uma escolha ortogonal de design: a linguagem converte um tipo em outro automaticamente durante uma operação?
As duas podem existir independentemente. Compare:
| Linguagem | Tipagem | Coerção implícita | Exemplo |
|---|---|---|---|
| Python | Forte | Não | "2" + 2 lança TypeError |
| Lua | Forte | Sim | "2" + 2 é 4 (operador + é inequivocamente soma) |
| JavaScript | Fraca | Sim | "2" + 2 é "22" (+ é concatenação quando um lado é string) |
Lua é o contraexemplo interessante. Ela faz coerção implícita e não é fracamente tipada porque o operador + sempre significa soma numérica — não tem ambiguidade de intenção. A coerção é determinística.
JavaScript é fraca porque o mesmo operador + tem dois significados possíveis dependendo do tipo dinâmico dos operandos em runtime. O engine precisa adivinhar o que você quis dizer, e a decisão muda silenciosamente o resultado:
1 + 2 // 3 — soma numérica
"1" + 2 // "12" — concatenação
1 + "2" // "12" — concatenação
[] + [] // "" — ambos viram string vazia
[] + {} // "[object Object]"
{} + [] // 0 (em contexto de expression statement; em browser console varia)Então quando alguém te disser "JS é fraco porque faz coerção", corrija: JS é fraco porque o mesmo operador muda de significado em runtime. A coerção em si é só a consequência.
Loose Equality: O Algoritmo por Trás do ==
O operador == aplica o algoritmo que a especificação ECMAScript chama de IsLooselyEqual (antigamente "Abstract Equality Comparison"; renomeado em ES2015). Ele não é aleatório — tem regras estáveis, e o problema é justamente que as regras são tantas que o resultado parece mágica.
1 == "1" // true — string vira number
0 == false // true — boolean vira number (false → 0)
"" == false // true — ambos viram 0
null == undefined // true — caso especial explícito na spec
null == 0 // false — null só é == a undefined, fim
NaN == NaN // false — NaN nunca é == a nada, nem a si mesmo
// Armadilha clássica de formulário
const input = document.getElementById("quantity").value; // "0"
if (input) {
// entra aqui! "0" é truthy como string, não converteu pra number
}
if (Number(input)) {
// NÃO entra. 0 é falsy.
}// typeof tem suas próprias armadilhas
typeof null // "object" — bug histórico, nunca corrigido por compat
typeof undefined // "undefined"
typeof NaN // "number" — sim, Not-a-Number é number
typeof [] // "object" — arrays são objetos, use Array.isArray
typeof function(){} // "function" — o único "primitivo de mentira"
// Como verificar NaN corretamente
Number.isNaN(NaN) // true
Number.isNaN("hello") // false (correto: "hello" não é NaN, é uma string)
isNaN("hello") // true (legado: converte "hello" pra Number primeiro)Regra prática: use === sempre. Converta explicitamente com Number(), String(), Boolean(). A única exceção útil do == é x == null para testar "null ou undefined" de uma vez — o resto é risco.
Lacuna 2: Closures de Verdade
Todo mundo "sabe" closures. Poucos conseguem explicar o que acontece na memória quando a função externa retorna.
Uma closure é uma função que mantém uma referência viva ao environment record onde foi criada. Não é cópia de valores. É ponteiro para o mesmo registro que o scope resolution usa.
function criarContador() {
let count = 0; // essa variável "sobrevive" após criarContador() retornar
return {
incrementar: () => ++count,
valor: () => count,
};
}
const contador = criarContador();
contador.incrementar(); // 1
contador.incrementar(); // 2
contador.valor(); // 2
// `count` não é acessível de fora. Encapsulamento real via escopo.O Bug Clássico do Loop
Esse bug ainda aparece em entrevistas porque é didático pra testar entendimento de escopo.
// ❌ Todas as callbacks referenciam o MESMO `i`
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3
// ✅ `let` cria um novo binding por iteração
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
// ✅ IIFE antes do `let` existir, útil pra saber o porquê
for (var i = 0; i < 3; i++) {
((j) => {
setTimeout(() => console.log(j), 100);
})(i);
}
// Output: 0, 1, 2Com var, existe uma única variável i no escopo da função que contém o loop. As três closures guardam referência para essa variável. Quando o setTimeout dispara, o loop já terminou e i vale 3. Com let, a spec diz que cada iteração do for cria um novo environment record com sua própria cópia de i. Cada closure captura a sua.
Closures e GC
// ⚠️ Cuidado: closures podem segurar memória indefinidamente
function processarDados() {
const dadosGigantes = new Array(1_000_000).fill("🔥");
return function resumo() {
return dadosGigantes.length; // mantém `dadosGigantes` vivo enquanto `resumo` existir
};
}
const fn = processarDados();
// dadosGigantes nunca vai ser coletado enquanto `fn` estiver referenciado
// ✅ Extraia só o necessário
function processarDadosMelhor() {
const dadosGigantes = new Array(1_000_000).fill("🔥");
const tamanho = dadosGigantes.length;
return function resumo() {
return tamanho; // só o número fica retido
};
}Isso é um dos pontos mais comuns de memory leak em código React com callbacks mal encapsuladas em useCallback ou closures pesadas em handlers de evento de longa duração.
Lacuna 3: Event Loop — Linguagem, Runtime e Job Queue
Aqui também tem mito pra desfazer. A especificação ECMAScript não define setTimeout, não define setInterval, não define I/O, não define macrotask queue. A linguagem pura só define Job Queue (microtasks), que é o que faz Promise.then rodar de forma determinística.
O resto vem do runtime:
- Browser: HTML spec define macrotask queue e integra com rendering.
setTimeout,fetch, eventos do DOM, tudo vem daqui. - Node.js: libuv implementa event loop com fases (timers, pending callbacks, poll, check, close), thread pool para I/O de disco, epoll/kqueue pra rede.
- Deno: usa Tokio (Rust) como runtime async por baixo do V8.
- Bun: runtime próprio em Zig, com io_uring no Linux.
O V8 sozinho só executa código síncrono na call stack e drena a Job Queue quando a stack esvazia. Todo o resto é responsabilidade do host.
console.log("1 - síncrono");
setTimeout(() => console.log("4 - macrotask"), 0);
Promise.resolve().then(() => console.log("3 - microtask"));
queueMicrotask(() => console.log("3b - microtask"));
console.log("2 - síncrono");
// saída:
// 1 - síncrono
// 2 - síncrono
// 3 - microtask
// 3b - microtask
// 4 - macrotaskMesmo com setTimeout(fn, 0), a Promise executa antes. A regra é: esvazia a call stack, drena completamente a job queue (microtasks) até ficar vazia, processa uma macrotask, volta a drenar microtasks. Microtasks sempre rodam antes da próxima macrotask — é do algoritmo.
Bloqueio da Thread e Yield Cooperativo
// ❌ Trava tudo por ~5s — em browser, congela a UI; em Node, bloqueia o event loop
function calcularSync() {
const inicio = Date.now();
while (Date.now() - inicio < 5000) {}
return "pronto";
}
// ✅ Quebre trabalho pesado em chunks e devolva controle
async function calcularAsync(dados) {
const CHUNK = 1000;
const resultados = [];
for (let i = 0; i < dados.length; i += CHUNK) {
const chunk = dados.slice(i, i + CHUNK);
resultados.push(...chunk.map(processar));
await new Promise((resolve) => setTimeout(resolve, 0));
}
return resultados;
}Esse padrão de yielding é a base do que o React chama de Concurrent Rendering (o nome "Concurrent Mode" foi descontinuado). O pacote scheduler interno usa MessageChannel em browsers que suportam — é mais preciso que setTimeout(0) porque não sofre o throttling que os browsers aplicam em timers. Para código próprio, setTimeout(0) basta na maioria dos casos.
Lacuna 4: Ferramentas que Você Usa Todo Dia
Saber JavaScript não basta. O ecossistema de tooling é o que separa produtividade real de tentativa-e-erro. Aqui é só o setup mínimo pra um projeto moderno:
mkdir meu-projeto && cd meu-projeto
npm init -y
# ESLint — encontra bugs antes de rodar
npm install -D eslint @eslint/js
# Prettier — formatação consistente
npm install -D prettier eslint-config-prettier
# Vitest — testes rápidos, API compatível com Jest
npm install -D vitestESLint flat config
// eslint.config.js
import js from "@eslint/js";
export default [
js.configs.recommended,
{
rules: {
"no-unused-vars": "error",
"no-undef": "error",
eqeqeq: ["error", "always"],
"no-var": "error",
"prefer-const": "error",
"no-implicit-coercion": "error",
},
},
];.prettierrc
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}Scripts no package.json
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"test": "vitest",
"test:run": "vitest run"
}
}Primeiro teste com Vitest
// src/utils.js
export function formatarPreco(centavos) {
if (typeof centavos !== "number" || Number.isNaN(centavos)) {
throw new TypeError("Valor deve ser um número válido");
}
return `R$ ${(centavos / 100).toFixed(2).replace(".", ",")}`;
}
export function debounce(fn, ms) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}// src/utils.test.js
import { describe, it, expect, vi } from "vitest";
import { formatarPreco, debounce } from "./utils.js";
describe("formatarPreco", () => {
it("formata centavos para reais", () => {
expect(formatarPreco(1990)).toBe("R$ 19,90");
expect(formatarPreco(100)).toBe("R$ 1,00");
expect(formatarPreco(0)).toBe("R$ 0,00");
});
it("rejeita valores inválidos", () => {
expect(() => formatarPreco("19.90")).toThrow(TypeError);
expect(() => formatarPreco(NaN)).toThrow(TypeError);
});
});
describe("debounce", () => {
it("executa apenas após o delay", async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});npx vitest runLacuna 5: Debug Além do console.log
O console.log é útil. Mas existe muito mais no próprio objeto console que a maioria dos devs nunca tocou:
// console.table — visualize arrays de objetos
console.table([
{ nome: "Ana", idade: 28, ativo: true },
{ nome: "Carlos", idade: 34, ativo: false },
]);
// console.time — meça performance direto
console.time("operação");
const resultado = Array.from({ length: 100_000 }, (_, i) => i * 2);
console.timeEnd("operação");
// console.trace — quem chamou essa função?
function a() { b(); }
function b() { c(); }
function c() { console.trace("chamado por:"); }
a();
// console.assert — log condicional sem cluttering
const idade = 15;
console.assert(idade >= 18, "Usuário menor de idade:", { idade });
// debugger statement — breakpoint no próprio código
function calcular(x) {
debugger; // abre o DevTools automaticamente aqui (se aberto)
return x * 2 + 1;
}Profiling no Node.js
# Profiler V8 embutido
node --prof app.js
node --prof-process isolate-*.log > profile.txt
# Debug interativo no Chrome DevTools
node --inspect-brk app.js
# Abra chrome://inspect no browserPara código CPU-bound em Node, --prof é o primeiro passo antes de sair otimizando no escuro. Para código I/O-bound, --inspect + aba Performance do DevTools mostra o que está travando o event loop.
Lacuna 6: Manipulação de Erros com Estratégia
try/catch não é band-aid pra silenciar warning. É ferramenta pra decidir como o sistema reage a falha.
// ❌ Engolir erro — o pior anti-pattern
try {
const data = JSON.parse(input);
} catch (e) {
// silêncio mortal — bug passa despercebido até produção
}
// ❌ Catch genérico sem contexto
try {
await fetch("/api/users");
} catch (e) {
alert("Erro!"); // qual erro? onde? o que o usuário faz?
}
// ✅ Tratamento com contexto e fallback
async function buscarUsuarios() {
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
if (error instanceof TypeError) {
// Falha de rede (offline, DNS, CORS) — fetch lança TypeError
console.error("Sem conexão:", error.message);
return getCachedUsers();
}
// Erro HTTP ou parsing JSON — re-throw pra camada superior decidir
console.error("Falha ao buscar usuários:", error.message);
throw error;
}
}Erros Customizados
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class NotFoundError extends Error {
constructor(resource, id) {
super(`${resource} #${id} não encontrado`);
this.name = "NotFoundError";
this.status = 404;
}
}
function validarEmail(email) {
if (!email.includes("@")) {
throw new ValidationError("email", "Email inválido");
}
}
try {
validarEmail("invalido");
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Campo ${error.field}: ${error.message}`);
}
}Erros customizados permitem que a camada chamadora filtre por tipo sem depender de parsing de string de mensagem — fundamental em qualquer API pública.
Lacuna 7: ES Modules vs CommonJS
Essa confusão ainda persiste em projetos Node. Os dois sistemas coexistem, mas ESM é o padrão para código novo.
// CommonJS — carregamento síncrono e dinâmico
const fs = require("fs");
module.exports = { minhaFuncao };
// ES Modules — carregamento estático (parse time) e assíncrono
import fs from "node:fs";
export function minhaFuncao() {}Para ativar ESM no Node, duas opções:
// package.json — projeto inteiro como ESM
{
"type": "module"
}# OU: use extensão .mjs por arquivo
mv utils.js utils.mjsA diferença prática mais importante: ESM é analisado estaticamente, o que permite tree-shaking real e bundlers otimizarem o que entra no pacote final. CommonJS resolve imports em runtime com require, o que impede análise estática profunda. Projetos frontend modernos (Vite, Next.js, Bun) já assumem ESM por padrão.
Opinião Pessoal
A maioria dos materiais introdutórios de JavaScript em português ensina sintaxe e pula direto para framework. Isso é eficiente pra colocar gente no mercado rápido, mas deixa buracos que aparecem exatamente quando o framework não salva — bug de comparação de tipo, memory leak em callback, race condition em estado assíncrono. Os buracos cobrados nesse post são exemplos desse padrão, não uma lista exaustiva.
Sobre TypeScript: ele ajuda com tipagem em compile time, mas não protege de coerção em runtime quando os dados entram pelo borda do sistema (API, localStorage, form). unknown + validação com Zod/Valibot na borda é o único jeito confiável de não escrever TypeScript que compila e quebra. TypeScript não substitui entender o modelo de tipos do JavaScript por baixo.
Sobre o ponto de framework primeiro: se você está no Next.js e não consegue explicar o que uma Promise representa (não como usar, o que ela é: um valor que captura o resultado futuro de uma computação assíncrona), vale dar um passo pra trás. Framework é multiplicador. Multiplicador em cima de zero continua zero.
Recomendação concreta: javascript.info (gratuito, traduzido para pt-BR, cobre praticamente todas as lacunas deste post em mais profundidade) e You Don't Know JS Yet do Kyle Simpson (segunda edição, disponível no GitHub do autor). Essas duas fontes juntas cobrem melhor do que qualquer bootcamp.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

Lacunas Ocultas do JavaScript Avançado que Travam sua Evolução

5 Lacunas Críticas que Todo Dev JavaScript Ignora (e Como Cobrir Cada Uma)

O Mapa Completo do Dev Web Moderno: Ferramentas, Decisões e Anti-Patterns que Separam Júnior de Sênior
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.