Null safety

Escopo: transversal. Aplica-se a qualquer linguagem ou stack do projeto.

Null tem um lugar certo no sistema: os limites. Quando o ?. aparece espalhado pelo código inteiro, como defesa preventiva, isso indica que os contratos de entrada não estão fechados: o limite deixou passar um valor ausente, e cada função seguinte precisa checá-lo de novo.

Diante de cada ?., a pergunta a fazer é: "esse null deveria ter chegado até aqui?"

Conceitos fundamentais

ConceitoO que é
Null (valor ausente)Representa ausência de valor; comportamento varia entre linguagens
boundary (limite do sistema)Ponto onde dados externos entram no sistema: request, resposta de API, leitura de banco
API (Application Programming Interface · Interface de Programação de Aplicações)Contrato externo que pode produzir nulls; checagem obrigatória no limite
HTTP (HyperText Transfer Protocol · Protocolo de Transferência de Hipertexto)Protocolo onde requests trazem dados não confiáveis
I/O (Input/Output · Entrada/Saída)Operação que cruza o limite; banco, arquivo e rede são fontes de null
JSON (JavaScript Object Notation · Notação de Objetos JavaScript)Formato de serialização onde campos ausentes viram null ou undefined

A regra: checa no limite, confia no interior

O sistema tem dois territórios, e as regras de um não valem no outro:

TerritórioO que éRegra
LimiteOnde dados de fora entram: request HTTP, resposta de API, leitura de banco, configChecar. Normalizar. Rejeitar o inválido.
InteriorDomínio, serviços, funções de negócioConfiar no contrato. Sem checagem defensiva.
entrada externa → limite (checa + normaliza)domínio (confia no contrato)

Null que aparece no interior indica um limite que deixou passar. Corrija na entrada.

O que é limite

LimiteExemplos
Entrada de requestBody, query params, path params de uma requisição HTTP
Resposta de API externaJSON de terceiros, webhooks
Retorno de banco de dadosfindById que pode retornar null quando não encontra
Variáveis de ambiente / configprocess.env, appsettings.json

O que não é limite

Funções internas, serviços de domínio, cálculos: tudo que recebe dados já filtrados pelo limite. Essas funções não checam nada, porque confiam que quem chamou entregou o contrato cumprido.

❌ Ruim: interior checando null que não deveria existir
function calculateDiscount(order) {
  if (!order) return 0;
  if (!order.discountRate) return 0;

  return order.total * order.discountRate;
}
✅ Bom: interior opera com contrato garantido
function calculateDiscount(order) {
  const discount = order.total * order.discountRate;
  return discount;
}

A diferença está em quem responde pelo quê. Passar um Order válido é responsabilidade de quem chama calculateDiscount. Se chegar inválido, o bug é de quem chamou, e checar dentro da função apenas esconderia esse bug.

Como fechar o limite

Três padrões dão conta da maioria dos casos:

1. Validação de schema na entrada

const orderRequest = CreateOrderSchema.parse(request.body); // lança se inválido

await createOrder(orderRequest); // domínio recebe dados garantidos

2. Guard clause logo após I/O

const order = await orderRepository.findById(id);
if (!order) throw new NotFoundError(`Order ${id} not found`);

// a partir daqui, order é garantido: sem ?. no restante da função
const total = calculateTotal(order);

3. Contratos não-nulos na construção

function buildOrder(id, items) {
  const order = { id, items: items ?? [] }; // items sempre [], nunca null
  return order;
}

Coleções: nunca null, sempre vazia

Uma lista já tem um estado neutro pronto: []. Devolver null para dizer "não achei nada" não informa mais do que a lista vazia informaria, e obriga cada chamador a se defender antes de iterar.

FunçãoRetorno corretoPor quê
findOrdersByUser(userId)Order[]: [] se não há pedidosAusência e vazio são equivalentes para quem itera
findUserById(id)User | null: null se não existeAusência de entidade é informação relevante
Propriedade de lista em classeinicializada como []Nunca precisa de ?. para iterar

Quando ?. e ?? ajudam e quando escondem um bug

Esses operadores existem para o campo que é opcional por design no domínio, aquele que legitimamente pode não estar lá. Não servem de escudo contra contrato mal fechado.

❌ Ruim: ?. como defesa contra contrato que deveria ser fechado
const discount = order?.discountRate ? order.total * order.discountRate : 0;
// order.discountRate nunca deveria ser null: contrato fraco exposto com `?.`
✅ Bom: ?. e ?? para campos opcionais por design
const display = user.nickname ?? user.name; // nickname é opcional no modelo
const city = user.address?.city ?? "N/A"; // endereço pode não existir

Se você precisa de ?. para acessar um campo que "sempre deveria existir", o contrato está aberto. Feche o contrato e remova o operador.

Campo novo em tabela que já tem dados

Uma regra de negócio muda, um campo novo entra no banco, e os registros antigos ficam com null porque não havia valor para eles. Esse null é histórico e o domínio não deve recebê-lo. O repositório resolve o valor antes de entregar o dado.

campo novo → registros antigos nulos → limite absorve → domínio nunca vê null

Três abordagens, em ordem de preferência:

1. Migration com DEFAULT: null nunca chega a existir no banco

A saída mais limpa. A migration preenche os registros antigos e garante valor para os novos, e o domínio nunca vê null nenhum.

ALTER TABLE orders ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'normal';
-- registros existentes recebem 'normal' automaticamente

2. Normalização no repositório: o null morre no limite

Use quando alterar o banco está fora de alcance: legado, multi-tenant, migration sob controle de outro time.

async function findById(id) {
  const row = await database.queryOne("SELECT id, priority, status FROM orders WHERE id = ?", [id]);
  if (!row) return null;

  const order = {
    ...row,
    priority: row.priority ?? "normal", // null histórico normalizado no limite
  };

  return order;
}

3. Campo opcional de verdade: quando a ausência significa alguma coisa

Às vezes o null carrega informação: "esse pedido foi criado antes dessa feature existir". Nesse caso o campo é opcional por design, e o domínio resolve a ausência em uma função central, sem espalhar ?. pelo código.

// priority é opcional: ausência significa "criado antes dessa feature existir"
function getEffectivePriority(order) {
  const priority = order.priority ?? "normal"; // uma função resolve, sem espalhar ?. pelo domínio
  return priority;
}
SituaçãoAbordagem
Campo sem significado em registros antigosMigration com DEFAULT
Banco legado, sem controle da migrationNormaliza no repositório
Ausência tem significado de negócioCampo opcional, função de resolução centralizada
?. espalhado "porque pode ser null"Problema de limite: fechar em um dos casos acima

Implementação por linguagem

  • TypeScript: strictNullChecks, noUncheckedIndexedAccess, ??, ?.
  • C#: nullable reference types, required, ??=, Array.Empty<T>()

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.