Adiós, new Date()
Por un dev frontend senior que no quiere volver a escribir
date.getMonth() + 1jamás.
Si llevas más de dos años trabajando con JavaScript o TypeScript, seguro arrastras alguna cicatriz provocada por Date.
El último trauma me lo dio un componente Gantt casero: las tareas se movían un día entero según la zona horaria ¡Clasico!
¿La solución? Me iluminaron dos señoras en el metro debatiendo el cambio de hora. Me bastaron dos paradas para entender lo ridículo del asunto: una fecha y la rotación de la Tierra. Ja!
Migrar nuestra librería de componentes de Date a la API Temporal supuso un antes y un después en mantenibilidad y robustez. Te cuento cómo abordamos el refactor paso a paso y qué código eliminamos de raíz.
La deuda técnica de Date, resumida
No hace falta recitar toda la especificación histórica para justificar el cambio. Los problemas que impactaban directamente en nuestro día a día eran cuatro:
- Mutabilidad descontrolada: Un simple
date.setMonth(date.getMonth() + 1)altera la instancia original. Si esa referencia viaja por Signals, RxJS o cualquier store reactivo, rastrear el origen de una mutación es una pesadilla de depuración. - Meses indexados en cero (0–11): Una convención heredada de Java en 1995 que sigue cobrándose bugs de off-by-one en producción.
- Zona horaria implícita:
new Date('2025-07-15')no parsea de forma idéntica en todos los entornos. Mezclar conceptos de “solo fecha” con “instantes de tiempo” produce discrepancias sutiles al cruzar fronteras geográficas. - Validación laxa y silenciosa:
new Date(2025, 1, 30)no arroja error: avanza silenciosamente hasta el 2 de marzo.
Este último punto nos obligaba a mantener código defensivo poco elegante. Teníamos literalmente este fragmento en producción, dentro del adaptador del datepicker, únicamente para cazar fechas inexistentes introducidas a mano:
// ANTES — datepicker.adapter.ts
const parseDate: (dateStr: string) => Date | null = (dateStr: string): Date | null => {
const match = dateStr.match(regex);
if (!match) return null;
const now: Date = new Date();
const [, day, month, year, h, m] = match;
const hour: number = (type !== 'date') ? +h || 0 : 0;
const minute: number = (type !== 'date') ? +m || 0 : 0;
const date: Date = (type === 'time')
? new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute)
: new Date(+year, +month - 1, +day, hour, minute);
// Detección de overflow silencioso:
// Comparar los campos generados contra los valores originales parseados
return (type === 'time' || (
date.getFullYear() === +year &&
date.getMonth() === +month - 1 &&
date.getDate() === +day
)) && date.getHours() === hour && date.getMinutes() === minute
? date
: null;
};
Construir un objeto para luego inspeccionar si sus propiedades cambiaron respecto al input no es validación: es autopsia de código.
Temporal en 60 segundos
Temporal reemplaza a Date con un catálogo de tipos inmutables, específicos y semánticamente correctos:
Temporal.PlainDate: representa una fecha de calendario sin hora ni huso horario (2026-08-01).Temporal.PlainDateTime: fecha y hora combinadas, sin zona horaria asociada.Temporal.Instant: un punto exacto en la línea temporal global (timestamp UTC con precisión de nanosegundos).Temporal.ZonedDateTime: fecha, hora y zona horaria explícita (Europe/Madrid).Temporal.Now: métodos directos para obtener el tiempo actual (Temporal.Now.plainDateISO(),Temporal.Now.timeZoneId()). Cada operación (.add(),.subtract(),.with()) retorna una instancia nueva sin alterar la fuente. Las comparaciones son nativas y tipadas mediante.equals()oTemporal.PlainDate.compare(a, b).
Por qué ahora
La gran ventaja de encarar esta migración hoy: no requerimos polyfills en tiempo de ejecución. Con motores modernos de Chromium en nuestros navegadores de destino y tests ejecutándose sobre Vitest/Playwright, el soporte nativo es una realidad. La integración en el proyecto se limitó a una sola línea en tsconfig.json:
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Temporal"]
}
Caso 1: Simplificando el Calendar
En el componente de calendario contábamos con una clase DateHelper lleno de parcher para compensar carencias.
Teníamos un toStartOfDay(date: Date)que clonaba el objeto y ponía a mano horas, minutos, segundos y milisegundos a cero (date.setHours(0, 0, 0, 0)), rezando para que una mutación accidental no rompiera otra vista.Teníamos un addMonths(date: Date, count: number)con lógica extra para evitar que sumar un mes a un 31 de enero saltara silenciosamente a marzo por desbordamiento.- Teníamos helpers para comparar solo año, mes y día, extrayendo los getters uno por uno o comparando timestamps formateados a medianoche.
Temporal.PlainDate representa estrictamente una fecha de calendario: no tiene componente de tiempo que haya que “reiniciar”, .add({ months }) ya resuelve la aritmética de forma nativa e inmutable, y comparar fechas se reduce a .equals().
Al final, nuestro helper quedó reducido prácticamente a una única función de conveniencia para unificar tipos si recibimos un PlainDateTime
export class DateHelper {
static isSameDate(
d1: Temporal.PlainDate | Temporal.PlainDateTime,
d2: Temporal.PlainDate | Temporal.PlainDateTime
): boolean {
const left = d1 instanceof Temporal.PlainDateTime ? d1.toPlainDate() : d1;
const right = d2 instanceof Temporal.PlainDateTime ? d2.toPlainDate() : d2;
return left.equals(right);
}
}
Caso 2: Robusteciendo el Datepicker
El cambio de mayor impacto arquitectónico ocurrió en el DatePickerAdapter. Este adaptador es la frontera del componente, recibe lo que el usuario tipea en el input (un string con formato dd/MM/yyyy, HH:mm, etc.) y lo convierte en el valor canónico que consumirá la aplicación.
Con la API vieja de Date, parsear este input era una ruleta rusa. Si el usuario escribía 31/02/2026, new Date(2026, 1, 31) no fallaba saltaba silenciosamente a marzo, obligándonos a montar chequeos manuales campo por campo para saber si el motor “había inventado” un día. Además, si el modo era time o datetime, mezclar la hora local del navegador provocaba desfases con UTC al serializar.
Con Temporal, delegamos la semántica exacta a cada tipo especializado y activamos { overflow: ‘reject’ }
// DESPUÉS — parseInputString
const parseISO = (dateStr: string): string | null => {
const match = dateStr.match(regex);
if (!match) return null;
try {
if (type === 'date') {
const [, dd, MM, yyyy] = match;
// 1. PlainDate: fecha pura sin hora ni zona horaria
return Temporal.PlainDate.from(
{ year: +yyyy, month: +MM, day: +dd },
{ overflow: 'reject' }
).toString();
}
if (type === 'datetime') {
const [, dd, MM, yyyy, HH, mm] = match;
// 2. PlainDateTime -> ZonedDateTime -> Instant: anclamos al huso del usuario
return Temporal.PlainDateTime
.from(
{ year: +yyyy, month: +MM, day: +dd, hour: +HH, minute: +mm, second: 0 },
{ overflow: 'reject' }
)
.toZonedDateTime(Temporal.Now.timeZoneId())
.toInstant()
.toString();
}
// 3. Caso: 'time' (combinamos con el día actual del usuario sin arrastrar fechas viejas)
const [, HH, mm] = match;
const today = Temporal.Now.plainDateISO();
return Temporal.PlainDateTime
.from(
{ year: today.year, month: today.month, day: today.day, hour: +HH, minute: +mm, second: 0 },
{ overflow: 'reject' }
)
.toZonedDateTime(Temporal.Now.timeZoneId())
.toInstant()
.toString();
} catch {
// Cualquier desborde (ej. mes 13, día 30 de febrero, minuto 61) lanza RangeError
return null;
}
};
Adicional dejo por aqui esto:
| Opción | Comportamiento | Ejemplo con 2026-02-31 |
|---|---|---|
'constrain' (por defecto) | Limita al valor válido más cercano. Si el día excede el fin de mes, lo recorta al último día de ese mes. | 2026-02-28 (se clampa silenciosamente) |
'reject' (estricto) | Rechaza el valor. Lanza un RangeError de inmediato. | Lanza RangeError: Value 31 out of range for month 2 |
Estableciendo una frontera clara
Un aspecto de diseño deliberado: la API pública del componente no expone instancias de Temporal, sino strings ISO.
Esto mantiene la compatibilidad con formularios reactivos (FormControl<string>) y APIs REST. Toda la lógica de transformación vive en un método de conversión centralizado:
private isoToTemporal(iso: string, hasTime: boolean): Temporal.PlainDate | Temporal.PlainDateTime {
if (hasTime) {
if (iso.includes('T')) {
const hasTimezone = iso.endsWith('Z') || iso.includes('[') || /T.*[+\-]/.test(iso);
if (hasTimezone) {
return Temporal.Instant.from(iso)
.toZonedDateTimeISO(Temporal.Now.timeZoneId())
.toPlainDateTime();
}
return Temporal.PlainDateTime.from(iso);
}
return Temporal.PlainDate.from(iso).toPlainDateTime(Temporal.PlainTime.from('00:00'));
}
return Temporal.PlainDate.from(iso);
}
El formateador también ganó en claridad:
private formatDate(date: Temporal.PlainDate | Temporal.PlainDateTime, format: string): string {
const hour = date instanceof Temporal.PlainDateTime ? date.hour : 0;
const minute = date instanceof Temporal.PlainDateTime ? date.minute : 0;
const second = date instanceof Temporal.PlainDateTime ? date.second : 0;
return format
.replace(/yyyy/g, date.year.toString())
.replace(/MM/g, date.month.toString().padStart(2, '0'))
.replace(/dd/g, date.day.toString().padStart(2, '0'))
.replace(/HH/g, hour.toString().padStart(2, '0'))
.replace(/mm/g, minute.toString().padStart(2, '0'))
.replace(/ss/g, second.toString().padStart(2, '0'));
}
date.month entrega el mes real (1–12). No más sumas arbitrarias de + 1 dispersas en el código.
Tests más limpios y declarativos
Las suites de pruebas unitarias ganaron legibilidad inmediata. Compara la ambigüedad de new Date(2026, 6, 15) frente a la precisión de un test con Temporal:
const startDate = Temporal.PlainDate.from('2026-07-15');
const endDate = Temporal.PlainDate.from('2026-07-20');
expect(Temporal.PlainDate.compare(startDate, endDate)).toBeLessThan(0);
Desaparece la confusión mental de los índices numéricos y el valor coincide con los payloads reales de la aplicación.
Tres advertencias técnicas indispensables
1. overflow: 'constrain' es el valor por defecto
Temporal.PlainDate.from({ year: 2026, month: 1, day: 32 }) no lanza un error por omisión: ajusta el valor al 31 de enero. Si necesitas validar inputs estrictos de usuario, pasa { overflow: 'reject' } de forma explícita.
2. dayOfWeek utiliza el estándar ISO (1 = lunes, 7 = domingo)
Difiere de Date.prototype.getDay() (0 = domingo). Atención a este detalle en componentes de calendario con selectores de semana.
3. Cuidado con Server-Side Rendering (SSR)
Si renderizas en el servidor mediante Node.js, verifica la versión del runtime en tus entornos de CI/CD y producción. Si el runtime del servidor no expone Temporal globalmente, invocarlo fuera de los ciclos de vida del cliente (afterNextRender, directivas solo para navegador) provocará un fallo en tiempo de ejecución.
Guía de migración recomendada
- Añade
"ESNext.Temporal"al arreglolibde tutsconfig.json. Confirma la compatibilidad de tu suite de pruebas antes de evaluar polyfills. - Delimita la frontera arquitectónica. Centraliza la conversión entre cadenas ISO y objetos de dominio temporal en un único adapter.
- Sustituye algoritmos de validación manual por llamadas a
Temporal.PlainDate.from(..., { overflow: 'reject' }). - Limpia el código accesorio. Elimina helpers de normalización de horas y cálculos manuales de desfase.
- Actualiza los fixtures y mocks en tus tests para usar cadenas ISO directas.
Listo para el cambio
No fue un refactor glamoroso ni salió en ningún roadmap de marketing, pero es de esos cambios que hacen que el código se sienta más honesto: lo que antes eran trucos y validaciones manuales ahora son llamadas directas a una API diseñada específicamente para esto. Si todavía tienes new Date() regado por tu codebase, es el momento de considerar Temporal.
¿Has integrado ya Temporal en tus proyectos o sigues dependiendo de librerías intermedias? Cuéntamelo en los comentarios.
Written by: Dayerlin Bustamante