Tratar con Fechas en JSON en Javascript y TypeScript

Hace ya tiempo que JSON ganó la batalla de los formatos de transmisión de datos y se hizo ubicuo. Es simple, fácil de leer, fácil de editar manualmente… una maravilla, vamos. O no. Por desgracia, un lenguaje cuya grámatica cabe en el reverso de una tarjeta de visita también tiene limitaciones importantes, y una de ellas, con la que todos tropezamos de vez en cuando, es la falta de soporte estandarizado para fechas.

Al final cada serializador de JSON utiliza su propio sistema para tratar con fechas. Algunos optan por convertir fechas a strings usando el formato ISO 8601, otros prefieren utilizar formato epoch para convertirlas a números, y otros muchos utilizan formatos específicos en sus aplicaciones.

El problema de esto es que al recomponer los datos serializados hemos perdido información del tipo original, por lo que en lugar de un valor Date/DateTime/etc., tendremos un string o number. Si a eso le unes que generalmente JSON se utiliza sin acompañarlo de un esquema, interpretar los datos que tenemos es complicado y acaba siendo incómodo.

Hace unos días comentaba el tema con los señores Landeras y Ros, reconocidos expertos en la materia, y acabamos con un pequeño catálogo de opciones para tratar con esta situación. Algunas son más simples, otras aportan más magia. Hasta dónde quieras llegar es cosa tuya.

Todos los ejemplos son aplicables a Javascript, pero veremos también hasta dónde son «tipables» con TypeScript.

En TypeScript el problema es aun mayor, porque al tener el tipado estático es fácil pensar que estamos trabajando con C# o Java, y que si tenemos una referencia a algo que dice ser un Date realmente es un Date, pero no tiene por qué ser necesariamente así. Podemos escribir let d: Date = "Macario" as any, y no descubriremos el problema hasta que intemos usar d, lo que puede ocurrir mucho tiempo después. En Java o C#, aunque podríamos engañar al compilador pasando por los casts correspondientes, en tiempo de ejecución al menos obtendríamos el error en el momento de hacer la asignación incompatible y no mucho tiempo después al intentar usar la referencia.

Vaya por delante que, por las características de JSON, vamos a necesitar saber a priori el formato que usará el servidor para serializar las fechas. Si no, la parte de convertirlas a objetos Date se puede complicar enormemente porque implicaría empezar a probar formas de conversión hasta que alguna funcione, y nunca estaríamos muy seguros de si esa conversión es correcta o sólo fruto de la casualidad.

Parsing manual

Esta es la opción más directa y que casi todos habremos usado alguna vez. La idea es realizar una conversión explícita de las propiedades que necesitamos:

getCustomer('/customer/1').then(function(customer) {
  customer.birthdate = new Date(customer.birthdate);
  customer.lastLogin = new Date(custoemr.lastLogin);
  return customer;
});

Cuando obtenemos el objeto deserializado desde JSON, nos encargamos de actualizar las propiedades que son de tipo Date (en este caso asumiendo que nos llega un valor que new Date es capaz de interpretar, pero podríamos usar técnicas más avanzadas para parsearlo).

Como esto es un poco tedioso, podemos escribir una función que nos eche una mano:

function convertDates(target, ...properties) {
  for (let prop of properties)
    target[prop] = new Date(target[prop]);

  return target;
}

getCustomer('/customer/1').then(function(customer) {
  return convertDates(customer, 'birthdate', 'lastLogin');
});

En TypeScript podemos tipar parte de la función convertDates usando keyof T, pero necesitaremos recurrir a algún any dentro de la función y intentar parsear propiedades que no son fechas:

function convertDates<T, S extends keyof T>(target: T, ...properties: S[]) {
  for (let prop of properties)
  target[prop] = new Date(target[prop] as any) as any;
  return target;
}

// Esto es válido
convertDates(customer, 'birthdate');

// Esto genera un error de compilación
convertDates(customer, 'invalid-property');

// Pero desgraciadamente esto no lo detecta el compilador
convertDates(customer, 'name');

No es una maravilla, pero algo ayuda el tipado.

Lo peor que tiene esta opción es que es laboriosa de mantener y muy propensa a errores. Si añadimos nuevas propiedades al objeto serializado o les cambiamos el nombre, hay que actualizar las invocaciones a la función convertDates para que las tenga en cuenta.

Una forma de solucionarlo es tener algún mecanismo que nos permita detectar potenciales fechas y haga la conversión automática. Para ello podríamos aplicar alguna convención del tipo «todos las propiedades fecha lleva el sufijo Date» o «todos los strings con formato ISO 8601 son fechas».

Aun así, hay que recordar llamar a convertDates cada vez que obtengamos un objeto que proceda de deserializar JSON.

Aprovechando JSON.parse

La forma que todos conocemos de deserializar JSON en Javascript es utilizando JSON.parse, que recibe un string y devuelve el objeto deserializado.

Lo que no es tan conocido es que JSON.parse recibe un segundo parámetro, el reviver, que permite controla la forma en que se realiza la deserialización.

Llevado esto a nuestro problema de fechas, si pudiéramos ser capaces de detectar los valores que representan fechas, por ejemplo porque se ajustan a una expresión regular, podríamos automatizar la conversión:

const customer = JSON.parse(someJsonString, function(key, value) {
  if (typeof value !== 'string')
    return value;

  const isoDate = /(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})[+-](\d{2})\:(\d{2})/;

  return value.match(isoDate) ? new Date(value) : value;
});

Con esta solución tenemos dos problemas.

El primero es que volvemos a necesitar acordarnos de invocar JSON.parse con nuestro reviver, y si se nos olvida tendremos problemas. Si tienes un único punto de la aplicación que parsea JSON no es demasiado grave porque sólo tendrás que hacerlo ahí, pero si andas parseando JSON por toda la aplicación es más incómodo (y tal vez deberías darle una vuelta a ese diseño).

De todas formas, podrías evitar el problema usando monkey patching sobre JSON.parse:

(function() {

  const customReviver = function(key, value) {
    if (typeof value !== 'string')
      return value;

    const isoDate = /(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})[+-](\d{2})\:(\d{2})/;

    return value.match(isoDate) ? new Date(value) : value;
  }

  const _parse = JSON.parse;
  JSON.parse = function(text, reviver) {
    return _parse(text, reviver || customReviver);
  }
})();

¿Qué tal se lleva esto con el tipado de TypeScript? Igual de bien o mal que antes de hacer el parcheado. El tipo de JSON.parse no ha cambiado y sigue siendo:

parse(text: string, reviver?: (key: any, value: any) => any): any;

Por tanto, lo que nos devolverá es un any, del cual haremos un cast al tipo que queramos y ya está, ya tendremos algo que parece un Customer (por ejemplo) de cara al compilador, pero que vete tú a saber qué lleva dentro y, lo más importante, ya veremos cuándo te enteras de que no es lo que pensabas.

El segundo problema que tiene esta solución es que no siempre que se parsea JSON se pasa por este método, lo que hace que aunque lo hayamos parcheado se nos puedan escapar casos. Por ejemplo, si estás utilizando fetch y response.json(), el parsing se hace a nivel más bajo (al menos en Chrome), por lo que esta solución no te sirve.

Interceptando las respuestas del servidor

En la mayoría de los casos el momento en que necesitamos deserializar JSON es cuando estamos recibiendo una respuesta de un servidor. Hay otros escenarios, como recuperar datos de LocalStorage o SessionStorage, pero son menos frecuentes.

Muchas librerías permiten interceptar de alguna forma la respuesta y procesarla, por lo que es un buen punto para enganchar nuestra conversión de fechas. Si estás utilizando fetch, podemos extender el objeto Request para parchear el método json(), o incluso añadir nuestro propio método haciendo algo así:

Response.prototype.typedJson = function () {
  // Aquí podemos usar cualquiera de las técnicas anteriores,
  // por ejemplo, la del reviver
  const customReviver = function(key, value) {
    if (typeof value !== 'string')
      return value;

    const isoDate = /(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})[+-](\d{2})\:(\d{2})/;

    return value.match(isoDate) ? new Date(value) : value;
  }

  return this.text().then(text => JSON.parse(text, revivir));
}

Podríamos haber reescrito directamente el método json, pero en este caso prefiero mantener también el método original por si fuese necesario acceder al JSON «puro» en algún escenario.

Llevado a TypeScript, podemos aplicar la técnica para extender tipos existentes que vimos hace unas semanas. Además del añadir typedJson al prototipo de Response usaríamos el declaration merging para tiparlo incluyendo esto en nuestro código:

interface Response {
  typedJson<T>(): T;
}	

Con esto, el uso quedaría bastante cómodo:

fetch('/customer/1')
  .then(response => response.typedJson<Customer>())
  .then(customer => customer.name);

No tenemos más seguridad de tipos que antes (seguimos convirtiendo por debajo de any a T), pero al menos las fechas estarán convertidas a Date y el código queda muy legible.

Resumen

Todas estas técnicas no dejan de ser parches para el problema subyacente, que es la elección de un formato de serialización con muchas limitaciones como es JSON. Desgraciadamente es también uno de los formatos más populares y toca lidiar con él.

Probablemente lo más limpio sea la primera opción porque no implica andar tocando los prototipos de cosas existentes, pero también es cierto que, ya que estamos en un lenguaje que lo permite (y pagas un precio por ello), parece razonable intentar aprovecharlo.

Un comentario en “Tratar con Fechas en JSON en Javascript y TypeScript

  1. No había visto que finalmente hiciste la entrada sobre el tema. Muy buen análisis.

Comentarios cerrados.