Null safety

Escopo: JavaScript. Visão transversal: shared/standards/null-safety.md.

JavaScript não tem compilador que rastreie nullability (nulabilidade, possibilidade de o valor ser nulo). A responsabilidade é do código: validar nos boundaries (limites, pontos de entrada de dados externos) e confiar no interior. Operadores específicos (??, ?.) tornam a intenção explícita; usar o operador errado (|| em vez de ??) descarta valores válidos como 0 ou "".

Conceito geral: Null Safety

Conceitos fundamentais

ConceitoO que é
null (nulo)Valor explícito que indica "ausência intencional"; atribuído pelo programador
undefined (indefinido)Valor padrão de variável não inicializada ou propriedade inexistente; atribuído pela engine
nullish (ausente: nulo ou indefinido)Conjunto que reúne null e undefined; o que ?? e ?. tratam
falsy (avalia como falso)Valores que coercionam para false em booleano: null, undefined, 0, "", false, NaN
nullish coalescing (coalescência de ausente, ??)Retorna o lado direito apenas se o esquerdo for null ou undefined
optional chaining (encadeamento opcional, ?.)Acessa propriedade ou chama método sem lançar erro se a base for nullish
boundary (limite)Ponto onde dados externos entram (HTTP, DB, fila); local correto para validar nulos
non-null assertion (afirmação de não-nulo)Garantia explícita ao leitor de que o valor não é nulo neste ponto; em JS via comentário ou guard

?? vs ||

|| retorna o lado direito para qualquer valor falsy: 0, "" e false disparam o fallback. ?? retorna o lado direito só para null e undefined. Para defaults, ?? é o correto na maioria dos casos.

❌ Ruim: || descarta valores falsy válidos
const timeout = config.timeout || 5000; // 0 → 5000: zero é tempo válido
const retries = input.retries || 3;     // 0 → 3: zero retries é intencional

const debug = options.debug || false;   // false → false: ok aqui, mas por acidente
✅ Bom: ?? respeita 0, "" e false
const timeout = config.timeout ?? 5000;
const retries = input.retries ?? 3;

const port = process.env.PORT ?? config.port ?? 3000; // encadeamento de fallbacks

??= vs ||=

??= atribui só se o valor atual for null ou undefined. ||= atribui se for qualquer falsy. A mesma distinção de ?? vs ||, aplicada à atribuição lógica.

❌ Ruim: ||= sobrescreve zero, que é um valor válido
let count = 0;
count ||= 10; // count vira 10: zero é falsy, então ||= dispara
✅ Bom: ??= respeita zero e false
let count = 0;
count ??= 10; // count permanece 0: zero não é null

const config = {};
config.port ??= 3000;

config.port ??= 8080; // não executa: port já é 3000

?. navegação segura

?. retorna undefined se o receptor for null ou undefined, sem lançar exceção.

Tem lugar para campos opcionais por design. Quando o campo deveria sempre existir, a ausência é um bug: use guard clause.

❌ Ruim: ?. esconde contrato fraco
async function getOrderTotal(orderId) {
  const order = await db.orders.findById(orderId);
  return order?.items?.reduce((sum, item) => sum + item.price, 0) ?? 0;
  // se order não existe, retorna 0 silenciosamente. É isso que queremos?
}
✅ Bom: guard clause quando ausência é erro; ?. quando é esperada
// ausência é erro → guard clause
async function getOrderTotal(orderId) {
  const order = await orderRepository.findById(orderId);
  if (!order) throw new NotFoundError({ message: `Order ${orderId} not found.` });

  const total = order.items.reduce((sum, item) => sum + item.price, 0);
  return total;
}

// ausência é esperada → ?. é suficiente
function formatUserCity(user) {
  const city = user?.address?.city ?? "N/A";
  return city;
}

Coleções nunca são nulas

Funções que retornam listas sempre retornam [], nunca null. No limite com dados externos, normalize com ?? [].

❌ Ruim: null em lista força defesa no caller
async function findOrdersByUser(userId) {
  const orders = await db.orders.findByUser(userId);
  return orders.length ? orders : null;
}
✅ Bom: lista vazia como estado neutro
async function findOrdersByUser(userId) {
  const orders = await orderRepository.findByUser(userId);
  return orders; // ORM já retorna []: nunca null
}

// limite com API externa: normaliza na entrada
async function fetchUserOrders(userId) {
  const response = await externalApi.get(`/users/${userId}/orders`);
  const orders = response.orders ?? []; // normaliza aqui, não no caller

  return orders;
}

Array.flatMap: filtrar e mapear sem null

flatMap com retorno de [] nos casos inválidos é o padrão moderno para remover nulls durante uma transformação: mais expressivo que .filter().map() por percorrer o array uma única vez.

❌ Ruim: filter + map percorre o array duas vezes
const rawItems = ["1", null, "3", undefined, "5"];

const parsed = rawItems
  .filter((item) => item != null)
  .map((item) => parseInt(item, 10));
✅ Bom: flatMap filtra e transforma em uma passagem
const rawItems = ["1", null, "3", undefined, "5"];

const parsed = rawItems.flatMap((item) => {
  if (item == null) return [];
  return [parseInt(item, 10)];
});
// [1, 3, 5]

Object.hasOwn: checar propriedade com segurança

Object.hasOwn(obj, key) verifica se a propriedade existe no próprio objeto, sem riscos de prototype pollution. Substitui o padrão antigo obj.hasOwnProperty(key).

❌ Ruim: hasOwnProperty vulnerável a prototype pollution
const config = { timeout: 0 };

config.hasOwnProperty("timeout"); // funciona, mas pode ser sobrescrito via prototype
✅ Bom: Object.hasOwn seguro e direto
const config = { timeout: 0, debug: false };

Object.hasOwn(config, "timeout"); // true: existe, mesmo sendo 0
Object.hasOwn(config, "retries"); // false: não existe

function mergeConfig(defaults, overrides) {
  const result = { ...defaults };

  for (const key of Object.keys(overrides)) {
    if (Object.hasOwn(defaults, key)) {
      result[key] = overrides[key] ?? defaults[key];
    }
  }

  return result;
}

structuredClone: cópia profunda sem perder nulls

JSON.parse(JSON.stringify(obj)) descarta campos undefined e não preserva Date, Map e Set. structuredClone copia corretamente, preservando null e os tipos nativos.

❌ Ruim: **JSON** (JavaScript Object Notation, Notação de Objetos JavaScript) round-trip perde undefined, Date e Map
const order = {
  notes: null,
  tags: undefined,
  createdAt: new Date(),
  meta: new Map([["source", "web"]]),
};

const clone = JSON.parse(JSON.stringify(order));
// notes: null       ✓
// tags              ausente: undefined some
// createdAt         "2026-...": virou string
// meta              {}: Map virou objeto vazio
✅ Bom: structuredClone preserva todos os tipos
const order = {
  notes: null,
  tags: undefined,
  createdAt: new Date(),
  meta: new Map([["source", "web"]]),
};

const clone = structuredClone(order);
// notes: null       ✓
// tags: undefined   ✓
// createdAt: Date   ✓
// meta: Map         ✓

clone.meta.set("cloned", true); // não afeta o original

Desenvolvido por @thiagocajadev · Fork baseado no repositório pmndrs/docs · Poimandres.