Pular para o conteúdo principal

Temporal — o fim do new Date() (e dos bugs de data que a gente aceitou por 30 anos)

· 14 min para ler
Bruno Carneiro
Fundador da @TautornTech
Temporal API no JavaScript

Todo dev JavaScript tem uma história com Date. O relatório que mostrava o dia anterior. O vencimento que pulou de 31 de janeiro pra 3 de março. A data de nascimento que mudou de dia quando o usuário estava em outro fuso. O moment de 300KB instalado só pra somar um mês.

O Date foi criado em 1995, em poucos dias, copiado do java.util.Date. O próprio Java depreciou boa parte dela pouco tempo depois. O JavaScript ficou com ela por 30 anos.

Isso acabou. Em março de 2026 o Temporal chegou ao Stage 4 no TC39 e virou parte do ECMAScript 2026. Já roda sem flag no Chrome, no Edge, no Firefox e no Node 26.

Esse artigo é sobre o que de fato melhorou. Com exemplo real, rodando, lado a lado com o new Date().

O problema de verdade do Date​

Antes dos exemplos, vale entender a raiz. O Date mistura duas coisas completamente diferentes num objeto só:

  • Um instante no tempo: um ponto exato na história, igual pra todo mundo no planeta. Por baixo, o Date é só isso: milissegundos desde 1970.
  • Uma data e hora "de calendário": o que você lê no relógio da parede. "1º de outubro, 10h". Isso depende de onde você está.

O Date guarda o primeiro, mas quase toda a API dele te faz trabalhar com o segundo, convertendo implicitamente pelo fuso da máquina. É dessa mistura que sai a maioria dos bugs.

O Temporal separa as duas coisas em tipos diferentes. E essa decisão de design resolve mais problema do que qualquer método novo.

O que melhorou, na prática​

Todos os exemplos abaixo rodaram com o fuso America/Sao_Paulo.

1. Mês começa em 1 (finalmente)​

new Date(2026, 1, 10)
// Tue Feb 10 2026 → fevereiro, porque o mês começa em 0

Temporal.PlainDate.from({ year: 2026, month: 2, day: 10 })
// 2026-02-10 → fevereiro, porque fevereiro é o mês 2

Parece bobo, mas quantos month + 1 e month - 1 espalhados você já viu num codebase?

2. Imutável​

// ❌ Date
const inicio = new Date(2026, 9, 1)
const fim = inicio
fim.setDate(fim.getDate() + 7)

inicio.toDateString() // "Thu Oct 08 2026" 😬
fim.toDateString() // "Thu Oct 08 2026"

Os setters do Date alteram o objeto. Passou a data pra uma função que fez setDate? A sua data mudou também. Isso é bug clássico em estado de React, em filtros de período, em qualquer lugar que a data é compartilhada.

// ✅ Temporal
const inicio = Temporal.PlainDate.from('2026-10-01')
const fim = inicio.add({ days: 7 })

inicio.toString() // "2026-10-01"
fim.toString() // "2026-10-08"

Nenhum método do Temporal altera o objeto. add, subtract, with, round: todos devolvem um objeto novo.

3. Parse sem surpresa de fuso​

Esse aqui é provavelmente o bug de data mais comum no Brasil:

const data = new Date('2026-10-01')

data.toString()
// "Wed Sep 30 2026 21:00:00 GMT-0300" 😬
data.getDate()
// 30

Uma string só com data (YYYY-MM-DD) é interpretada como meia-noite em UTC. Em São Paulo, isso é 21h do dia anterior. Agora, se você passar '2026-10-01T00:00' (com hora e sem Z), ele interpreta como horário local. Duas strings quase iguais, dois comportamentos diferentes.

É assim que nasce o "a data de vencimento aparece um dia antes no front".

Temporal.PlainDate.from('2026-10-01').day
// 1

Um PlainDate é uma data de calendário, sem hora e sem fuso. Não tem conversão implícita pra lugar nenhum. 1º de outubro é 1º de outubro.

4. Somar meses sem pular pra março​

Vencimento todo dia 31, a partir de janeiro:

// ❌ Date
const d = new Date(2026, 0, 31)
d.setMonth(d.getMonth() + 1)
d.toDateString() // "Tue Mar 03 2026" 😬

O Date tenta criar 31 de fevereiro, que não existe, e "transborda" os dias excedentes pra março.

// ✅ Temporal
const vencimento = Temporal.PlainDate.from('2026-01-31')

vencimento.add({ months: 1 }) // 2026-02-28
vencimento.add({ months: 2 }) // 2026-03-31
vencimento.add({ months: 3 }) // 2026-04-30

Temporal.PlainDate.from('2024-01-31').add({ months: 1 }) // 2024-02-29 (bissexto)

Por padrão, o Temporal ajusta pro último dia válido do mês. E repara num detalhe: como cada soma parte da data original, o vencimento volta pro dia 31 em março. Com setMonth em sequência, isso se perderia.

E se pra sua regra de negócio data inválida tem que ser erro, você pede isso explicitamente:

vencimento.add({ months: 1 }, { overflow: 'reject' })
// RangeError

5. Diferença entre datas que não depende de matemática com milissegundo​

O jeito "clássico" de contar dias com Date é subtrair e dividir por 86400000. Funciona... até não funcionar.

Lembra do horário de verão? Em 2018, ele começou no Brasil em 4 de novembro. Aquele dia teve 23 horas:

// ❌ Date (TZ=America/Sao_Paulo)
const a = new Date(2018, 10, 3)
const b = new Date(2018, 10, 5)

(b - a) / 86400000 // 1.9583333333333333
Math.floor((b - a) / 86400000) // 1 😬

De 3 pra 5 de novembro são 2 dias. O código diz 1.

// ✅ Temporal
Temporal.PlainDate.from('2018-11-03').until('2018-11-05').days
// 2

O Brasil não tem horário de verão desde 2019, mas os EUA, a Europa, o Chile e o Paraguai ainda têm. E volta e meia se discute a volta por aqui. Código que depende de "um dia = 86.400.000 ms" é bug esperando o fuso certo.

E o until faz bem mais que contar dias:

const hoje = Temporal.PlainDate.from('2026-10-01')
const natal = Temporal.PlainDate.from('2026-12-25')

hoje.until(natal).days // 85
hoje.until(natal, { largestUnit: 'months' }).toString() // "P2M24D" → 2 meses e 24 dias

// idade
Temporal.PlainDate.from('1990-05-15')
.until(hoje, { largestUnit: 'years' }).years // 36

Calcular idade com Date é aquela função de 10 linhas que todo projeto tem e que sempre tem um bug no dia do aniversário. Com Temporal é uma linha.

6. Fuso horário de verdade​

O Date só conhece dois fusos: UTC e o da máquina. Quer saber que horas vai ser uma reunião em Lisboa? Biblioteca externa ou conta na mão.

const reuniao = Temporal.ZonedDateTime.from({
year: 2026, month: 10, day: 15, hour: 10,
timeZone: 'America/Sao_Paulo',
})

reuniao.toString()
// "2026-10-15T10:00:00-03:00[America/Sao_Paulo]"

reuniao.withTimeZone('Europe/Lisbon').toPlainTime().toString() // "14:00:00"
reuniao.withTimeZone('America/New_York').toPlainTime().toString() // "09:00:00"
reuniao.withTimeZone('Asia/Tokyo').toString()
// "2026-10-15T22:00:00+09:00[Asia/Tokyo]"

Repara na string: ela carrega o offset (-03:00) e o fuso ([America/Sao_Paulo]). Isso é o formato do RFC 9557, uma extensão do ISO 8601. O offset diz o instante exato; o nome do fuso diz as regras pra fazer conta depois. Um ISO comum (2026-10-15T13:00:00Z) perde essa segunda informação.

7. Horário de verão sem armadilha​

Somar "1 dia" e somar "24 horas" parecem a mesma coisa. Não são. Nos EUA, o horário de verão de 2026 começa em 8 de março:

const sabado = Temporal.ZonedDateTime.from('2026-03-07T12:00[America/New_York]')

sabado.add({ days: 1 }).toString()
// "2026-03-08T12:00:00-04:00[America/New_York]" → mesmo horário no dia seguinte

sabado.add({ hours: 24 }).toString()
// "2026-03-08T13:00:00-04:00[America/New_York]" → 24 horas reais depois

Os dois estão certos, porque significam coisas diferentes. "Lembrete amanhã ao meio-dia" é days: 1. "Token expira em 24 horas" é hours: 24. O Temporal te obriga a dizer qual dos dois você quer.

E o meu exemplo favorito, bem brasileiro: em 4 de novembro de 2018, o horário de verão começava à meia-noite em São Paulo. O relógio pulou de 23:59:59 direto pra 01:00. Meia-noite não existiu.

// ❌ Date
new Date(2018, 10, 4).toString()
// "Sun Nov 04 2018 01:00:00 GMT-0200" → você pediu meia-noite, ganhou 1h

Quem tinha código assumindo que "início do dia = 00:00" quebrou nesse dia. Filtro de relatório, agendamento, comparação de data.

// ✅ Temporal
const dia = Temporal.ZonedDateTime.from('2018-11-04T12:00[America/Sao_Paulo]')

dia.startOfDay().toString()
// "2018-11-04T01:00:00-02:00[America/Sao_Paulo]" → o início real do dia
dia.hoursInDay
// 23

O startOfDay() sabe que aquele dia começou à 01:00. O hoursInDay sabe que ele teve 23 horas. E se o seu sistema precisa tratar horário inexistente como erro, também dá:

Temporal.ZonedDateTime.from(
'2018-11-04T00:30[America/Sao_Paulo]',
{ disambiguation: 'reject' }
)
// RangeError

8. Comparar e ordenar​

const datas = ['2026-12-25', '2026-01-31', '2026-10-01']
.map((s) => Temporal.PlainDate.from(s))

datas.sort(Temporal.PlainDate.compare)
// [2026-01-31, 2026-10-01, 2026-12-25]

Temporal.PlainDate.from('2026-10-01').equals(Temporal.PlainDate.from('2026-10-01')) // true
new Date(2026, 9, 1) === new Date(2026, 9, 1) // false

Cada tipo tem um compare estático, que encaixa direto no sort, e um equals. Nada de getTime() pra comparar.

aviso

Não use < e > com objetos Temporal. Diferente do Date, eles lançam erro de propósito:

Temporal.PlainDate.from('2026-01-01') < Temporal.PlainDate.from('2026-02-01')
// TypeError: Do not use built-in arithmetic operators with Temporal objects...

É chato na primeira vez, mas evita comparação silenciosamente errada.

9. Durações como cidadãs de primeira classe​

const duracao = Temporal.Duration.from({ minutes: 150 })

duracao.round({ largestUnit: 'hours' }).toString() // "PT2H30M"
duracao.total({ unit: 'hours' }) // 2.5

Com Date, duração é um número de milissegundos solto que você precisa lembrar o que significa. Com Temporal, é um objeto que sabe o que é, que serializa em ISO 8601 (PT2H30M) e que pode ser somado a qualquer data.

10. Utilitários que você sempre escreveu na mão​

const data = Temporal.PlainDate.from('2026-02-10')

data.with({ day: 1 }).toString() // "2026-02-01" → primeiro dia do mês
data.with({ day: data.daysInMonth }).toString() // "2026-02-28" → último dia do mês
data.daysInMonth // 28
data.inLeapYear // false

Temporal.PlainDate.from('2026-10-01').dayOfWeek // 4 → quinta (segunda = 1, domingo = 7)

O dayOfWeek começa em 1 na segunda-feira, seguindo a ISO. Diferente do getDay(), que começa em 0 no domingo. Outro + 1 que some do codebase.

Qual tipo usar​

Essa é a parte que mais assusta no começo: são vários tipos. Mas cada um existe pra um caso, e escolher o tipo certo já elimina uma categoria inteira de bug.

CasoTipo
Timestamp de log, created_at, evento no sistemaTemporal.Instant
Reunião, voo, agendamento com fusoTemporal.ZonedDateTime
Aniversário, vencimento, feriadoTemporal.PlainDate
Horário de abertura da loja, alarme diárioTemporal.PlainTime
Data e hora "de formulário", sem fuso definidoTemporal.PlainDateTime
Competência de fatura, validade de cartãoTemporal.PlainYearMonth
Data que se repete todo ano (Natal, aniversário)Temporal.PlainMonthDay
Quanto tempo algo levaTemporal.Duration
AgoraTemporal.Now

A regra que eu uso: se é um momento que aconteceu, é Instant. Se é algo que uma pessoa marcou num calendário, é Plain*. Se precisa de fuso pra fazer sentido, é ZonedDateTime.

E um aviso sobre o Temporal.Now: Temporal.Now.plainDateISO() usa o fuso da máquina. No servidor, isso normalmente é UTC. Se a regra de negócio é "hoje no Brasil", passe o fuso explicitamente:

Temporal.Now.plainDateISO('America/Sao_Paulo')

Serialização e banco​

Os objetos Temporal viram string ISO no JSON.stringify automaticamente:

JSON.stringify({ reuniao })
// '{"reuniao":"2026-10-15T10:00:00-03:00[America/Sao_Paulo]"}'

Mas o caminho de volta não é automático. O JSON.parse devolve string, e você converte com from():

const recebida = Temporal.ZonedDateTime.from(json.reuniao)

Pro banco, a regra que funciona bem:

  • Timestamp de evento: salve como Instant (coluna timestamptz no Postgres ou ISO com Z).
  • Agendamento futuro com fuso: salve o instante e o nome do fuso. Regras de fuso mudam (o Brasil mudou em 2019), e "reunião às 10h em São Paulo" tem que continuar sendo às 10h.
  • Data pura: salve como date, não como timestamp. É só 2026-10-01, sem meia-noite em lugar nenhum.

Convivendo com código legado​

Você não vai reescrever o projeto inteiro, e nem precisa. A conversão é simples nos dois sentidos:

// Date → Temporal
const legado = new Date('2026-10-01T13:00:00Z')
const instante = Temporal.Instant.fromEpochMilliseconds(legado.getTime())

instante.toZonedDateTimeISO('America/Sao_Paulo').toString()
// "2026-10-01T10:00:00-03:00[America/Sao_Paulo]"

// Temporal → Date (pra lib que ainda espera Date)
new Date(instante.epochMilliseconds)

Nos ambientes com suporte nativo, também existe legado.toTemporalInstant().

Minha sugestão de migração: comece pelas bordas. Funções utilitárias de data (aquelas formatDate, addMonths, diffInDays que todo projeto tem) passam a usar Temporal por dentro, e o resto do código vai migrando aos poucos.

Formatação​

O Temporal não inventa um sistema de formatação próprio. Ele usa o Intl, que você já conhece:

reuniao.toLocaleString('pt-BR', { dateStyle: 'full', timeStyle: 'short' })
// "quinta-feira, 15 de outubro de 2026 às 10:00"

Temporal.PlainDate.from('2026-10-01').toLocaleString('pt-BR')
// "01/10/2026"

Se você instalava moment ou dayjs só pra formatar, o Intl já resolvia. Agora o Temporal resolve o resto.

Dá pra usar hoje?​

Depende de onde seu código roda.

AmbienteSuporte
Chrome / Edge✅ 144+ (janeiro de 2026)
Firefox✅ 139+
Node.js✅ 26+ (sem flag)
Safari (macOS/iOS)⏳ só no Technology Preview

No back-end com Node 26+, dá pra usar hoje, nativo.

No front-end, o Safari ainda não tem suporte estável, e isso inclui todo navegador no iOS. Na prática, isso significa polyfill:

npm i @js-temporal/polyfill
import { Temporal } from '@js-temporal/polyfill'

Também existe o temporal-polyfill, mantido pelo pessoal do FullCalendar, que é bem menor e vale considerar se tamanho de bundle importa pra você.

dica

Importe o Temporal de um módulo seu (src/lib/temporal.ts) que reexporta o polyfill. Quando o Safari tiver suporte estável, você troca uma linha e remove a dependência.

E o date-fns, dayjs, luxon? Continuam funcionando, ninguém vai te obrigar a migrar amanhã. Mas pra projeto novo, eu já não instalaria nenhum deles. O motivo de existirem era justamente compensar o Date.

O que eu aprendi testando​

Algumas coisas que me pegaram:

Vários tipos assustam no começo. A primeira reação é "por que não um objeto só?". Mas depois de um tempo, você percebe que o tipo documenta a intenção. Um PlainDate numa assinatura de função diz muito mais que um Date.

< lança erro. Já falei acima, mas vai acontecer com você. Use compare.

PlainDateTime não é um Date melhorado. Ele não tem fuso. Se você converter um PlainDateTime pra instante, vai ter que dizer de qual fuso ele é. Isso é bom, mas é diferente do que a gente está acostumado.

O servidor está em UTC. Temporal.Now sem fuso explícito vai te dar o "hoje" do servidor, não do usuário. Esse bug continua possível; ele só ficou mais visível.

Conclusão​

O Temporal não é só uma API mais bonita. Ele resolve os problemas do Date separando conceitos que nunca deveriam ter estado juntos:

  • Instante ≠ data de calendário: cada um com seu tipo.
  • Imutável: nenhuma data muda por baixo do pano.
  • Fuso horário de verdade: qualquer fuso, não só UTC e o da máquina.
  • Aritmética correta: meses, dias, horário de verão e anos bissextos tratados pela API, não por você.
  • Erros explícitos: < quebra, overflow: 'reject', disambiguation: 'reject'. Melhor erro na hora do que dado errado em produção.

Foram 9 anos de proposta até o Stage 4. Valeu a espera.

No back-end com Node 26, use hoje. No front, use com polyfill e deixe o caminho pronto pra remover quando o Safari chegar.

E se você tem uma função diffInDays com / 86400000 no seu projeto... talvez valha dar uma olhada nela hoje.

Referências​