Capturer des données indépendantes de l'emplacement et des dates partielles avec Temporal

Fonctionnalités Web : Temporal

L'enregistrement de données chronologiques qui doivent rester identiques, quel que soit l'emplacement de l'utilisateur (par exemple, les dates de naissance, les alarmes quotidiennes, les dates d'expiration des cartes de crédit ou les cycles de facturation mensuels), est historiquement sujet aux erreurs avec l'ancien objet Date. Étant donné qu'un Date représente toujours un instant précis et s'affiche en fonction du fuseau horaire local de l'utilisateur, l'enregistrement d'une date telle que "1990-01-01" peut entraîner l'affichage de "1989-12-31" pour les utilisateurs situés à l'ouest du fuseau horaire UTC. De plus, la modélisation de dates partielles (comme l'expiration d'une carte, qui n'indique que le mois) force l'utilisation de jours fictifs arbitraires qui provoquent des bugs de dépassement.

L'API Temporal fournit une famille de types "simples" indépendants de l'emplacement, qui ne comportent pas de décalage de fuseau horaire et représentent les dates et les heures telles qu'elles sont lues sur un calendrier ou une horloge :

Type Ce qu'il modélise Cas d'utilisation courants
Temporal.PlainDate Date complète (year, month, day) Dates de naissance, dates d'événements, dates de facturation des abonnements
Temporal.PlainTime Temps réel (hour, minute, second, etc.) Alarmes quotidiennes (9h, où que se trouve l'utilisateur), horaires d'ouverture
Temporal.PlainDateTime Date de calendrier + heure de l'horloge sans fuseau horaire Planifications locales flottantes, combinant un PlainDate et un PlainTime avant d'associer une zone
Temporal.PlainMonthDay Mois et jour sans l'année Anniversaires récurrents, fêtes fixes
Temporal.PlainYearMonth Année et mois sans jour Expirations de cartes de crédit (2027-12), périodes de facturation mensuelles

Implémentation

  1. Choisissez le type Temporal.Plain* le plus précis qui correspond à vos données :
    • Utilisez Temporal.PlainDate.from() pour les dates complètes du calendrier et Temporal.PlainTime.from() pour les heures de l'horloge.
    • Utilisez Temporal.PlainDateTime.from() (ou plainDate.toPlainDateTime(plainTime)) lorsque vous combinez une date calendaire et une heure sans les lier à un fuseau horaire.
    • Utilisez Temporal.PlainMonthDay.from() pour les dates annuelles sans année, et convertissez-les en PlainDate avec .toPlainDate({ year }) avant d'effectuer des calculs de date.
    • Utilisez Temporal.PlainYearMonth.from() pour les valeurs d'année et de mois afin que les numéros de jour arbitraires ne soient jamais pris en compte dans les calculs.
  2. Notez le paramètre calendar lors de la construction de types Plain : ISO 8601 ('iso8601') est le calendrier par défaut lors de l'analyse de chaînes ISO ou de la construction avec PlainDate.from({ year, month, day }). Lorsque vous construisez PlainMonthDay ou PlainYearMonth à partir de sacs de propriétés, ou lorsque vous travaillez avec des calendriers non ISO, transmettez explicitement calendar (par exemple, { month: 10, day: 31, calendar: 'iso8601' }).
  3. Utilisez .add() avec les options overflow pour les intervalles d'agenda récurrents : l'appel de plainDate.add({ months: 1 }) utilise par défaut { overflow: 'constrain' }, ce qui limite les dates de fin de mois comme le 31 janvier au 28/29 février. Transmettez { overflow: 'reject' } lorsque les dates cibles non valides doivent générer une RangeError au lieu d'être limitées.

Exemple : dates complètes, heures de l'horloge et 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;

Exemple : Dates partielles (PlainMonthDay et 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;

Exemple : Intervalles mensuels récurrents (overflow: 'constrain' vs '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
}

Exemple : Interopérabilité avec l'ancien Date

Un Date hérité est un code temporel (un instant), et non une date calendaire. Le stockage de "1990-01-01" en tant que Date vous oblige à choisir un instant (généralement minuit UTC), et sa lecture dans un fuseau horaire à l'ouest de l'UTC donne "31/12/1989". Si une bibliothèque ou une API nécessite toujours un Date, effectuez une conversion explicite via Temporal.Instant au lieu de créer des chaînes ISO manuellement :

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

Implémentation stratégique et bonnes pratiques

  • À FAIRE : Utilisez Temporal.PlainDate pour les dates complètes du calendrier, Temporal.PlainTime pour les heures, Temporal.PlainDateTime pour les paires date-heure locales sans fuseau horaire, Temporal.PlainMonthDay pour les dates annuelles récurrentes et Temporal.PlainYearMonth pour les dates d'expiration ou les périodes de facturation exprimées en année-mois.
  • N'EFFECTUEZ PAS d'opérations arithmétiques directement sur Temporal.PlainMonthDay. Convertissez-le d'abord en Temporal.PlainDate via .toPlainDate({ year }), car la durée des mois varie d'une année à l'autre.
  • À FAIRE : Utilisez .add() sur Temporal.PlainDate pour les intervalles de calendrier récurrents, en vous appuyant sur overflow: 'constrain' (par défaut) pour limiter les dates de fin de mois ou sur overflow: 'reject' pour générer une RangeError sur les dates inexistantes.
  • À FAIRE : Spécifiez calendar (par exemple, calendar: 'iso8601') lorsque vous construisez des types PlainMonthDay ou Plain non ISO à partir d'objets.
  • N'UTILISEZ PAS de types simples si vous devez représenter un moment spécifique dans le temps physique (un instant). Utilisez Temporal.Instant ou Temporal.ZonedDateTime pour les journaux, les codes temporels d'événements ou la planification sur plusieurs fuseaux horaires.
  • NE construisez PAS d'objets Date à partir de chaînes ISO créées manuellement (comme `${date}T00:00:00Z`). Produisez un Date à partir de la epochMilliseconds d'un Temporal.Instant ou d'un Temporal.ZonedDateTime, et lisez-en un avec Temporal.Instant.fromEpochMilliseconds(date.getTime()) ou date.toTemporalInstant(), afin que l'hypothèse de fuseau horaire soit explicite.
  • NE PAS utiliser Intl.DateTimeFormat.prototype.formatToParts() pour extraire ou comparer des valeurs d'année, de mois ou de jour. Lisez .year, .month et .day directement à partir d'un type Plain, puis comparez-les avec .equals().
  • À FAIRE : Identifiez les fuseaux horaires par leur nom IANA (par exemple, America/New_York) et jamais par un décalage UTC fixe. Les décalages changent avec l'heure d'été et la législation. Par conséquent, les libellés tels que "UTC-5" deviennent incorrects pendant une partie de l'année.
  • À FAIRE : N'oubliez pas que les objets Temporal sont immuables. Les méthodes telles que .add(), .subtract() ou .with() renvoient toujours une nouvelle instance avec une nouvelle référence au lieu de modifier l'objet d'origine sur place. Elles peuvent donc être stockées directement dans l'état réactif de l'UI.

Stratégie de remplacement

Solutions de remplacement et compatibilité des navigateurs avec Temporal

Pour les navigateurs qui ne sont pas encore compatibles avec l'API Temporal native, utilisez la détection de fonctionnalités et chargez dynamiquement temporal-polyfill (~15 Ko min+gz).

Notez que l'importation de temporal-polyfill n'attribue pas automatiquement l'objet Temporal au champ d'application global. Attribuez-la explicitement si votre code repose sur 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();
})();