7 de agosto de 2026

La API Temporal ya viene activada en Node 26: deja de pelearte con Date

Foto de Marco Orta Marco Orta | 15 min de lectura
Compartir
Un reloj de bolsillo de latón agrietado perdiendo engranajes a la izquierda, con un flujo de partículas que va hacia unos anillos concéntricos de luz azul a la derecha
Tabla de Contenidos

    Nueve años después de que se propusiera, la API Temporal ya está aquí y no hace falta ningún flag. Instalé Node 26 solo para comprobarlo antes de escribir esto:

    $ node -v
    v26.7.0
    
    $ node -e "console.log(typeof Temporal)"
    object
    

    Las nueve clases, disponibles de entrada:

    Now, PlainDate, PlainTime, PlainDateTime, ZonedDateTime,
    Duration, Instant, PlainYearMonth, PlainMonthDay
    

    Todos los ejemplos de este artículo están ejecutados en esa versión. Cuando veas una salida, es la salida real, no lo que dice la documentación que debería pasar.

    Dónde está Temporal ahora mismo

    Antes de nada, conviene ser exacto con el estado, porque hay artículos que exageran en las dos direcciones:

    Estado en agosto de 2026
    EstándarStage 4 desde marzo de 2026. Forma parte de ES2026
    Node26: activada por defecto. Entra en LTS en octubre
    Chrome / EdgeSoportada desde la 144 (enero de 2026)
    FirefoxSoportada desde la 139
    SafariSolo en Technology Preview, tras un flag
    BaselineNo, y el bloqueo es Safari

    Dos matices que se cuentan mal por ahí:

    • Date no está deprecado. MDN describe Temporal como «un reemplazo completo» de Date, que no es lo mismo. Date sigue siendo parte del estándar y va a seguir funcionando. No hay ninguna fecha de retirada.
    • En el navegador todavía necesitas polyfill, porque Safari no lo lleva en estable. En el servidor, con Node 26, no.

    Dicho de otro modo: en backend ya puedes usarlo tal cual; en frontend, con polyfill.


    Los cuatro fallos de Date que Temporal arregla

    Estos no son detalles estéticos. Son los que producen bugs que llegan a producción.

    1. Los meses empiezan en cero

    El clásico. Sigue ahí desde 1995:

    new Date(2026, 8, 7)                              // → 2026-09-07  😐
    Temporal.PlainDate.from({year:2026, month:8, day:7}) // → 2026-08-07  ✅
    

    Salida verificada. El 8 significa septiembre en Date y agosto en Temporal, que es lo que cualquiera esperaría.

    2. El formato de la cadena cambia la zona horaria

    Este es el que más daño hace en LatAm y España, y casi nadie lo sabe. Ejecutado en una máquina en America/Mexico_City (UTC−6):

    new Date("2026-08-07")   // → Thu Aug 06 2026 18:00:00   ⚠️ ¡el día anterior!
    new Date("2026/08/07")   // → Fri Aug 07 2026 00:00:00
    

    Misma fecha, dos separadores distintos, dos días distintos. El motivo es que Date interpreta el formato ISO con guiones como UTC, y cualquier otro formato como hora local. Al estar seis horas por detrás de UTC, la medianoche UTC cae en las 18:00 del día anterior.

    Si tienes un formulario que envía 2026-08-07 y guardas el resultado, acabas de perder un día. Es el origen de la mitad de los bugs de «se muestra un día antes» que aparecen en cualquier proyecto.

    Temporal.PlainDate.from("2026-08-07")   // → 2026-08-07 y punto
    

    Un PlainDate no tiene zona horaria, así que no hay nada que interpretar. Ese es el arreglo de fondo: separar «una fecha del calendario» de «un instante en el tiempo», dos cosas que Date mezcla.

    3. Date es mutable

    const d = new Date(2026, 0, 15);
    d.setMonth(5);
    // d cambió: → 2026-06-15    el objeto original está modificado
    

    Frente a:

    const d = Temporal.PlainDate.from('2026-01-15');
    d.add({months: 5});
    // d sigue siendo → 2026-01-15    add() devuelve uno nuevo
    

    Todo en Temporal es inmutable. Si pasas una fecha a una función, nadie te la va a cambiar por debajo.

    4. Sumar un mes al 31 de enero

    Aquí Date hace algo genuinamente indefendible:

    const d = new Date(2026, 0, 31);
    d.setMonth(d.getMonth() + 1);
    // → 2026-03-03   😱  ¿el 3 de marzo?
    

    Como el 31 de febrero no existe, Date desborda: cuenta 31 días desde el 1 de febrero y aterriza en marzo. Silenciosamente.

    Temporal te obliga a decidir qué quieres:

    Temporal.PlainDate.from('2026-01-31').add({months: 1})
    // → 2026-02-28   por defecto acota al último día válido
    
    Temporal.PlainDate.from('2026-01-31').add({months: 1}, {overflow: 'reject'})
    // → lanza RangeError
    

    Ese overflow es la diferencia entre una librería de fechas seria y una que adivina. En una app de facturación con vencimientos mensuales, el comportamiento por defecto de Date te genera facturas con fecha equivocada y nadie se entera hasta que un cliente reclama.


    El caso que de verdad importa: zonas horarias

    Todo lo anterior está bien, pero lo que justifica migrar es esto.

    Chile, y las dos de la tarde que no llegan

    Escenario real: una cita el sábado 5 de septiembre a las 12:00 en Santiago, que se reprograma «dos días después». Chile entra en horario de verano el día 6.

    Verifiqué el cambio de offset día a día:

    2026-09-04   offset -04:00
    2026-09-05   offset -04:00
    2026-09-06   offset -03:00   ← entra el horario de verano
    2026-09-07   offset -03:00
    

    Y el mismo cálculo con las dos APIs, partiendo del mismo instante:

    Inicio (ambos):           05-09-26, 12:00   |  UTC 2026-09-05T16:00:00.000Z
    
    Temporal .add({days: 2}):  07-09-26, 12:00   ← 12:00, como espera el usuario
    Date +48h en milisegundos: 07-09-26, 13:00   ← una hora tarde
    

    Temporal entiende que «dos días después» significa la misma hora de reloj dos días después, y que para lograrlo solo han de pasar 47 horas reales, no 48:

    zdt.until(conTemporal, {largestUnit: 'hour'}).hours
    // → 47
    

    Date no puede hacer esto porque no sabe nada de zonas horarias: solo sabe sumar milisegundos. Y 48 × 3600 × 1000 milisegundos son 48 horas, siempre, aunque el calendario diga otra cosa.

    Cada vez que alguien escribe fecha.getTime() + dias * 86400000, está cometiendo este error. Funciona once meses al año.

    México, que dejó de cambiar la hora

    El reverso del problema. México abolió el horario de verano en 2022, y eso se refleja en los datos:

    Temporal.ZonedDateTime.from('2026-01-15T12:00:00[America/Mexico_City]').offset  // → -06:00
    Temporal.ZonedDateTime.from('2026-07-15T12:00:00[America/Mexico_City]').offset  // → -06:00
    

    El mismo offset en enero y en julio. Si arrastras código con la suposición de que México cambia la hora —o peor, con un -05:00 escrito a mano para el verano—, llevas cuatro años calculando mal medio año.

    Temporal lee la base de datos de zonas horarias del sistema, así que estos cambios políticos le llegan con las actualizaciones del sistema operativo y no hay nada que mantener.


    Qué clase usar

    Nueve clases intimidan al principio, pero la decisión es mecánica:

    Necesitas representarClase
    Una fecha de calendario, sin horaPlainDate
    Una hora del reloj, sin fechaPlainTime
    Fecha y hora, sin zonaPlainDateTime
    Fecha y hora en un lugar concretoZonedDateTime
    Un instante exacto en la línea temporalInstant
    Un lapso (3 meses y 4 días)Duration
    Un mes de un año (vencimiento de tarjeta)PlainYearMonth
    Un día del año (cumpleaños)PlainMonthDay
    La hora actualNow

    La regla práctica: si el dato tiene sentido sin saber dónde estás, es Plain. Una fecha de nacimiento es PlainDate; la hora a la que se envió un correo es Instant; una reunión es ZonedDateTime.

    Esa distinción entre PlainDate e Instant es la que Date nunca tuvo, y de ahí venían casi todos sus problemas.


    Tabla de conversión desde date-fns y dayjs

    Todo esto ejecutado y verificado en Node 26.7.0:

    Lo que hacíasCon TemporalSalida real
    addDays(d, 5)d.add({days: 5})2026-08-12
    subDays(d, 10)d.subtract({days: 10})2026-07-28
    differenceInDays(a, b)a.until(b, {largestUnit:'day'}).days13294
    intervalToDuration()a.until(b, {largestUnit:'year'})P36Y4M23D
    isBefore(a, b)Temporal.PlainDate.compare(a, b) < 0-1 | 0 | 1
    isEqual(a, b)a.equals(b)true
    startOfDay(d)zdt.startOfDay()2026-08-07T00:00:00-06:00[…]
    setDate(d, 1)d.with({day: 1})2026-08-01
    getDay(d)d.dayOfWeek5 (1 = lunes)
    getDaysInMonth(d)d.daysInMonth31
    parseISO(s)Temporal.PlainDate.from(s)
    format(d, …)d.toLocaleString('es-MX', {…})7 de agosto de 2026
    isValid(d)try { … } catchlanza RangeError
    roundToNearestHours(d)zdt.round({smallestUnit:'hour'})…T16:00:00-06:00
    (sin equivalente)zdt.withTimeZone('Europe/Madrid')…T23:30:00+02:00[…]

    Dos apuntes sobre esa tabla:

    dayOfWeek empieza en 1 y el 1 es lunes, al contrario que getDay() de Date, donde el 0 es domingo. Es de los pocos sitios donde una migración descuidada introduce un bug silencioso.

    Duration.total() necesita un punto de referencia cuando la duración tiene meses, porque los meses no duran lo mismo:

    Temporal.Duration.from('P2M10D').total({unit: 'day', relativeTo: '2026-01-01'})
    // → 69
    

    Sin relativeTo, lanza. Es incómodo y es correcto: la pregunta «cuántos días son dos meses» no tiene respuesta sin decir desde cuándo.

    Si necesitas hacer una cuenta suelta sin escribir código, la calculadora del sitio hace exactamente esto:


    Migrar sin romper nada

    No hace falta un big bang. Date y Temporal interoperan en las dos direcciones:

    // Date → Temporal
    new Date('2026-08-07T18:00:00Z').toTemporalInstant()
    // → 2026-08-07T18:00:00Z
    
    // Temporal → Date
    new Date(Temporal.Instant.from('2026-08-07T18:00:00Z').epochMilliseconds)
    // → 2026-08-07T18:00:00.000Z
    

    Date.prototype.toTemporalInstant() viene incluido, así que el puente ya está construido. La estrategia que recomiendo:

    1. Empieza por la capa de dominio, no por la de presentación. Donde calculas vencimientos, edades y plazos es donde Date te hace daño.
    2. Guarda en base de datos como siempre. Un Instant en epoch o un ISO string; no cambies el esquema.
    3. Convierte en los bordes. Temporal por dentro, Date en la frontera con librerías que aún no lo soportan.
    4. Cambia primero lo que tenga tests. Los bugs de fechas son sutiles, y dayOfWeek cambiando de base es el ejemplo perfecto de lo que un test caza y una revisión visual no.

    Y si trabajas con marcas de tiempo epoch mientras migras, conviene tener a mano el conversor:

    En el navegador

    Hasta que Safari lo lleve en estable, polyfill:

    npm install temporal-polyfill
    
    import { Temporal } from 'temporal-polyfill';
    

    Pesa lo suyo, así que si tu único problema es formatear fechas, Intl.DateTimeFormat ya resuelve eso sin dependencias. El polyfill compensa cuando haces aritmética de fechas, que es donde Date falla de verdad.

    Cuándo NO migrar todavía

    • Si estás en Node 22 o 24. Temporal no está ahí; necesitarías el polyfill también en servidor, y entonces la ganancia es menor.
    • Si solo formateas fechas para mostrarlas. Intl.DateTimeFormat ya lo hace bien y no requiere nada.
    • Si dependes de librerías que devuelven Date. Vas a estar convirtiendo en cada frontera; espera a que actualicen.
    • Si tu app vive en una sola zona horaria sin horario de verano. El argumento más fuerte de Temporal no te aplica; migra sin prisa.

    Conclusión

    Temporal es de las pocas incorporaciones al lenguaje que arreglan un problema real en vez de añadir azúcar sintáctico. Date no era mejorable: mezclar «fecha de calendario» con «instante en el tiempo» en un solo objeto mutable es un error de diseño de raíz, y no se arregla añadiendo métodos.

    Lo que hay que retener:

    • En Node 26 ya está, sin flags. En navegador, polyfill hasta que Safari lo lleve.
    • Date no está deprecado y no va a desaparecer. Migrar es una decisión de calidad, no una urgencia.
    • Si tu app cruza zonas horarias o hace aritmética con plazos, migra la capa de dominio. Ahí es donde fecha.getTime() + dias * 86400000 te está mintiendo once meses al año.

    Y si te llevas un solo ejemplo, que sea el de Chile: dos días después de las 12:00 son las 12:00, no las 13:00, y entre medias solo pasan 47 horas.

    Compartir

    Buscar

    Etiquetas

    Laravel PHP Tutorial IA JavaScript Desarrollo Web Buenas Prácticas Laravel 13 Seguridad Migración Herramientas Claude SEO Expresiones Regulares Manipulación de Texto