Ir para o conteúdo
Carreira

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

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustração 3D de placa de vidro fosco fraturada com luz dourada escapando pelas rachaduras representando lacunas em JavaSc...
Ouça este artigo
0:00As 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:

LinguagemTipagemCoerção implícitaExemplo
PythonForteNão"2" + 2 lança TypeError
LuaForteSim"2" + 2 é 4 (operador + é inequivocamente soma)
JavaScriptFracaSim"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:

JAVASCRIPT
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.

JAVASCRIPT
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.
}
JAVASCRIPT
// 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.

JAVASCRIPT
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.

JAVASCRIPT
// ❌ 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, 2

Com 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

JAVASCRIPT
// ⚠️ 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.

JAVASCRIPT
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 - macrotask

Mesmo 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

JAVASCRIPT
// ❌ 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:

Bash
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 vitest

ESLint flat config

JAVASCRIPT
// 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

JSON
{
  "semi": true,
  "singleQuote": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 80
}

Scripts no package.json

JSON
{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Primeiro teste com Vitest

JAVASCRIPT
// 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);
  };
}
JAVASCRIPT
// 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();
  });
});
Bash
npx vitest run

Lacuna 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:

JAVASCRIPT
// 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

Bash
# 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 browser

Para 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.

JAVASCRIPT
// ❌ 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

JAVASCRIPT
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.

JAVASCRIPT
// 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:

JSON
// package.json — projeto inteiro como ESM
{
  "type": "module"
}
Bash
# OU: use extensão .mjs por arquivo
mv utils.js utils.mjs

A 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.

Marcos Soares

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

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.