La API Temporal ya viene activada en Node 26: deja de pelearte con Date
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ándar | Stage 4 desde marzo de 2026. Forma parte de ES2026 |
| Node | 26: activada por defecto. Entra en LTS en octubre |
| Chrome / Edge | Soportada desde la 144 (enero de 2026) |
| Firefox | Soportada desde la 139 |
| Safari | Solo en Technology Preview, tras un flag |
| Baseline | No, y el bloqueo es Safari |
Dos matices que se cuentan mal por ahí:
Dateno está deprecado. MDN describe Temporal como «un reemplazo completo» deDate, que no es lo mismo.Datesigue 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 representar | Clase |
|---|---|
| Una fecha de calendario, sin hora | PlainDate |
| Una hora del reloj, sin fecha | PlainTime |
| Fecha y hora, sin zona | PlainDateTime |
| Fecha y hora en un lugar concreto | ZonedDateTime |
| Un instante exacto en la línea temporal | Instant |
| 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 actual | Now |
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ías | Con Temporal | Salida 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'}).days | 13294 |
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.dayOfWeek | 5 (1 = lunes) |
getDaysInMonth(d) | d.daysInMonth | 31 |
parseISO(s) | Temporal.PlainDate.from(s) | — |
format(d, …) | d.toLocaleString('es-MX', {…}) | 7 de agosto de 2026 |
isValid(d) | try { … } catch | lanza 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:
- Empieza por la capa de dominio, no por la de presentación. Donde calculas vencimientos, edades y plazos es donde
Datete hace daño. - Guarda en base de datos como siempre. Un
Instanten epoch o un ISO string; no cambies el esquema. - Convierte en los bordes. Temporal por dentro,
Dateen la frontera con librerías que aún no lo soportan. - Cambia primero lo que tenga tests. Los bugs de fechas son sutiles, y
dayOfWeekcambiando 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.DateTimeFormatya 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.
Dateno 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 * 86400000te 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.