Como capturar dados independentes de local e datas parciais com o Temporal

Recursos da Web: Temporal

O registro de dados cronológicos que precisam permanecer idênticos, independentemente da localização do espectador, como datas de nascimento, alarmes diários, vencimentos de cartões de crédito ou ciclos de faturamento mensal, sempre foi propenso a erros com o objeto Date legado. Como um Date sempre representa um instante específico no tempo e é exibido usando o fuso horário local do espectador, salvar uma data como "1990-01-01" pode fazer com que os usuários a oeste do UTC vejam "1989-12-31", enquanto a modelagem de datas parciais (como uma validade de cartão apenas com o mês) força dias de marcador de posição arbitrários que causam bugs de estouro.

A API Temporal fornece uma família de tipos "simples" que não dependem de local, não têm diferença de fuso horário e representam datas de calendário e horários de relógio exatamente como lidos em um calendário ou relógio:

Tipo O que ele modela Casos de uso típicos
Temporal.PlainDate Data completa da agenda (year, month, day) Datas de nascimento, datas de eventos, datas de faturamento de assinaturas
Temporal.PlainTime Tempo de relógio (hour, minute, second, ...) Alarmes diários (9h, onde quer que o usuário esteja), horário de funcionamento da loja
Temporal.PlainDateTime Data do calendário + hora do relógio sem fuso horário Programações locais flutuantes, combinando um PlainDate e um PlainTime antes de anexar uma zona
Temporal.PlainMonthDay Mês e dia sem ano Aniversários, datas comemorativas e feriados fixos recorrentes
Temporal.PlainYearMonth Ano e mês sem dia Vencimento do cartão de crédito (2027-12), períodos de faturamento mensal

Como implementar

  1. Escolha o tipo de Temporal.Plain* mais específico que corresponda aos seus dados:
    • Use Temporal.PlainDate.from() para datas completas do calendário e Temporal.PlainTime.from() para horários do relógio.
    • Use Temporal.PlainDateTime.from() (ou plainDate.toPlainDateTime(plainTime)) ao combinar uma data do calendário e um horário do relógio sem vincular a um fuso horário.
    • Use Temporal.PlainMonthDay.from() para datas anuais sem um ano e converta em um PlainDate com .toPlainDate({ year }) antes de realizar operações aritméticas de data.
    • Use Temporal.PlainYearMonth.from() para valores de ano e mês para que números de dias arbitrários nunca vazem para os cálculos.
  2. Observe o parâmetro calendar ao criar tipos simples: ISO 8601 ('iso8601') é o calendário padrão ao analisar strings ISO ou criar com PlainDate.from({ year, month, day }). Ao criar PlainMonthDay ou PlainYearMonth de conjuntos de propriedades ou ao trabalhar com calendários não ISO, transmita calendar explicitamente (por exemplo, { month: 10, day: 31, calendar: 'iso8601' }).
  3. Use .add() com as opções overflow para intervalos de calendário recorrentes: chamar plainDate.add({ months: 1 }) usa como padrão { overflow: 'constrain' }, fixando datas de fim de mês, como 31 de janeiro a 28/29 de fevereiro. Transmita { overflow: 'reject' } quando datas de destino inválidas precisarem gerar um RangeError em vez de fixar.

Exemplo: datas completas, horários de relógio e PlainDateTime

// 1. Parse a calendar date and a wall-clock time from input strings
const birthdate = Temporal.PlainDate.from('1990-01-01');
const reminderTime = Temporal.PlainTime.from('09:00');

// Combine into a location-agnostic PlainDateTime without string interpolation
const localAppointment = birthdate.toPlainDateTime(reminderTime);

// 2. Display without time zone conversion; outputs the same date in any time zone
console.log(birthdate.toLocaleString('en-GB'));
console.log(localAppointment.toString()); // "1990-01-01T09:00:00"

// 3. Read calendar fields directly from the Plain type. A PlainDate already *is*
// a calendar date, so there is no need for the formatToParts() workaround that
// legacy Date code uses to recover year/month/day from an instant.
const { year, month, day } = birthdate;

Exemplo: datas parciais (PlainMonthDay e PlainYearMonth)

// Annual recurring event (no year component)
// Include explicit calendar when constructing from an object
const annualHoliday = Temporal.PlainMonthDay.from({
  month: 10,
  day: 31,
  calendar: 'iso8601',
});

const today = Temporal.Now.plainDateISO();
const isToday = annualHoliday.equals(today.toPlainMonthDay());

// Convert PlainMonthDay to a PlainDate with a specific year before doing arithmetic,
// because month lengths (such as February) depend on whether the year is a leap year.
const occurrenceThisYear = annualHoliday.toPlainDate({ year: today.year });

// Monthly expiration (no day component, preventing day-of-month overflow bugs)
const cardExpiry = Temporal.PlainYearMonth.from({
  year: 2027,
  month: 12,
  calendar: 'iso8601',
});
const currentMonth = today.toPlainYearMonth();
const isExpired = Temporal.PlainYearMonth.compare(cardExpiry, currentMonth) < 0;

Exemplo: intervalos mensais recorrentes (overflow: 'constrain' x 'reject')

const billingStart = Temporal.PlainDate.from('2024-01-31');

// Default overflow: 'constrain' clamps Jan 31 + 1 month to the last valid day of February
const nextBilling = billingStart.add({ months: 1 }, { overflow: 'constrain' });
console.log(nextBilling.toString()); // "2024-02-29" (2024 is a leap year)

// Use overflow: 'reject' to throw a RangeError when the target day does not exist
try {
  billingStart.add({ months: 1 }, { overflow: 'reject' });
} catch (err) {
  // RangeError: day 31 does not exist in February
}

Exemplo: interoperabilidade com o Date legado

Um Date legado é um carimbo de data/hora (um instante), não uma data do calendário. Armazenar "1990-01-01" como Date força você a escolher algum instante (normalmente meia-noite UTC), e ler isso em um fuso horário a oeste do UTC gera "31/12/1989". Se uma biblioteca ou API ainda exigir um Date, faça a conversão explicitamente usando Temporal.Instant em vez de criar strings ISO manualmente:

// PlainDate -> Date: attach a time zone (time of day defaults to the start of
// that day) to get an Instant, then hand the epoch milliseconds to Date.
// 'UTC' is a deliberate choice and must be documented; any zone would do,
// but the stored instant depends on it.
const instant = birthdate.toZonedDateTime('UTC').toInstant();
const legacyDate = new Date(instant.epochMilliseconds);

// Date -> PlainDate: go back through Instant (legacyDate.toTemporalInstant()
// is equivalent), then project into an explicit time zone. The result depends
// on the zone, which is exactly the drift risk.
const viewedDate = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime())
  .toZonedDateTimeISO('America/New_York')
  .toPlainDate();

// Compare Plain types with .equals() (or Temporal.PlainDate.compare), not strings.
console.log(viewedDate.equals(birthdate)); // false: the calendar date drifted to 1989-12-31

Implementação estratégica e práticas recomendadas

  • USE Temporal.PlainDate para datas completas do calendário, Temporal.PlainTime para horários de relógio, Temporal.PlainDateTime para pares de data e hora locais sem fuso, Temporal.PlainMonthDay para datas anuais recorrentes e Temporal.PlainYearMonth para vencimentos ou períodos de faturamento de ano-mês.
  • NÃO faça operações aritméticas diretamente em Temporal.PlainMonthDay. Primeiro, converta-o em um Temporal.PlainDate usando .toPlainDate({ year }), porque a duração dos meses varia de acordo com o ano.
  • USE .add() em Temporal.PlainDate para intervalos de calendário recorrentes, usando overflow: 'constrain' (o padrão) para fixar datas de fim de mês ou overflow: 'reject' para gerar uma RangeError em datas inexistentes.
  • É RECOMENDÁVEL especificar calendar (por exemplo, calendar: 'iso8601') ao criar tipos PlainMonthDay ou Plain não ISO com base em objetos.
  • NÃO use tipos simples se precisar representar um momento específico no tempo físico (um instante). Use Temporal.Instant ou Temporal.ZonedDateTime para registros, carimbos de data/hora de eventos ou programação em vários fusos horários.
  • NÃO construa objetos Date de strings ISO criadas manualmente (como `${date}T00:00:00Z`). Produza um Date do epochMilliseconds de um Temporal.Instant ou Temporal.ZonedDateTime e leia um de volta com Temporal.Instant.fromEpochMilliseconds(date.getTime()) ou date.toTemporalInstant() para que a suposição de fuso horário seja explícita.
  • NÃO use Intl.DateTimeFormat.prototype.formatToParts() para extrair ou comparar valores de ano, mês ou dia. Leia .year, .month e .day diretamente de um tipo simples e compare com .equals().
  • IDENTIFIQUE os fusos horários pelo nome da IANA (por exemplo, America/New_York) e nunca por um ajuste de tempo fixo do UTC. As compensações mudam com o horário de verão e a legislação, então rótulos como "UTC-5" ficam errados em parte do ano.
  • NÃO se esqueça de que os objetos Temporal são imutáveis. Métodos como .add(), .subtract() ou .with() sempre retornam uma nova instância com uma nova referência, em vez de modificar o objeto original no local, o que os torna seguros para armazenamento direto no estado de IU reativo.

Estratégia de fallback

Substitutos e suporte do navegador para Temporal

Para navegadores que ainda não são compatíveis com a API Temporal nativa, use a detecção de recursos e carregue dinamicamente temporal-polyfill (~15 KB min+gz).

A importação de temporal-polyfill não atribui automaticamente o objeto Temporal ao escopo global. Atribua-o explicitamente se o código depender de globalThis.Temporal.

// Check if Temporal is supported natively
(async () => {
  if (typeof Temporal === 'undefined') {
    // Load the polyfill conditionally
    const module = await import('https://esm.sh/temporal-polyfill');
    globalThis.Temporal = module.Temporal;
    // Extend Date.prototype if needed
    Date.prototype.toTemporalInstant = module.toTemporalInstant;
  }
  initializeApp();
})();