Adiós, new Date()
Por un dev Angular senior que ya no quiere volver a escribir
date.getMonth() + 1
Si llevas más de dos años trabajando con JavaScript, seguro que tienes alguna fricción con Date.
Mi ultimo conficto llegó estos meses, mientras creaba desde cero y sin dependencias un componente Gantt. El bug: un rango de fechas se movía un día dependiendo de la zona horaria del navegador del usuario. Clásico.
Y lo curioso es que la luz llegó de la forma más inesperada: escuchando una conversación entre dos señoras en el metro sobre el cambio de hora. Ja!
Migrar el manejo de fechas de Date a la nueva API Temporal fue un paso importante pero que solvento tantos dolores de cabeza. Te cuento cómo nos fue y qué código cambió literalmente.
El problema con Date, resumido
No voy a darte la charla completa (para eso ya existe media internet), pero el resumen ejecutivo de por qué nos dolía:
- Es mutable.
date.setMonth(date.getMonth() + 1)muta el objeto original, y si ese objeto está compartido en un signal o en un estado, buena suerte depurando eso. - Los meses van de 0 a 11. Todavía no conozco a nadie que no se haya equivocado con esto al menos una vez.
- La zona horaria es implícita.
new Date('2025-07-15')no se comporta igual en todos los navegadores, y mezclar fechas “solo fecha” con fechas “con hora” es una fuente constante de bugs sutiles. - Validar fechas inválidas es un dolor de cabeza.
new Date(2025, 1, 30)no explota, simplemente te da el 2 de marzo. Silenciosamente.
Este último punto, de hecho, es el que más nos convenció de migrar. Mira este pedazo de código que teníamos literalmente en producción, en el adapter del datepicker, para poder detectar fechas inválidas escritas 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);
// truco para detectar overflow silencioso: comparar los campos
// después de construir el Date contra los que el usuario escribió
return (type === 'time' || (date.getFullYear() === +year && date.getMonth() === +month - 1 && date.getDate() === +day))
&& date.getHours() === hour && date.getMinutes() === minute
? date : null;
};
Fíjate bien en ese comentario: literalmente construíamos el Date y después verificábamos si los campos coincidían con lo que el usuario había escrito, porque era la única forma de detectar que new Date(2025, 1, 30) se había “corregido” solo a marzo. Eso no es una validación, es una reconstrucción forense.
Qué es Temporal, en 60 segundos
Temporal es la propuesta (ya bastante madura, stage avanzado en TC39) que reemplaza a Date con un set de tipos inmutables y explícitos:
Temporal.PlainDate— una fecha sin hora ni zona horaria (2025-07-15).Temporal.PlainDateTime— fecha + hora, sin zona horaria.Temporal.Instant— un punto exacto en el tiempo (equivalente al timestamp deDate).Temporal.ZonedDateTime— fecha + hora + zona horaria, todo explícito.Temporal.Now— el reemplazo denew Date()para “dame la fecha/hora actual” (Temporal.Now.plainDateISO(),Temporal.Now.timeZoneId()).
Todo es inmutable: .add(), .subtract() y .with() devuelven una instancia nueva, nunca mutan la original. Y comparar es explícito: Temporal.PlainDate.compare(a, b) o a.equals(b), nada de restar timestamps a mano.
Por qué ahora
El dato que más nos gustó al hacer este refactor: no tuvimos que instalar ningún polyfill. Todo el cambio de configuración fue una línea en el tsconfig.json:
"lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Temporal"]
Con Angular 22 y TypeScript 6, y corriendo nuestros tests en Vitest con Playwright/Chromium, el motor ya trae soporte nativo. Cero dependencias nuevas en el package.json, solo el tipo ambiental de TypeScript para que el compilador conozca la API global Temporal.
El refactor real: Calendar
En el componente de calendario, el helper de fechas (DateHelper) ya usaba Temporal.PlainDate / Temporal.PlainDateTime para comparar fechas con .equals(). Parte del trabajo de esta migración fue simplemente borrar código muerto que ya no tenía sentido con la nueva API:
// ANTES
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);
}
static toStartOfDay(date: Temporal.PlainDate): Temporal.PlainDate {
return date; // un PlainDate ya "es" el inicio del día, no tiene hora
}
static addMonths(date: Temporal.PlainDate, months: number): Temporal.PlainDate {
return date.add({ months }); // esto ya lo hace Temporal por ti
}
}
// DESPUÉS
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);
}
}
toStartOfDay y addMonths eran wrappers que tenían sentido cuando trabajábamos con Date (donde “inicio del día” significaba poner horas/minutos/segundos en cero a mano). Con Temporal.PlainDate, un objeto no tiene componente de hora por diseño, y .add({ months }) ya es parte de la API. Es un ejemplo pequeño, pero para mí resume bien el espíritu del refactor: no solo cambiamos Date por Temporal, también eliminamos toda la capa de utilidades que existía únicamente para compensar las carencias de Date.
El refactor real: Datepicker
Aquí está el cambio más jugoso. El adapter del datepicker (DatePickerAdapter) es la “frontera” entre lo que ve el consumidor del componente (un string en formato dd/MM/yyyy, por ejemplo) y la lógica interna. Antes, parsear un string escrito por el usuario significaba construir un Date y cruzar los dedos:
// ANTES — parseInputString (fragmento)
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);
return (type === 'time' || (date.getFullYear() === +year && date.getMonth() === +month - 1 && date.getDate() === +day))
&& date.getHours() === hour && date.getMinutes() === minute
? date : null;
};
Y así quedó después, delegando la validación directamente en Temporal:
// DESPUÉS — parseInputString (fragmento)
const parseISO: (dateStr: string) => string | null = (dateStr: string): string | null => {
const match = dateStr.match(regex);
if (!match) return null;
if (type === 'date') {
const [, dd, MM, yyyy] = match;
return Temporal.PlainDate.from({ year: +yyyy, month: +MM, day: +dd }).toString();
} else if (type === 'datetime') {
const [, dd, MM, yyyy, HH, mm] = match;
return Temporal.PlainDateTime
.from({ year: +yyyy, month: +MM, day: +dd, hour: +HH, minute: +mm, second: 0 })
.toZonedDateTime(Temporal.Now.timeZoneId())
.toInstant()
.toString();
} else { // 'time'
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 })
.toZonedDateTime(Temporal.Now.timeZoneId())
.toInstant()
.toString();
}
};
Nota algo importante: el adapter dejó de devolver Date y ahora devuelve strings ISO. Ese es un cambio de diseño deliberado, no solo un cambio de tipo. La API pública del componente (lo que ve quien consume el datepicker) sigue siendo un string serializable, fácil de guardar en un formulario o mandar a un backend. Pero toda la lógica de parseo, comparación y formateo por dentro usa Temporal. La conversión entre “string ISO” y “objeto Temporal” quedó centralizada en un único método privado:
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);
}
Ese método es la “frontera” de la que hablaba antes: todo lo que entra desde afuera (un ISO string) se convierte una sola vez, y todo lo que sale se serializa una sola vez. Nada de Temporal se filtra hacia afuera del componente, y nada de ambigüedad de Date se filtra hacia adentro.
El formateo también se simplificó, porque ya no hay que llamar a media docena de getters distintos:
// DESPUÉS
private getformatDate(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 ya es el mes real (1-12), no hay que sumarle uno. Pequeño detalle, pero cada vez que no tienes que escribir + 1 en un mes, el mundo es un poco mejor.
Bonus: los tests quedaron más legibles
Uno de los efectos secundarios que no esperábamos: los specs se volvieron mucho más fáciles de leer. Compara mentalmente new Date(2025, 6, 15) (¿por qué 6 si es julio?) contra:
const startDate = Temporal.PlainDate.from('2025-07-15');
const endDate = Temporal.PlainDate.from('2025-07-20');
expect(Temporal.PlainDate.compare(startDate, endDate)).toBeLessThan(0);
No hay ambigüedad de mes, no hay que memorizar índices, y el string ISO en el test es literalmente el mismo formato que verías en la base de datos o en el payload de una API.
Notas importantes
overflow: 'constrain'es el comportamiento por defecto.Temporal.PlainDate.from({ year: 2025, month: 1, day: 32 })no explota, se ajusta silenciosamente al 31 de enero. Si quieres que falle explícitamente, hay que pasar{ overflow: 'reject' }. La ventaja sobreDatees que ahora es una decisión explícita, no un accidente.dayOfWeekva de 1 a 7 (lunes = 1), no de 0 a 6 comoDate.getDay(). Si vienes de años deDate, este es el típico off-by-one que te va a agarrar en el primer PR.- No hay zona horaria implícita en ningún lado. Cada vez que necesitas “ahora, en la zona horaria del usuario”, tienes que pedirlo explícitamente con
Temporal.Now.timeZoneId(). Es más verboso, pero elimina de raíz los bugs de “funciona en mi máquina, en otro huso horario no”. - Si trabajas con Angular y estas usando SSR recuerda que typescript 7 aun no esta asi que ten cuidado con declarar temporal antes del inicio del ciclo del componente por que te dará error en el build en producción.
Checklist si quieres migrar tu propia librería
- Agrega
"ESNext.Temporal"allibde tutsconfig.jsony confirma que tu runtime de tests (Chromium/Node reciente) ya soporta Temporal nativo antes de instalar un polyfill. - Define la frontera: ¿tu API pública sigue exponiendo strings ISO o vas a exponer objetos
Temporaldirectamente? Nosotros elegimos strings ISO por compatibilidad con formularios y APIs existentes. - Centraliza la conversión ISO ↔ Temporal en un único método (como nuestro
isoToTemporal), no la repitas por todo el código. - Reemplaza cualquier “truco de validación” basado en reconstruir un
Datey comparar campos —Temporal.PlainDate.from()conoverflow: 'reject'ya lo hace por ti. - Revisa tus specs: si tienes
new Date(2025, 6, 15)en tests, es buen momento para pasarlos aTemporal.PlainDate.from('2025-07-15')y ganar legibilidad gratis. - Borra los helpers que existían solo para compensar las carencias de
Date(inicio de día, sumar meses a mano, etc.) — probablementeTemporalya los cubre de forma nativa.
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 para esto. Si todavía tienes new Date() regado por tu codebase, probablemente sea buen momento para empezar a mirar Temporal de cerca — spoiler: probablemente no necesites ni un polyfill.
¿Ya migraste algo a Temporal o todavía te da miedo? Cuéntamelo en los comentarios.
Written by: Dayerlin Bustamante