Dayerlin Dev

Dayerlin Bustamante

Adiós, new Date()

Por un dev frontend senior que no quiere volver a escribir date.getMonth() + 1 jamá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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

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.

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ónComportamientoEjemplo 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

  1. Añade "ESNext.Temporal" al arreglo lib de tu tsconfig.json. Confirma la compatibilidad de tu suite de pruebas antes de evaluar polyfills.
  2. Delimita la frontera arquitectónica. Centraliza la conversión entre cadenas ISO y objetos de dominio temporal en un único adapter.
  3. Sustituye algoritmos de validación manual por llamadas a Temporal.PlainDate.from(..., { overflow: 'reject' }).
  4. Limpia el código accesorio. Elimina helpers de normalización de horas y cálculos manuales de desfase.
  5. 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