Dayerlin Dev

Dayerlin Bustamante

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:

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:

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

Checklist si quieres migrar tu propia librería

  1. Agrega "ESNext.Temporal" al lib de tu tsconfig.json y confirma que tu runtime de tests (Chromium/Node reciente) ya soporta Temporal nativo antes de instalar un polyfill.
  2. Define la frontera: ¿tu API pública sigue exponiendo strings ISO o vas a exponer objetos Temporal directamente? Nosotros elegimos strings ISO por compatibilidad con formularios y APIs existentes.
  3. Centraliza la conversión ISO ↔ Temporal en un único método (como nuestro isoToTemporal), no la repitas por todo el código.
  4. Reemplaza cualquier “truco de validación” basado en reconstruir un Date y comparar campos — Temporal.PlainDate.from() con overflow: 'reject' ya lo hace por ti.
  5. Revisa tus specs: si tienes new Date(2025, 6, 15) en tests, es buen momento para pasarlos a Temporal.PlainDate.from('2025-07-15') y ganar legibilidad gratis.
  6. Borra los helpers que existían solo para compensar las carencias de Date (inicio de día, sumar meses a mano, etc.) — probablemente Temporal ya 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