Home > Net >  How can I make a generic function that recursively deserializes JSON with nested datestrings type sa
How can I make a generic function that recursively deserializes JSON with nested datestrings type sa

Time:02-03

The code below defines a function stringsToDates that will take a primitive, object, or Promise and recursively convert any ISO8601 date strings that it finds (serialized JSON) to instantiated Date objects. How can I make such a function type safe so that I could call const result = stringsToDates<MyBusinessObject>(serializedObject) and result be of type MyBusinessObject? I have verified the functionality (and can include the test suite if requested), but am struggling with the typing aspect.

If I were to replace the function signature with the commented line, several of the return statements throw type errors such as Type 'Promise<any>' is not assignable to type 'DateDeserialized<T>'. Further examples are in the TS Playground included at the bottom of this section.

Code snippet:

type IsoDateString = string
function isIsoDateString(value: unknown): value is IsoDateString {
    const isoDateFormat = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?Z$/;
    if (value === null || value === undefined) {
        return false;
    }
    if (typeof value === 'string') {
        return isoDateFormat.test(value);
    }
    return false;
}

type DateDeserialized<T> =
    T extends string ? Date | T :
    T extends PromiseLike<infer U> ? PromiseLike<DateDeserialized<U>> :
    { [K in keyof T]: DateDeserialized<T[K]> }

function stringsToDates<T>(body: T): DateDeserialized<T> {
    if (body === null || body === undefined) {
        return body;
    } else if (body instanceof Promise) {
        return body.then(stringsToDates);
    } else if (Array.isArray(body)) {
        return body.map(stringsToDates);
    } else if (isIsoDateString(body)) {
        return new Date(body);
    } else if (typeof body !== 'object') {
        return body;
    } else {
        const bodyRecord = body;
        console.log(bodyRecord)
        return Object.keys(bodyRecord).reduce((previous, current): Record<string, any> => {
            const currentValue = bodyRecord[current];
            if (isIsoDateString(currentValue)) {
                return { ...previous, [current]: new Date(currentValue) };
            } else {
                return { ...previous, [current]: stringsToDates(currentValue) };
            }

        }, Object.create({}));
    }
}

// Example invocation
type MyBusinessObject = { foo: string, bar: Date };

const input = { foo: 'example', bar: (new Date(0)).toJSON() }
const result = stringsToDates(input); 
/* const result: {
    foo: string | Date;
    bar: string | Date;
} */

Original TS Playground

New TS Playground courtesy of @jcalz

CodePudding user response:

The TypeScript type checker isn't currently able to do much analysis on conditional types which depend on as-yet-unspecified generic type parameters. It essentially defers evaluation of such types, and therefore treats them as opaque. It doesn't know what values may or may not be assignable to such types, and it errs on the side of safety by issuing warnings about assignability.

The DateDeserialized<T> type is a conditional type, and inside the body of stringsToDates(), the type T is a generic type parameter and the compiler doesn't know exactly what it is. So DateDeserialized<T> is opaque to the compiler, and when you return something like body (of type T), the compiler compares it to DateDeserialized<T>, is unable to verify whether that will work, and issues a warning like Type 'T' is not assignable to type 'DateDeserialized<T>'. And likewise for the other return statements.

Compare this to the behavior ouside stringsToDates(). When you call

const result: { foo: string | Date, bar: string | Date } =
  stringsToDates({ foo: 'example', bar: (new Date(0)).toJSON() });

the compiler infers that T has been specified with the specific type {foo: string; bar: string}, and therefore the return type is DateDeserialized<{foo: string; bar: string}>, which is no longer generic. It evaluates the type fully as { foo: string | Date; bar: string | Date; } and therefore the assignment to result succeeds.

So the issue with your code is the compiler's inability to analyze DateDeserialized<T> for generic T.


Now, you probably expected that by explicitly checking body of type T, then the compiler would be able to understand something more specific about T so that the type DateDeserialized<T> would become specific it could evaluate. After all, if body === null || body === undefined is true, doesn't that mean that T is null | undefined, and then the return type would be the specific type DateDeserialized<null | undefined> which is just null | undefined?

Well, no. The problem is that while checking body === null || body === undefined can tell you something straightforward about body, it doesn't do anything to the type parameter T itself. All we know about T after that check is that null and undefined are in its domain; it could be string | null or {a: number} | undefined or unknown or anything. And this complexity is not modeled well by the compiler, so it still gives up.

It would be great if it could do something better, though. And there are various open feature requests about this. The most applicable one here is probably microsoft/TypeScript#33912, asking for some way to use control flow analysis to get more useful information about conditional types in return statements. If that ever gets implemented, it would presumably fix or at least improve the situation. But for now we have to work around it.


The easiest workaround is to just accept that the compiler is hopelessly unprepared to do any useful type checking inside the body of a function that deals extensively with generic conditional types, and take steps to suppress the errors, via techniques like type assertions.

In cases like this I generally turn the offending function into an overloaded function with a single call signature. Overloads are usually used for multiple distinct call signatures, but they also allow for a barrier between the function as viewed by callers and the function as viewed by the implementer, and latter is checked fairly loosely against the former.

In your example, I'd just move the generic conditional version to the call signature, and use the "turn off type checking" any type in the implementation signature:

// call signature
function stringsToDates<T>(body: T): DateDeserialized<T>;

// implementation
function stringsToDates(body: any) {
    if (body === null || body === undefined) {
        return body;
    } else if (body instanceof Promise) {
        return body.then(stringsToDates);
    } else if (Array.isArray(body)) {
        return body.map(stringsToDates);
    } else if (isIsoDateString(body)) {
        return new Date(body);
    } else if (typeof body !== 'object') {
        return body;
    } else {
        const bodyRecord = body;
        console.log(bodyRecord)
        return Object.keys(bodyRecord).reduce((previous, current): Record<string, any> => {
            const currentValue = bodyRecord[current];
            if (isIsoDateString(currentValue)) {
                return { ...previous, [current]: new Date(currentValue) };
            } else {
                return { ...previous, [current]: stringsToDates(currentValue) };
            }

        }, Object.create({}));
    }
}

Now there are no errors.

Note that the lack of errors does not imply type safety in the body of the function. The whole point of this workaround is to suppress the errors because the compiler is outmatched. That means if you want type safety inside the body of the function, it will have to be verified by you and not by the compiler. So I'd suggest you double and triple check the implementation to be sure that the values being returned really will be of the type DateDeserialized<T>, or at least close enough to be useful.

Playground link to code

  • Related