Captura datos independientes de la ubicación y fechas parciales con Temporal

Funciones web: Temporal

Históricamente, el registro de datos cronológicos que deben permanecer idénticos independientemente de la ubicación del usuario, como fechas de nacimiento, alarmas diarias, vencimientos de tarjetas de crédito o ciclos de facturación mensuales, ha sido propenso a errores con el objeto Date heredado. Debido a que un Date siempre representa un instante específico en el tiempo y se muestra con la zona horaria local del usuario, guardar una fecha como "1990-01-01" puede hacer que los usuarios al oeste de UTC vean "1989-12-31", mientras que modelar fechas parciales (como un vencimiento de tarjeta solo de mes) fuerza días de marcador de posición arbitrarios que causan errores de desbordamiento.

La API de Temporal proporciona una familia de tipos "simples" independientes de la ubicación que no tienen un desplazamiento de zona horaria y representan las fechas del calendario y las horas del reloj de pared exactamente como se leen en un calendario o reloj:

Tipo Qué modela Casos de uso típicos
Temporal.PlainDate Fecha completa del calendario (year, month, day) Fechas de nacimiento, fechas de eventos y fechas de facturación de suscripciones
Temporal.PlainTime Tiempo de reloj (hour, minute, second, etcétera) Alarmas diarias (9:00 a.m. dondequiera que esté el usuario), horario de atención
Temporal.PlainDateTime Fecha del calendario + hora del reloj sin zona horaria Programaciones locales flotantes, que combinan un PlainDate y un PlainTime antes de adjuntar una zona
Temporal.PlainMonthDay Mes y día sin año Cumpleaños, aniversarios y días festivos fijos que se repiten anualmente
Temporal.PlainYearMonth Año y mes sin día Vencimientos de tarjetas de crédito (2027-12), períodos de facturación mensuales

Cómo implementarla

  1. Elige el tipo de Temporal.Plain* más específico que coincida con tus datos:
    • Usa Temporal.PlainDate.from() para fechas de calendario completas y Temporal.PlainTime.from() para horas de reloj.
    • Usa Temporal.PlainDateTime.from() (o plainDate.toPlainDateTime(plainTime)) cuando combines una fecha de calendario y una hora de reloj sin vincularlas a una zona horaria.
    • Usa Temporal.PlainMonthDay.from() para las fechas anuales sin año y conviértelas en PlainDate con .toPlainDate({ year }) antes de realizar operaciones aritméticas de fechas.
    • Usa Temporal.PlainYearMonth.from() para los valores de año y mes, de modo que los números de día arbitrarios nunca se filtren en los cálculos.
  2. Ten en cuenta el parámetro calendar cuando construyas tipos simples: ISO 8601 ('iso8601') es el calendario predeterminado cuando se analizan cadenas ISO o se construye con PlainDate.from({ year, month, day }). Cuando construyas PlainMonthDay o PlainYearMonth a partir de bolsas de propiedades, o cuando trabajes con calendarios que no sean ISO, pasa calendar de forma explícita (p.ej., { month: 10, day: 31, calendar: 'iso8601' }).
  3. Usa .add() con las opciones de overflow para intervalos de calendario recurrentes: Cuando se llama a plainDate.add({ months: 1 }), se usa de forma predeterminada { overflow: 'constrain' }, lo que limita las fechas de fin de mes, como el 31 de enero, al 28 o 29 de febrero. Pasa { overflow: 'reject' } cuando las fechas de destino no válidas deban arrojar un RangeError en lugar de limitarse.

Ejemplo: Fechas completas, horas de reloj y 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;

Ejemplo: Fechas parciales (PlainMonthDay y 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;

Ejemplo: Intervalos mensuales recurrentes (overflow: 'constrain' frente a '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
}

Ejemplo: Interoperabilidad con Date heredado

Un Date heredado es una marca de tiempo (un instante), no una fecha de calendario. Almacenar "1990-01-01" como Date te obliga a elegir un instante (por lo general, la medianoche UTC), y leerlo en una zona horaria al oeste de UTC produce "31/12/1989". Si una biblioteca o API aún requiere un Date, conviértelo de forma explícita a través de Temporal.Instant en lugar de compilar cadenas ISO de forma manual:

// 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

Implementación estratégica y prácticas recomendadas

  • SÍ usa Temporal.PlainDate para fechas de calendario completas, Temporal.PlainTime para horas de reloj, Temporal.PlainDateTime para pares de fecha y hora locales sin zona, Temporal.PlainMonthDay para fechas anuales recurrentes y Temporal.PlainYearMonth para vencimientos de año y mes o períodos de facturación.
  • NO realices operaciones aritméticas directamente en Temporal.PlainMonthDay; primero, conviértelo en un Temporal.PlainDate a través de .toPlainDate({ year }) porque la duración de los meses varía según el año.
  • SÍ usa .add() en Temporal.PlainDate para intervalos de calendario recurrentes, confiando en overflow: 'constrain' (el valor predeterminado) para fijar las fechas de fin de mes o overflow: 'reject' para arrojar un RangeError en fechas inexistentes.
  • SÍ especifica calendar (p.ej., calendar: 'iso8601') cuando construyas tipos PlainMonthDay o Plain que no sean ISO a partir de objetos.
  • NO uses tipos simples si necesitas representar un momento específico en el tiempo físico (un instante). Usa Temporal.Instant o Temporal.ZonedDateTime para los registros, las marcas de tiempo de los eventos o la programación en diferentes zonas horarias.
  • NO construyas objetos Date a partir de cadenas ISO creadas manualmente (como `${date}T00:00:00Z`). Produce un Date a partir de epochMilliseconds de un Temporal.Instant o Temporal.ZonedDateTime, y lee uno con Temporal.Instant.fromEpochMilliseconds(date.getTime()) o date.toTemporalInstant(), de modo que la suposición de la zona horaria sea explícita.
  • NO uses Intl.DateTimeFormat.prototype.formatToParts() para extraer o comparar valores de año, mes o día. Lee .year, .month y .day directamente desde un tipo Plain y compáralos con .equals().
  • SÍ identifica las zonas horarias por el nombre de la IANA (p.ej., America/New_York) y nunca por una diferencia horaria fija con respecto al UTC. Los desplazamientos cambian con el horario de verano y la legislación, por lo que las etiquetas como "UTC-5" se vuelven incorrectas durante parte del año.
  • SÍ recuerda que los objetos Temporal son inmutables. Los métodos como .add(), .subtract() o .with() siempre devuelven una instancia nueva con una referencia nueva en lugar de mutar el objeto original en su lugar, lo que hace que sea seguro almacenarlos directamente en el estado de la IU reactiva.

Estrategia de resguardo

Resguardos y compatibilidad de navegadores para Temporal

Para los navegadores que aún no admiten la API de Temporal nativa, usa la detección de funciones y carga temporal-polyfill de forma dinámica (~15 KB min+gz).

Ten en cuenta que importar temporal-polyfill no asigna automáticamente el objeto Temporal al alcance global. Asígnalo de forma explícita si tu código depende 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();
})();