[HOWTO] Convert JSON date to TypeScript Date with Axios

After a longer period of inactivity I’m finally back and glad to continue sharing solutions for challenges I faced in my daily work!

Recently I faced the following problem: a React (v18.2.0) frontend application that uses Axios (v1.3.6) to fetch data from an ASP.NET Core Web API did not behave as expected when it comes to deserialization of JSON datetimes to TypeScript type Date. Against my expectations, the JSON datetimes got deserialized to type string instead of Date.

The model/DTO class in the ASP.NET Core Web API looks as follows.

namespace ArbitraryApplication.Shared.Models;

public class ArbitraryModel
{
    public int Id { get; set; }
    public DateTime Start { get; set; }
    public DateTime End { get; set; }
}

Next the model/DTO interface in the React frontend.

export interface ArbitraryModel {
  id: number;
  start: Date;
  end: Date;
}

For simplicity, I’ll leave out the use of react-query library.

The service in the frontend fetches the data as shown in the next code snippet.

this.httpClient.get<ArbitraryModel[]>(
      "arbitrary/details"
    );

The function in HttpClient.ts:

export class HttpClient {
  private readonly axiosInstance: AxiosInstance;

  constructor() {
    this.axiosInstance = axios.create();
  }

  async get<TResponse, TParameters = unknown>(
    url: string,
    params?: TParameters
  ): Promise<TResponse> {
    return this.doGet<TResponse, TParameters>(url, params);
  }
}

Next the JSON body that gets returned by the ASP.NET Core Web API.

[
    {
        "id": 54,
        "start": "2023-09-08T07:43:49",
        "end": "2023-09-08T13:12:29"
    },
    {
        "id": 53,
        "start": "2023-09-06T23:13:44",
        "end": "2023-09-07T06:46:57"
    },
    {
        "id": 52,
        "start": "2023-09-04T23:18:26",
        "end": "2023-09-05T01:17:16"
    }
]

As usual, there are several ways to solve this problem. First, I simply converted the start and end properties of ArbitraryModel in the frontend code when using it.

new Date(start)

However, as my teammate Gian-Luca Mateo rightly pointed out, this is not a good solution because the expected type of start and end by contract is Date and not string. So I thought about doing the conversion in the corresponding service in the frontend. However, this approach has the disadvantage that I have to repeat myself in different services and that I have to pay attention for newly introduced properties of the type Date so that they get converted as well.

After consulting the documentation of Axios I came up with the solution of using a response interceptor that recursively checks the response body for date/date time values and converts them into TypeScript type Date as follows.

# HttpClient.ts

export class HttpClient {
  private readonly axiosInstance: AxiosInstance;

  constructor() {
    this.axiosInstance = axios.create();
    # Add response interceptor
    this.axiosInstance.interceptors.response.use((response) => {
      recursivelyHydrateDates(response.data);
      return response;
    });
  }

  async get<TResponse, TParameters = unknown>(
    url: string,
    params?: TParameters
  ): Promise<TResponse> {
    return this.doGet<TResponse, TParameters>(url, params);
  }
}
# recursivelyHydrateDates.ts

/* eslint-disable @typescript-eslint/no-explicit-any */
import parseISO from "date-fns/parseISO";

const dateRegexp = /\d{4}-\d{2}-\d{2}T.*\d{2}:\d{2}/;

const doesValueLookLikeADateString = (propValue: any) => (
    propValue && typeof propValue === "string" && dateRegexp.test(propValue)
  );

const recursivelyHydrateDates = <T>(data: T, maxDepth = 5): T => {
  const objects: any = [[data]];

  for (let depth = 0; depth < maxDepth; depth += 1) {
    const objectsToIterateNext: any[] = [];
    objects[depth].forEach((obj: any) => {
      if (obj && typeof obj === "object") {
        Object.keys(obj).forEach((k) => {
          const isValuePotentialDate = doesValueLookLikeADateString(obj[k]);

          const attemptedParsedValue = parseISO(obj[k]);
          if (
            isValuePotentialDate &&
            !Number.isNaN(attemptedParsedValue.getTime())
          ) {
            // eslint-disable-next-line no-param-reassign
            obj[k] = attemptedParsedValue;
          } else {
            objectsToIterateNext.push(obj[k]);
          }
        });
      }
    });
    objects[depth + 1] = objectsToIterateNext;
  }
  return data;
};

export { recursivelyHydrateDates };

Special thanks to Gian-Luca Mateo for pushing me to that solution which is much better and cleaner than my first approach!

Leave a comment

Website Powered by WordPress.com.

Up ↑