Trabalhando com datas e horas em JavaScript

[448] Trabalhando com datas e horas em JavaScript

Data parece o tipo mais simples que existe e é o que mais engana: o mês começa em zero, os setters alteram o objeto original e duas datas iguais não são idênticas. O artigo mostra o que um Date guarda de fato, por que uma string sem hora volta um dia, como formatar com Intl e onde o fuso horário derruba o cálculo.
Javascript

24 min de leitura

Data é o tipo que mais engana em JavaScript. Ele parece simples — tem dia, mês e ano, todo mundo sabe o que é uma data — e é justamente por isso que o código escrito no automático costuma funcionar na máquina de quem escreveu e falhar na de outra pessoa, ou funcionar em junho e quebrar em janeiro.

Os artigos anteriores esbarraram nisso mais de uma vez: o JSON.stringify transforma uma data em texto e o JSON.parse não a traz de volta; o app de clima recebe horários num fuso e os exibe em outro. Este artigo enfrenta o assunto de frente — o que o objeto Date guarda de verdade, onde ele mente, como formatar para quem lê e como fazer conta sem perder um dia no caminho.

O que um Date realmente guarda

Apesar do nome, um Date não guarda dia, mês nem ano. Ele guarda um número: a quantidade de milissegundos desde 1º de janeiro de 1970, 00:00 UTC — o instante conhecido como época Unix.

const agora = new Date();

console.log(agora.getTime());   // 1758712345678 — milissegundos desde 1970
console.log(Number(agora));     // o mesmo número
console.log(Date.now());        // atalho, sem criar objeto

// Tudo o mais é interpretação desse número
console.log(agora.getFullYear()); // calculado na hora, no fuso do ambiente
console.log(agora.toISOString()); // calculado na hora, em UTC

Essa é a ideia que explica quase todo o resto. Um Date representa um instante, não uma data de calendário. "15 de junho de 2025" não é um instante — é um dia inteiro, e ele começa e termina em momentos diferentes conforme o lugar do mundo. Quando você guarda "15 de junho" num Date, está escolhendo um instante para representá-lo, e essa escolha é onde os problemas nascem.

As formas de criar uma data

// 1. Agora
const agora = new Date();

// 2. A partir de milissegundos
const epoca = new Date(0);           // 1970-01-01T00:00:00.000Z

// 3. A partir de uma string
const s1 = new Date("2025-06-15T14:30:00Z");     // instante explícito, em UTC
const s2 = new Date("2025-06-15T14:30:00-03:00"); // instante explícito, com deslocamento

// 4. A partir de componentes — sempre no fuso LOCAL
const c = new Date(2025, 5, 15, 14, 30);  // 15/06/2025 14:30 local

// 5. A partir de outro Date (cópia)
const copia = new Date(agora);

A quarta forma tem a armadilha mais conhecida da linguagem: o mês começa em zero. O 5 ali é junho, não maio. Dia, ano, hora e minuto são normais; só o mês é indexado a partir de zero, herança direta da linguagem C.

new Date(2025, 0, 1);   // 1º de JANEIRO
new Date(2025, 11, 25); // 25 de DEZEMBRO

// Um efeito colateral útil: os valores são normalizados
new Date(2025, 0, 32);  // 1º de fevereiro — o dia 32 de janeiro "transborda"
new Date(2025, 0, 0);   // 31 de dezembro de 2024
new Date(2025, 12, 1);  // 1º de janeiro de 2026

Essa normalização não é acidente: é o mecanismo que faz a aritmética de datas funcionar sem que você precise saber quantos dias tem cada mês.

A pegadinha que mais custa caro

Duas strings quase idênticas produzem instantes diferentes — e a diferença é de um dia inteiro na exibição:

// Rodando num computador em São Paulo (UTC-3)

const a = new Date("2025-06-15");        // sem hora
const b = new Date("2025-06-15T00:00");  // com hora, sem fuso

console.log(a.toLocaleDateString("pt-BR")); // 14/06/2025  ← um dia antes!
console.log(b.toLocaleDateString("pt-BR")); // 15/06/2025

A regra que explica isso está na especificação da linguagem e é contraintuitiva:

"2025-06-15"             → interpretada como UTC
"2025-06-15T00:00"       → interpretada como LOCAL
"2025-06-15T00:00Z"      → UTC (o Z é explícito)
"2025-06-15T00:00-03:00" → o deslocamento manda

Ou seja: data pura é UTC, data com hora é local. Como o Brasil está três horas atrás de UTC, a meia-noite UTC do dia 15 é ainda o dia 14 aqui — e o toLocaleDateString, que formata no fuso local, mostra 14.

Isso morde exatamente onde dói: um campo <input type="date"> devolve "2025-06-15", uma string pura. Passar esse valor direto para new Date() é a receita do relatório que aparece com a data de ontem.

// ERRADO — o dia volta um
const escolhida = new Date(input.value);

// CERTO — força a interpretação local
const [ano, mes, dia] = input.value.split("-").map(Number);
const escolhida2 = new Date(ano, mes - 1, dia);

// Também certo, e mais curto
const escolhida3 = new Date(`${input.value}T00:00`);

Lendo as partes de uma data

const d = new Date(2025, 5, 15, 14, 30, 45);

d.getFullYear();     // 2025
d.getMonth();        // 5  ← junho, lembre do zero
d.getDate();         // 15 — dia do MÊS
d.getDay();          // 0  — dia da SEMANA (0 = domingo)
d.getHours();        // 14
d.getMinutes();      // 30
d.getSeconds();      // 45
d.getMilliseconds(); // 0

// A versão UTC de cada um
d.getUTCHours();     // 17, se o ambiente estiver em UTC-3

// Quanto o fuso local difere de UTC, em minutos, com o sinal invertido
d.getTimezoneOffset(); // 180 em São Paulo — sim, positivo para UTC-3

Dois nomes atrapalham a leitura do código alheio e vale fixá-los: getDate devolve o dia do mês e getDay devolve o dia da semana. E existe um getYear() antigo que devolve o ano menos 1900 — ele está obsoleto e não deve aparecer em código novo.

Formatando para quem vai ler

Montar a data concatenando getDate() com barras funciona, dá trabalho e erra em português. O caminho é a API de internacionalização, que já vem no navegador e no Node:

const d = new Date("2025-06-15T14:30:00-03:00");

d.toLocaleDateString("pt-BR");  // 15/06/2025
d.toLocaleTimeString("pt-BR");  // 14:30:00
d.toLocaleString("pt-BR");      // 15/06/2025, 14:30:00

// Com opções
d.toLocaleDateString("pt-BR", { dateStyle: "long" });
// 15 de junho de 2025

d.toLocaleDateString("pt-BR", { weekday: "long", day: "numeric", month: "long" });
// domingo, 15 de junho

d.toLocaleString("pt-BR", {
  dateStyle: "short",
  timeStyle: "short",
  timeZone: "America/Sao_Paulo",
});
// 15/06/2025, 14:30

Quando a mesma formatação é usada muitas vezes — numa tabela, numa lista —, vale criar o formatador uma vez só. Construir um Intl.DateTimeFormat é caro; usá-lo, não:

// Criado uma vez, no topo do módulo
const formatarData = new Intl.DateTimeFormat("pt-BR", {
  day: "2-digit",
  month: "2-digit",
  year: "numeric",
  timeZone: "America/Sao_Paulo",
});

// Usado mil vezes
linhas.forEach(l => console.log(formatarData.format(l.criadoEm)));

Para gravar ou trafegar, o formato é outro: toISOString(), que devolve sempre UTC, sempre com o mesmo layout, e é o que qualquer banco de dados e qualquer API entendem sem ambiguidade.

d.toISOString();  // "2025-06-15T17:30:00.000Z"
d.toJSON();       // o mesmo — é ele que o JSON.stringify chama

// E a volta
const deVolta = new Date("2025-06-15T17:30:00.000Z"); // exato, sem surpresa

A regra que resolve a maior parte dos problemas: guarde e transporte em ISO com fuso; formate só na hora de mostrar.

Fuso horário: onde tudo desanda

Um Date não tem fuso. Ele guarda um instante, e o fuso entra apenas na hora de interpretar esse instante — na leitura com os getters e na formatação. Quem decide qual fuso é o ambiente: o relógio do navegador de quem abriu a página, ou a configuração do servidor.

// O MESMO instante, lido em dois lugares diferentes
const instante = new Date("2025-06-15T23:00:00Z");

// Em São Paulo (UTC-3)
instante.toLocaleString("pt-BR"); // 15/06/2025, 20:00:00

// Em Lisboa (UTC+1 no verão)
instante.toLocaleString("pt-BR"); // 16/06/2025, 00:00:00  ← outro DIA

Não há erro nenhum aí: os dois estão certos, é o mesmo momento visto de lugares diferentes. O erro aparece quando o código supõe um fuso. A defesa é dizer qual você quer, em vez de aceitar o padrão:

// Sempre no horário de Brasília, não importa onde o código rode
const noBrasil = instante.toLocaleString("pt-BR", {
  timeZone: "America/Sao_Paulo",
});

// Qual é o fuso deste ambiente?
Intl.DateTimeFormat().resolvedOptions().timeZone; // "America/Sao_Paulo"

Três decisões práticas evitam a maioria dos incidentes com fuso:

// 1. No banco, sempre UTC (ou timestamptz). Nunca "hora local" sem dizer qual.
// 2. Na API, sempre ISO com fuso: "2025-06-15T17:30:00.000Z"
// 3. Na tela, formate com timeZone explícito quando o negócio tem um fuso
//    oficial — horário de um voo, fechamento de pregão, prazo de um edital.

E uma distinção que vale internalizar: há informações que são instante (quando o pedido foi pago) e há informações que são data de calendário (a data de nascimento, o vencimento de um boleto). A primeira pede Date e UTC. A segunda não tem hora nenhuma, e enfiá-la num Date é o que produz o aniversário que aparece um dia antes para quem está em outro fuso — guarde-a como string "1990-04-23" e trate como texto.

Aritmética com datas

Somar tempo se faz pelos setters, que normalizam sozinhos a virada de mês e de ano:

function somarDias(data, dias) {
  const nova = new Date(data);        // cópia — veja a próxima seção
  nova.setDate(nova.getDate() + dias);
  return nova;
}

somarDias(new Date(2025, 0, 30), 5);  // 4 de fevereiro — vira o mês sozinho
somarDias(new Date(2024, 1, 28), 1);  // 29 de fevereiro — 2024 é bissexto

Existe a tentação de somar milissegundos, e ela funciona na maior parte do tempo:

// Funciona... quase sempre
const amanha = new Date(hoje.getTime() + 24 * 60 * 60 * 1000);

O problema é que nem todo dia tem 24 horas. Em fusos com horário de verão, há um dia com 23 e outro com 25. Somar 86.400.000 milissegundos na véspera da virada devolve um horário deslocado — e, se a hora era meia-noite, devolve o dia errado. O Brasil não tem mais horário de verão desde 2019, mas um sistema com usuários no Chile, em Portugal ou nos Estados Unidos tem. O setDate não sofre disso: ele trabalha em calendário, não em duração.

Somar mês tem uma regra própria, que surpreende quando aparece:

const d = new Date(2025, 0, 31);  // 31 de janeiro
d.setMonth(d.getMonth() + 1);
console.log(d.toLocaleDateString("pt-BR")); // 03/03/2025 — não é 28 de fevereiro!

O motor calculou "31 de fevereiro" e normalizou para 3 de março. Não é defeito, é a mesma regra de transbordo de antes — mas raramente é o que se quer numa tela de assinatura mensal. Quando o desejado é "o último dia do mês seguinte se o dia não existir", é preciso tratar o caso à mão, e esse é um dos motivos pelos quais bibliotecas de data existem.

A diferença entre duas datas sai da subtração, que devolve milissegundos:

const inicio = new Date("2025-06-01T00:00:00-03:00");
const fim    = new Date("2025-06-15T00:00:00-03:00");

const ms = fim - inicio;             // 1209600000
const dias = ms / (1000 * 60 * 60 * 24); // 14

// Para "quantos dias de calendário separam as duas", zere as horas antes —
// senão 15/06 às 23h e 16/06 à 01h dão 0 dia de diferença.
function diasEntre(a, b) {
  const x = new Date(a.getFullYear(), a.getMonth(), a.getDate());
  const y = new Date(b.getFullYear(), b.getMonth(), b.getDate());
  return Math.round((y - x) / 86400000);
}

O Math.round ali não é preguiça: com horário de verão no meio do intervalo, a divisão devolve algo como 13,958, e um Math.floor transformaria isso em 13 dias.

Comparando datas

const a = new Date("2025-06-15T10:00:00Z");
const b = new Date("2025-06-15T10:00:00Z");

a < b;   // false — funciona, os objetos viram número
a > b;   // false — funciona
a === b; // false ← são dois objetos diferentes!
a == b;  // false ← também não funciona

a.getTime() === b.getTime(); // true  ← a forma correta
Number(a) === Number(b);     // true  ← equivalente

A assimetria é desconcertante e tem explicação: < e > convertem os operandos em número antes de comparar, então acabam comparando os instantes. Já === e ==, entre dois objetos, comparam referência — perguntam se é o mesmo objeto na memória, não se valem o mesmo. É o mesmo comportamento de dois arrays iguais que não são ===, visto no artigo de objetos.

Para ordenar uma lista, a subtração resolve, porque o sort quer um número:

posts.sort((a, b) => b.criadoEm - a.criadoEm); // mais recentes primeiro

Date é mutável — e isso morde

const vencimento = new Date(2025, 5, 15);
const lembrete = vencimento;          // NÃO é cópia: é o mesmo objeto

lembrete.setDate(lembrete.getDate() - 3);

console.log(vencimento.toLocaleDateString("pt-BR")); // 12/06/2025 ← mudou!

Todo set* altera o objeto em que é chamado e devolve um número, não uma data nova. Como atribuir um objeto a outra variável não copia nada — a mesma armadilha de referência dos objetos comuns —, uma função que recebe um Date e usa um setter está alterando a data de quem a chamou.

// Copie antes de mexer, sempre
const copia = new Date(original);
// ou
const copia2 = new Date(original.getTime());

Essa mutabilidade é uma das críticas mais antigas ao Date, e é o motivo de bibliotecas modernas devolverem sempre um objeto novo em vez de alterar o recebido.

Data inválida

const d = new Date("30/02/2025");  // formato brasileiro não é entendido
console.log(d);                     // Invalid Date
console.log(d.getFullYear());       // NaN
console.log(d.toLocaleDateString()); // "Invalid Date" — a string vai para a tela

Uma data inválida não lança erro: ela se comporta como um Date qualquer e vai contaminando tudo com NaN até aparecer, em geral já na interface do usuário. A verificação é sempre a mesma:

function dataValida(d) {
  return d instanceof Date && !Number.isNaN(d.getTime());
}

E vale saber que o Date só entende com segurança o formato ISO. Strings como "15/06/2025" ou "June 15, 2025" dependem do motor e podem ser lidas de formas diferentes em navegadores diferentes — "03/04/2025" é 3 de abril para um e 4 de março para outro. Para ler data digitada por gente, faça o parse você mesmo, como no exemplo do <input type="date">.

Tempo relativo: "há 2 dias"

Escrever "há 3 horas" à mão significa cuidar de plural, de singular e de cada faixa. A plataforma já faz isso, e em português:

const rtf = new Intl.RelativeTimeFormat("pt-BR", { numeric: "auto" });

rtf.format(-1, "day");   // "ontem"       ← numeric: "auto" troca por palavra
rtf.format(-3, "day");   // "há 3 dias"
rtf.format(2, "hour");   // "em 2 horas"
rtf.format(-1, "month"); // "mês passado"

function tempoRelativo(data, agora = new Date()) {
  const segundos = Math.round((data - agora) / 1000);
  const faixas = [
    ["year", 31536000], ["month", 2592000], ["day", 86400],
    ["hour", 3600], ["minute", 60], ["second", 1],
  ];

  for (const [unidade, tamanho] of faixas) {
    if (Math.abs(segundos) >= tamanho || unidade === "second") {
      return rtf.format(Math.round(segundos / tamanho), unidade);
    }
  }
}

Quando usar biblioteca

O Date dá conta de guardar instante, formatar com Intl e somar dias. Fora disso, a conta de linhas escritas à mão cresce rápido: somar mês respeitando o último dia, calcular dias úteis, montar um calendário, converter entre fusos nomeados, interpretar formatos variados de entrada.

// date-fns — funções soltas, você importa só o que usa
import { addMonths, differenceInBusinessDays, format } from "date-fns";
import { ptBR } from "date-fns/locale";

format(new Date(), "dd 'de' MMMM", { locale: ptBR }); // 15 de junho

// Day.js — API pequena, 2 KB, parecida com a do antigo Moment
// Luxon — a mais forte em fuso horário e duração

O Moment.js ainda aparece em muito material antigo, mas os próprios autores o declararam projeto encerrado: ele não recebe recursos novos e é grande demais para projeto novo. Se você o encontrar num código existente, é sinal de dívida técnica, não de boa prática.

No horizonte está o Temporal, uma API nova que o comitê da linguagem desenhou justamente para substituir o Date — com objetos imutáveis, tipos separados para instante, data de calendário e hora do dia, e fuso horário de primeira classe. Ela já começou a aparecer nos navegadores e resolve de nascença quase tudo o que este artigo apresentou como armadilha. Até virar rotina, o que vale é conhecer as pegadinhas do Date: elas vão continuar em produção por muitos anos.

Tarefa para você

Monte um módulo datas.js com as funções abaixo, sem usar biblioteca externa:

// 1. dataDoInput(valor)
//    Recebe "2025-06-15" de um <input type="date"> e devolve um Date
//    no dia CERTO, no fuso local.

// 2. formatar(data, estilo)
//    estilo "curto"  → 15/06/2025
//    estilo "longo"  → domingo, 15 de junho de 2025
//    estilo "completo" → 15/06/2025 às 14:30

// 3. somarDiasUteis(data, dias)
//    Pula sábados e domingos. somarDiasUteis(sexta, 1) → segunda.

// 4. idade(nascimento)
//    Em anos completos. Atenção a quem faz aniversário depois de hoje.

// 5. tempoRelativo(data)
//    "há 3 dias", "em 2 horas", "ontem" — usando Intl.RelativeTimeFormat.

// 6. mesmoDia(a, b)
//    true se as duas datas caem no mesmo dia de calendário local.

// Bônus: escreva um teste para cada uma usando 29/02/2024 e
// 31/01/2025 como entradas — são as datas que quebram implementações
// ingênuas.
Ver solução — o módulo inteiro, com os casos de borda tratados
// ---- datas.js

// ---------------------------------------------------------------
// 1 — a data que veio do input
// ---------------------------------------------------------------
export function dataDoInput(valor) {
  // new Date("2025-06-15") seria interpretada como UTC e, em qualquer
  // fuso negativo, exibiria o dia ANTERIOR. Construir por componentes
  // força a interpretação local, que é o que o usuário quis dizer ao
  // escolher o dia no calendário.
  if (typeof valor !== "string") return new Date(NaN);

  const [ano, mes, dia] = valor.split("-").map(Number);
  if (!ano || !mes || !dia) return new Date(NaN);

  const d = new Date(ano, mes - 1, dia);

  // Cuidado com o transbordo: "2025-02-30" viraria 2 de março em
  // silêncio. Conferir os componentes de volta rejeita a data falsa.
  if (d.getFullYear() !== ano || d.getMonth() !== mes - 1 || d.getDate() !== dia) {
    return new Date(NaN);
  }

  return d;
}

// ---------------------------------------------------------------
// 2 — formatação
// ---------------------------------------------------------------
// Os formatadores são criados UMA vez: construir um Intl.DateTimeFormat
// é caro, formatar com ele é barato. Numa tabela de mil linhas a
// diferença é visível.
const FORMATOS = {
  curto: new Intl.DateTimeFormat("pt-BR", { dateStyle: "short" }),
  longo: new Intl.DateTimeFormat("pt-BR", {
    weekday: "long", day: "numeric", month: "long", year: "numeric",
  }),
  completo: new Intl.DateTimeFormat("pt-BR", {
    dateStyle: "short", timeStyle: "short",
  }),
};

export function formatar(data, estilo = "curto") {
  if (!valida(data)) return "—"; // nunca deixe "Invalid Date" chegar à tela

  const f = FORMATOS[estilo] ?? FORMATOS.curto;
  const texto = f.format(data);

  // O estilo "completo" do pt-BR sai como "15/06/2025, 14:30"; o
  // enunciado pediu "às".
  return estilo === "completo" ? texto.replace(", ", " às ") : texto;
}

export function valida(d) {
  return d instanceof Date && !Number.isNaN(d.getTime());
}

// ---------------------------------------------------------------
// 3 — dias úteis
// ---------------------------------------------------------------
export function somarDiasUteis(data, dias) {
  const d = new Date(data); // cópia: setDate mutaria o argumento
  const passo = dias < 0 ? -1 : 1;
  let restantes = Math.abs(dias);

  while (restantes > 0) {
    d.setDate(d.getDate() + passo);
    const semana = d.getDay();
    if (semana !== 0 && semana !== 6) restantes--;
  }

  return d;
}
// Não há atalho aritmético confiável aqui, e a razão é o feriado: um
// calendário de dias úteis de verdade precisa de uma lista deles, que
// varia por país, estado e município. Esta versão trata só o fim de
// semana — e deve dizer isso em voz alta, senão vira bug de prazo.

// ---------------------------------------------------------------
// 4 — idade em anos completos
// ---------------------------------------------------------------
export function idade(nascimento, hoje = new Date()) {
  if (!valida(nascimento)) return null;

  let anos = hoje.getFullYear() - nascimento.getFullYear();

  // A subtração de anos supõe que o aniversário já passou. Se ainda
  // não passou neste ano, desconta um. Sem isto, quem nasceu em
  // dezembro fica um ano mais velho durante onze meses.
  const mes = hoje.getMonth() - nascimento.getMonth();
  if (mes < 0 || (mes === 0 && hoje.getDate() < nascimento.getDate())) {
    anos--;
  }

  return anos;
}
// Nascido em 29/02/2024: em 28/02/2025 ainda tem 0 anos por esta
// conta, e completa 1 em 01/03/2025. Não existe resposta única — a
// lei brasileira considera 1º de março —, mas a decisão tem de ser
// consciente, e não um acidente de arredondamento.

// ---------------------------------------------------------------
// 5 — tempo relativo
// ---------------------------------------------------------------
const RTF = new Intl.RelativeTimeFormat("pt-BR", { numeric: "auto" });

const FAIXAS = [
  ["year", 31_536_000],
  ["month", 2_592_000],
  ["week", 604_800],
  ["day", 86_400],
  ["hour", 3_600],
  ["minute", 60],
  ["second", 1],
];

export function tempoRelativo(data, agora = new Date()) {
  if (!valida(data)) return "—";

  const segundos = (data - agora) / 1000;

  for (const [unidade, tamanho] of FAIXAS) {
    if (Math.abs(segundos) >= tamanho) {
      // trunc, não round: faltando 1,9 dia, "em 1 dia" é mais honesto
      // do que "em 2 dias" para um prazo.
      return RTF.format(Math.trunc(segundos / tamanho), unidade);
    }
  }

  return "agora mesmo";
}

// ---------------------------------------------------------------
// 6 — mesmo dia de calendário
// ---------------------------------------------------------------
export function mesmoDia(a, b) {
  if (!valida(a) || !valida(b)) return false;

  // Comparar os três componentes locais, e não o timestamp: dois
  // instantes separados por segundos podem cair em dias diferentes,
  // e dois separados por horas podem cair no mesmo.
  return (
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  );
}

// ---------------------------------------------------------------
// Os testes que o enunciado pediu
// ---------------------------------------------------------------
// 29/02/2024 — bissexto
console.assert(formatar(dataDoInput("2024-02-29")) === "29/02/2024");
console.assert(!valida(dataDoInput("2025-02-29")), "2025 não é bissexto");

// 31/01/2025 — o mês que "transborda"
const jan31 = new Date(2025, 0, 31);
const fev = new Date(jan31);
fev.setMonth(fev.getMonth() + 1);
console.assert(fev.getDate() === 3, "31 de fevereiro vira 3 de março");

// sexta + 1 dia útil = segunda
const sexta = new Date(2025, 5, 13);
console.assert(somarDiasUteis(sexta, 1).getDay() === 1);

// o dia não pode voltar
console.assert(dataDoInput("2025-06-15").getDate() === 15);

O caso que mais revela implementação ingênua é o dataDoInput: quase todo código de formulário faz new Date(input.value) e perde um dia em qualquer fuso a oeste de Greenwich — inclusive o Brasil inteiro. O segundo é o idade, onde esquecer a comparação de mês e dia envelhece metade dos usuários em um ano.

Guarde instante em UTC, formate no fuso de quem lê, e nunca ponha uma data de calendário — aniversário, vencimento, feriado — dentro de um Date só porque ela parece uma data. A maior parte dos defeitos deste artigo nasce de confundir essas duas coisas, e os restantes vêm de três detalhes que vale decorar: o mês começa em zero, os setters alteram o objeto original, e === entre duas datas iguais devolve false.

Fontes e Referências

Exercícios

Exercício 1

O usuário escolhe 15 de junho no calendário do formulário. O sistema registra 14. O código roda em São Paulo. Onde está o erro?

// <input type="date" id="vencimento">
const valor = document.querySelector("#vencimento").value; // "2025-06-15"

const data = new Date(valor);
console.log(data.toLocaleDateString("pt-BR")); // 14/06/2025
Ver resposta

✓ Resposta: O erro está em passar a string do input direto para new Date(). Pela especificação da linguagem, uma data sem hora é interpretada como UTC — então "2025-06-15" vira a meia-noite UTC, que em São Paulo, três horas atrás, ainda é o dia 14 às 21h. O toLocaleDateString formata no fuso local e mostra 14. A regra completa é contraintuitiva e vale decorar: data pura é UTC, data com hora e sem fuso é local — new Date("2025-06-15T00:00") devolve o dia certo. As duas correções são acrescentar a hora à string, new Date(`${valor}T00:00`), ou construir por componentes: const [a, m, d] = valor.split("-").map(Number); new Date(a, m - 1, d). Repare que o defeito é invisível para quem desenvolve em fuso positivo: em Berlim ou em Tóquio a meia-noite UTC cai no mesmo dia, o teste passa, e o bug só aparece para os usuários. E há um agravante quando a data volta ao servidor por toISOString() — o valor gravado fica um dia atrás no banco, e ninguém liga o sintoma à causa.

Exercício 2

Uma assinatura vence dia 31 de janeiro. O código calcula o vencimento seguinte. Que data ele produz?

const vencimento = new Date(2025, 0, 31);

vencimento.setMonth(vencimento.getMonth() + 1);

console.log(vencimento.toLocaleDateString("pt-BR"));
Ver resposta

✓ Resposta: Produz 03/03/2025. O setMonth trocou o mês mantendo o dia 31, o que descreve "31 de fevereiro"; como esse dia não existe, o motor normaliza e avança três dias além do fim de fevereiro — que em 2025 tem 28. Não é defeito: é a mesma regra de transbordo que faz new Date(2025, 0, 32) ser 1º de fevereiro, e é ela que permite setDate(getDate() + 1) virar o mês sozinho. O problema é que quase nunca é isso que se quer numa cobrança: o cliente contratou dia 31 e seria cobrado dia 3, e em 2024 — ano bissexto — seria dia 2, com o mesmo código. Sistemas de assinatura resolvem isso fixando a regra antes: ou o vencimento cai no último dia do mês quando o dia não existe, ou a data é sempre "o dia N ou o último, o que vier primeiro". A verificação é simples — depois do setMonth, se getDate() não for mais o dia original, volte para o último dia do mês anterior com setDate(0).

Exercício 3

As duas datas representam exatamente o mesmo instante. Por que a terceira linha imprime false enquanto as duas primeiras se comportam como esperado?

const a = new Date("2025-06-15T10:00:00Z");
const b = new Date("2025-06-15T10:00:00Z");

console.log(a <= b); // A
console.log(a >= b); // B
console.log(a === b); // C
Ver resposta

✓ Resposta: A e B imprimem true; C imprime false. Os operadores relacionais — <, >, <=, >= — convertem os operandos em número antes de comparar, e um Date convertido em número é o seu timestamp; comparam-se então dois instantes iguais. Já === entre dois objetos compara referência: a pergunta que ele faz não é "valem o mesmo?", e sim "são o mesmo objeto na memória?". Como são dois objetos distintos, a resposta é não. O == tampouco ajuda — entre dois objetos ele também compara referência, sem coerção nenhuma. A forma correta de testar igualdade é comparar os números explicitamente: a.getTime() === b.getTime(). Essa assimetria — os relacionais funcionam, o de igualdade não — é o que torna o defeito difícil de enxergar: um teste que use <= passa, e o que usa === falha silenciosamente, sempre devolvendo "diferente" e fazendo, por exemplo, um cache nunca reconhecer a data já processada.

Exercício 4

A função devolve a data do lembrete três dias antes do vencimento. Depois de chamá-la, o vencimento também mudou. Por quê?

function lembreteAntes(vencimento, dias) {
  vencimento.setDate(vencimento.getDate() - dias);
  return vencimento;
}

const fatura = { venceEm: new Date(2025, 5, 15) };
const lembrete = lembreteAntes(fatura.venceEm, 3);

console.log(lembrete.toLocaleDateString("pt-BR"));      // 12/06/2025
console.log(fatura.venceEm.toLocaleDateString("pt-BR")); // 12/06/2025
Ver resposta

✓ Resposta: Porque Date é mutável e todo set* altera o próprio objeto, em vez de devolver um novo. A função recebeu a referência do Date que está dentro de fatura — passar um objeto não copia nada —, e o setDate reescreveu aquele objeto. O return devolve a mesma referência, de modo que lembrete e fatura.venceEm são o mesmo Date: a fatura passou a vencer três dias antes, e nada no código sugere isso. É a armadilha de referência dos objetos comuns, agravada pelo fato de os setters de data parecerem operações de cálculo. A correção é copiar antes de mexer — const nova = new Date(vencimento) — e devolver a cópia; a função vira pura e o chamador fica protegido. Vale como regra geral: toda função que recebe um Date e usa um setter deve copiar na primeira linha. E é exatamente essa característica que as bibliotecas modernas abandonaram — no date-fns, addDays devolve uma data nova e não encosta na original, e é também o comportamento do Temporal, cujos objetos são imutáveis por construção.

Exercício 5

O formulário aceita a data digitada como texto. O usuário escreve 03/04/2025. O que o sistema registra — e o que acontece com 30/02/2025?

const digitada = "03/04/2025";
const d = new Date(digitada);

salvar({ vencimento: d.toISOString() });
Ver resposta

✓ Resposta: Com "03/04/2025", o sistema registra 3 de abril ou 4 de março, dependendo do navegador. Esse formato não está na especificação: o Date só garante a leitura do padrão ISO, e qualquer outra string cai num caminho que cada motor implementa como quer — o V8 tende a interpretar como mês/dia/ano, à americana, enquanto outros motores divergem. O resultado é um sistema que grava datas diferentes conforme o navegador do usuário, sem erro nenhum no caminho. Já "30/02/2025" produz Invalid Date, e aí o problema muda de natureza: Invalid Date não lança. O toISOString(), sim — ele estoura com RangeError: Invalid time value —, mas se o código usasse toLocaleDateString() em vez dele, a string "Invalid Date" iria calmamente para a tela ou para o banco. A regra que fecha os dois casos: nunca entregue texto digitado por gente ao new Date(). Faça o parse você mesmo, componente a componente, valide que o resultado é uma data real — comparando getDate(), getMonth() e getFullYear() com o que foi digitado, o que rejeita o 30 de fevereiro que o construtor normalizaria em silêncio — e confira com Number.isNaN(d.getTime()) antes de usar. Melhor ainda: use <input type="date"> e receba sempre o formato ISO.

Comentários

Mais em Javascript

Async/Await: escrevendo código assíncrono de forma limpa
Async/Await: escrevendo código assíncrono de forma limpa

A cadeia de .then resolveu a pirâmide mas espalhou os valores por escopos…

ESLint e Prettier: código limpo e padronizado
ESLint e Prettier: código limpo e padronizado

Entrar num projeto em que cada arquivo tem um estilo custa caro: revisão que…

Desestruturação, Spread e Rest Operator
Desestruturação, Spread e Rest Operator

Os mesmos três pontinhos espalham num lugar e recolhem no outro: espalhando…