Typed Dates Conversion with Typescript

Volodymyr Yepishev - Jun 30 '22 - - Dev Community

Links to repos are in the footer of the article.

We can't pass objects like Date between frontend and backend, we pass strings (or can pass numbers). Yet, sometimes
primitives like strings or numbers are not what frontend actually needs, so they are converted into something more
suitable, like Date, Moment or whatever object representation. In javascript it might not be a big deal, but
when you try to describe your frontend entities with typescript interfaces, you immediately run into problems. Suppose
you have the following User interface coming from an api:

interface User {
  registered: string;
  name: string;
  lastLoginDate: string | null;
  details: Details;
  activities: Activity[];
}

interface Details {
  birthday: string;
  place: string;
}

interface Activity {
  place: string;
  date: string;
}
Enter fullscreen mode Exit fullscreen mode

So there are some date fields which come as strings, but as you operate with, let's say, Date on the frontend for
adding/removing time or whatever, so you set up a layer of mappers for conversion of string representation of dates into
Date when you receive them and then Date back to string when you need to send those back to a backend. But then
what to do with types on the interfaces? Create new ones with Date? Use generics? Go for unions?

For some time I had an idea that such mapper should be a single function that modifies an object of any given depth and
does conversion of dates both on the object and in the interface, eliminating the need for additional interfaces to be
created. And this is the story how I tackled the problem.

Obviously it isn't a problem to convert dates in an object from string to some object representation, the real problem
is how to type those. From typescript generic perspective, there's no telling if a string or a number is a date, however
if union type is used, then it can be narrowed down with a generic, and that's the approach that can be used.

Therefore, any date is an ambiguous union, which can be presented by a generic type

 type AmbiguousDate<ObjectDate, PrimitiveDate> = ObjectDate | PrimitiveDate | null;
Enter fullscreen mode Exit fullscreen mode

in which ObjectDate is Date or any other object type and PrimitiveDate is number or string.

Let's update our User interface now and make all dates there union types with Date:

interface User {
  registered: Date | string;
  name: string;
  lastLoginDate: Date | string | null;
  details: Details;
  activities: Activity[];
}

interface Details {
  birthday: Date | string;
  place: string;
}

interface Activity {
  place: string;
  date: Date | string;
}
Enter fullscreen mode Exit fullscreen mode

We've made it so ambiguous that if used inside an application it would require constant checks to narrow the types. Yet,
this can be done using a generic, which would accept:

  • the type of the object being converted;
  • target date type to narrow down the union;
  • object date type of the union to perform check;
  • primitive date type of the union to perform checks.

It would also need to traverse down the object into its nested objects/arrays and check those too, but it should remove
the ambiguity, so we'll call this generic type DeepNonAmbiguousDate ('cos I suck at naming).

type DeepNonAmbiguousDate<T, TargetDateType extends AmbiguousDate<ObjectDate, PrimitiveDate>, ObjectDate, PrimitiveDate> = {
    [K in keyof T]: T[K] extends AmbiguousDate<ObjectDate, PrimitiveDate>
        ? T[K] extends NonNullable<ObjectDate | PrimitiveDate>
            ? T[K] extends PrimitiveDate
                ? T[K]
                : TargetDateType
            : TargetDateType | null
        : T[K] extends Array<infer A>
        ? Array<DeepNonAmbiguousDate<A, TargetDateType, ObjectDate, PrimitiveDate>>
        : T[K] extends object
        ? DeepNonAmbiguousDate<T[K], TargetDateType, ObjectDate, PrimitiveDate>
        : T[K];
};
Enter fullscreen mode Exit fullscreen mode

Now we can force all dates in a user to be of Date type:

const user: DeepNonAmbiguousDate<User, Date, Date, string> = {
    activities: [{
        date: new Date(),
        place: ''
    }],
    details: {
        birthday: new Date(),
        place: ''
    },
    lastLoginDate: new Date(),
    name: '',
    registered: new Date()
}
Enter fullscreen mode Exit fullscreen mode

Looks good, generic is powerful, but there's no actual conversion, generic itself is not enough, besides there's no link
between its 2nd and 3rd argument.

Let's think about a converter for a second. It should be able to convert from object to primitive and back, without
the actual knowledge of which primitive or object it converts into, otherwise it becomes biased and is tied to certain
types. This means we cannot really implement it to work with any types, unless we provide means for consumers to provide
their functions for conversion. This would boil over converter to an object tree walker applying provided functions to
different fields. The only certain knowledge that we have is that primitive type for a date can be either string or
number.

type PrimitiveDateType = string | number;
Enter fullscreen mode Exit fullscreen mode

So to produce our converter with a factory function, we'd need 4 functions actually:

  • one to check if a property is of primitive date type;
  • one to check if a property is of object date type;
  • one to convert primitive date to object date;
  • one to convert object date to primitive date.

Since there's 4 params, we can pack them into a payload object:

interface RecursiveDeepDateConverterFactoryPayload<PrimitiveDate extends PrimitiveDateType, ObjectDate> {
    converterToJsDate: (d: PrimitiveDate) => ObjectDate;
    converterFromJsDate: (d: ObjectDate) => PrimitiveDate;
    checkTypeofDate: (d: unknown) => boolean;
    checkTypeofPrimitive: (d: PrimitiveDate) => boolean;
}
Enter fullscreen mode Exit fullscreen mode

The converter we're producing can also be represented by a generic

type RecursiveDateConverter<PrimitiveDate, ObjectDate> = <T, TargetDateType extends PrimitiveDate | ObjectDate>(
    obj: T
) => DeepNonAmbiguousDate<T, TargetDateType, ObjectDate, PrimitiveDate>;
Enter fullscreen mode Exit fullscreen mode

So it will accept an object and a target date type for conversion in generic, while the conversion itself will be done
using the functions provided to it.

All what's left is to create the factory function:

function createRecursiveDateConverter<PrimitiveDate extends PrimitiveDateType, ObjectDate>({
    checkTypeofDate,
    checkTypeofPrimitive,
    converterFromJsDate,
    converterToJsDate,
}: RecursiveDeepDateConverterFactoryPayload<PrimitiveDate, ObjectDate>): RecursiveDateConverter<PrimitiveDate, ObjectDate> {
    return function recursiveDateConverter<T, TargetDateType extends PrimitiveDate | ObjectDate>(
        obj: T
    ): DeepNonAmbiguousDate<T, TargetDateType, ObjectDate, PrimitiveDate> {
        for (const k in obj) {
            if (!Object.prototype.hasOwnProperty.call(obj, k)) {
                continue;
            }

            const value: unknown = obj[k];

            if (Array.isArray(value)) {
                value.map(recursiveDateConverter);
                continue;
            }

            if (typeof value === 'object') {
                if (checkTypeofDate(value)) {
                    // @ts-ignore
                    obj[k] = converterFromJsDate(value);
                    continue;
                }
                // @ts-ignore
                obj[k] = recursiveDateConverter(value);
            }

            if (checkTypeofPrimitive(<PrimitiveDate>value)) {
                // @ts-ignore
                obj[k] = converterToJsDate(value);
            }
        }
        return obj as DeepNonAmbiguousDate<T, TargetDateType, ObjectDate, PrimitiveDate>;
    };
}
Enter fullscreen mode Exit fullscreen mode

So it creates a converter walking down the object tree, doing checks and conversions with provided functions. Which means
it is quite flexible, it doesn't make assumptions about primitive date being string or an object date being Date.

Let's try to use it on a User with string dates:

const user: User = {
    activities: [{
        date: '1991-12-12',
        place: ''
    }],
    details: {
        birthday: '1991-12-12',
        place: ''
    },
    lastLoginDate: '1991-12-12',
    name: '',
    registered: '1991-12-12'
}
Enter fullscreen mode Exit fullscreen mode

Creating a converter, for the sake of simplicity we assume dates are any string with 8 digits with hyphens:

const deepDateConverter = createRecursiveDateConverter<string, Date>({
    checkTypeofDate: (v: unknown) => v instanceof Date,
    checkTypeofPrimitive: (v: string | number) => typeof v === 'string' && /^\d{4}(-\d{2}){2}$/.test(v),
    converterFromJsDate: (d: Date) => d.toISOString(),
    converterToJsDate: (d: string) => new Date(d)
});
Enter fullscreen mode Exit fullscreen mode

Now we can convert all string dates of an object (not necessary a User) from strings to Date:

const convertedUser = deepDateConverter<User, Date>(user);
Enter fullscreen mode Exit fullscreen mode

And then we can convert them back if we want strings:

const convertedUser2 = deepDateConverter<typeof convertedUser, string>(convertedUser);
Enter fullscreen mode Exit fullscreen mode

Pretty cool, eh? :D

The caveat here is conversion always goes from one direction to another and type is hint is set using generic, so these
are two different things: pass wrong date type to generic, get wrong hints :)

Links

Typescript playground

NPM package

Github repo

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .