Programação Por Equipe DevBras

Capa do artigo: Null virando zero: JS, PHP, Python, C# e SQL testados

Null virando zero: JS, PHP, Python, C# e SQL testados

Onde null, vazio e campo ausente viram 0 em JavaScript, PHP, Python, C#, MySQL e PostgreSQL, com leitura de preço segura e o CHECK que deixa NULL passar.

Zero é uma medida: alguém olhou e deu zero. Ausência é outra coisa: o dado não veio. Quando o código troca uma pela outra, o resultado é um número válido, que passa por todas as validações de tipo e só aparece como problema quando um produto é vendido por R$ 0,00 ou uma média cai sem motivo. Este guia é uma referência das conversões que fazem isso em JavaScript, PHP, Python, C#, MySQL e PostgreSQL, todas rodadas no Node 20, PHP 8.4.27, Python 3, .NET 10, MySQL 8.4.13 e PostgreSQL 18.6, seguida de uma leitura de preço que não chuta e de uma tabela que recusa o zero acidental.

JavaScript: o operador || apaga o aviso

Number(null)           // 0
Number("")             // 0
Number(" ")            // 0
Number(undefined)      // NaN
parseFloat("12,90")    // 12   (a vírgula encerra a leitura)
Number(undefined) || 0 // 0    (o NaN que denunciava o erro sumiu)
0 || 10                // 10   (desconto zero virou dez)
0 ?? 10                // 0
null ?? 0              // 0    (?? corrige o desconto, mas ainda inventa preço)
JSON.stringify({ preco: NaN, desconto: undefined })  // {"preco":null}
Math.round(1.005 * 100)  // 100, não 101

Dois detalhes pesam em sistema brasileiro. parseFloat("12,90") para na vírgula e devolve 12, sem erro, então uma planilha exportada em português vira preço truncado. E JSON.stringify transforma NaN em null e some com undefined, então o problema muda de cara quando o objeto atravessa a API. O operador ?? resolve o caso do desconto legítimo de zero, porque só substitui null e undefined, mas não decide nada por você: item.preco ?? 0 continua inventando preço. As regras de conversão do Number() estão na referência do Number no MDN.

A última linha não é sobre nulo, mas aparece no mesmo código de importação: 1.005 * 100 em ponto flutuante dá 100.49999…, e o arredondamento vai para 100. Converter preço multiplicando float por 100 perde centavo. Por isso a leitura mais abaixo trabalha com o texto. O guia de dinheiro em centavos explica por que guardar em inteiros.

PHP: conversão de tipo sem reclamação

intval(null)                          // int(0)
(int) ""                              // int(0)
(float) "12,90"                       // float(12)
(int) "abc"                           // int(0)
(int) ($item["preco"] ?? 0)           // int(0) com o campo ausente
filter_var("", FILTER_VALIDATE_INT)   // bool(false)
filter_var("0", FILTER_VALIDATE_INT)  // int(0)

As conversões de tipo do PHP transformam nulo, texto vazio e texto qualquer em 0, e "12,90" em 12. O filter_var com FILTER_VALIDATE_INT é o que separa os casos: vazio devolve false, e o zero digitado devolve 0. Compare com === false, nunca com !, ou o zero legítimo cai no mesmo balde do erro.

Python: or tem o mesmo defeito, o resto reclama

item = {"sku": "FN-349", "desconto": 0}
item.get("preco") or 0        # 0
item.get("desconto") or 10    # 10
float("12,90")                # ValueError: could not convert string to float: '12,90'
int(None)                     # TypeError
json.loads('{"preco": NaN}')  # {'preco': nan}

O or segue as regras de valor verdade: 0, None e texto vazio são falsos, então desconto or 10 estraga o zero. Em compensação, float("12,90") e int(None) levantam exceção, o que é bom. Um cuidado menos conhecido: o módulo json aceita NaN, que não faz parte do padrão JSON, e devolve nan, que passa em isinstance(x, float).

C#: decimal ausente vira 0 ao desserializar

using System.Text.Json;

var json = """{"sku":"FN-349"}""";
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);

JsonSerializer.Deserialize<ItemSimples>(json, web)!.Preco;    // 0
JsonSerializer.Deserialize<ItemAnulavel>(json, web)!.Preco;   // null

var estrito = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    RespectRequiredConstructorParameters = true,
};
JsonSerializer.Deserialize<ItemSimples>(json, estrito);
// JsonException: JSON deserialization for type 'ItemSimples' was missing
// required properties including: 'preco'.

record ItemSimples(string Sku, decimal Preco);
record ItemAnulavel(string Sku, decimal? Preco);

Com System.Text.Json, propriedade decimal que não veio no JSON fica com o valor padrão do tipo, que é zero. decimal? preserva a ausência como null. E desde o .NET 9 a opção RespectRequiredConstructorParameters faz o parâmetro de construtor de um record virar obrigatório, o que transforma a ausência em exceção logo na entrada. Para classes, a palavra-chave required tem o mesmo efeito. As duas formas estão na documentação de propriedades obrigatórias.

MySQL: o modo do servidor decide se o zero aparece

CREATE TABLE produtos (sku VARCHAR(20) PRIMARY KEY, preco_centavos INT NOT NULL);

SET SESSION sql_mode = '';                       -- comum em servidor antigo
INSERT INTO produtos (sku) VALUES ('FN-349');    -- grava 0
INSERT INTO produtos VALUES ('CB-USB', '');      -- grava 0

SET SESSION sql_mode = DEFAULT;                  -- STRICT_TRANS_TABLES ligado
INSERT INTO produtos (sku) VALUES ('MS-110');
-- ERROR 1364 (HY000): Field 'preco_centavos' doesn't have a default value

Sem o modo estrito, o MySQL grava 0 numa coluna NOT NULL sem default quando o INSERT esquece o campo, e converte texto vazio em 0, deixando só um aviso que quase ninguém lê. O modo padrão do 8.4 inclui STRICT_TRANS_TABLES e recusa o insert. Servidor migrado de versão antiga ou hospedagem compartilhada pode estar com o modo vazio: confira com SELECT @@GLOBAL.sql_mode;. E um DEFAULT 0 explícito na coluna faz o mesmo estrago em qualquer modo.

Agregação: NULL fica de fora, e SUM vazio é NULL

-- notas 10, NULL (não avaliou) e 8
SELECT AVG(nota), AVG(COALESCE(nota, 0)), COUNT(*), COUNT(nota) FROM avaliacoes;
--  9.0000 | 6.0000 | 3 | 2

-- produto sem nenhuma avaliação
SELECT SUM(nota), COALESCE(SUM(nota), 0) FROM avaliacoes WHERE produto = 2;
--  (null) | 0

As funções de agregação ignoram NULL, e isso é o correto: a média de quem avaliou é 9. Trocar NULL por zero antes de agregar muda a resposta para 6. No sentido contrário, SUM sobre nenhuma linha devolve NULL, não zero. Ali o COALESCE(SUM(...), 0) faz sentido, porque a soma de nada é mesmo zero. A regra prática: COALESCE depois de agregar costuma estar certo, antes de agregar costuma estar errado.

Uma leitura de preço que não chuta

Juntando tudo, a leitura de um preço vindo de fornecedor precisa separar três respostas: não veio, veio inválido e veio zero. Esta versão também aceita o formato brasileiro e não passa por ponto flutuante:

// Lê o preço que veio do fornecedor e devolve centavos, ou o motivo de não ter preço.
// Aceita "349.90", "349,90", 349.9 e "1.299,00"; recusa vazio, texto, negativo e zero sem brinde.
function lerPrecoCentavos(item) {
  const bruto = item.preco;
  if (bruto === undefined || bruto === null || (typeof bruto === 'string' && bruto.trim() === '')) {
    return { ok: false, motivo: 'sem preço' };
  }
  let texto = typeof bruto === 'number' ? String(bruto) : bruto.trim();
  if (/^\d{1,3}(\.\d{3})*,\d{1,2}$/.test(texto) || /^\d+,\d{1,2}$/.test(texto)) {
    texto = texto.replace(/\./g, '').replace(',', '.');       // formato brasileiro
  }
  const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(texto);
  if (!m) return { ok: false, motivo: 'preço inválido: ' + JSON.stringify(bruto) };
  const centavos = Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
  if (centavos === 0 && item.brinde !== true) {
    return { ok: false, motivo: 'preço zero sem marcação de brinde' };
  }
  return { ok: true, centavos };
}

const casos = [
  { sku: 'FN-349', preco: '349,90' },
  { sku: 'TV-55', preco: '1.299,00' },
  { sku: 'CB-USB', preco: 1.005 },
  { sku: 'MS-110' },
  { sku: 'PL-02', preco: ' ' },
  { sku: 'AD-12', preco: 'R$ 12' },
  { sku: 'BR-01', preco: 0, brinde: true },
  { sku: 'KB-77', preco: 0 },
];
for (const c of casos) console.log(c.sku.padEnd(7), JSON.stringify(lerPrecoCentavos(c)));
FN-349  {"ok":true,"centavos":34990}
TV-55   {"ok":true,"centavos":129900}
CB-USB  {"ok":false,"motivo":"preço inválido: 1.005"}
MS-110  {"ok":false,"motivo":"sem preço"}
PL-02   {"ok":false,"motivo":"sem preço"}
AD-12   {"ok":false,"motivo":"preço inválido: \"R$ 12\""}
BR-01   {"ok":true,"centavos":0}
KB-77   {"ok":false,"motivo":"preço zero sem marcação de brinde"}

O 1.005 foi recusado em vez de arredondado, porque preço com três casas é dado suspeito. "R$ 12" também, porque limpar símbolo de moeda é uma decisão que deve ficar explícita, não um efeito de parseFloat. Item recusado não deve ser descartado nem vendido: ele vai para um estado próprio, como sem_preco, com o motivo gravado para quem cuida do catálogo. E essa validação vale no servidor mesmo que a tela já valide, pelo motivo explicado em validar no navegador e repetir no servidor.

A tabela que recusa o zero acidental, e a pegadinha do CHECK

No banco, a primeira ideia é tirar o DEFAULT 0, permitir NULL para o item sem preço e criar uma regra que impeça produto ativo sem preço. Testado no PostgreSQL, a primeira versão da regra deixou passar justamente o caso que devia barrar:

ALTER TABLE produtos ALTER COLUMN preco_centavos DROP DEFAULT;
ALTER TABLE produtos ALTER COLUMN preco_centavos DROP NOT NULL;
ALTER TABLE produtos ADD CONSTRAINT preco_para_vender CHECK (
  status <> 'ativo' OR preco_centavos > 0 OR (preco_centavos = 0 AND brinde)
);
INSERT INTO produtos (sku) VALUES ('KB-77');   -- passou: preço NULL, status ativo

A documentação de restrições explica: um CHECK é satisfeito quando a expressão dá verdadeiro ou nulo, e qualquer comparação com NULL dá nulo. A regra precisa tratar a ausência explicitamente:

ALTER TABLE produtos ADD CONSTRAINT preco_para_vender CHECK (
  status <> 'ativo'
  OR (preco_centavos IS NOT NULL AND (preco_centavos > 0 OR brinde))
);
INSERT INTO produtos (sku, status) VALUES ('MS-110', 'sem_preco');       -- ok
INSERT INTO produtos (sku, preco_centavos, brinde) VALUES ('BR-01', 0, true);  -- ok
INSERT INTO produtos (sku) VALUES ('KB-77');
-- ERROR: new row for relation "produtos" violates check constraint "preco_para_vender"

Agora o item sem preço pode existir, desde que não esteja à venda. O brinde de zero continua permitido porque alguém o registrou como brinde. E o insert que esquece o preço de um produto ativo falha, que é o comportamento que se quer.

O teste que pega antes do fornecedor

Os casos da lista acima viram testes: campo ausente, nulo, texto vazio, espaço, vírgula, três casas, símbolo de moeda, zero com e sem brinde. Escreva o teste do campo ausente antes de corrigir o código e veja ele falhar. Para entradas digitadas na tela, o guia sobre campo que aceita só números trata do lado do formulário.

Avalie este artigo

Referência: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing