typescript 如何使递归反序列化JSON的泛型函数具有嵌套的日期字符串类型安全?

omvjsjqw  于 2023-02-05  发布在  TypeScript
关注(0)|答案(1)|浏览(154)

下面的代码定义了一个函数stringsToDates,它将接受一个原语、对象或Promise并递归转换找到的任何ISO8601日期字符串(序列化JSON)到示例化的Date对象。如何使这样的函数类型安全,以便可以调用const result = stringsToDates<MyBusinessObject>(serializedObject),并且result的类型为MyBusinessObject?我已经验证了功能(如果需要的话,还可以包括测试套件),但是我在打字方面很吃力。
如果我用注解行替换函数签名,则一些返回语句会抛出类型错误,例如Type 'Promise<any>' is not assignable to type 'DateDeserialized<T>'.。更多示例请参见本节底部的TS Playground。
代码片段:

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由@jcalz提供

iszxjhcz

iszxjhcz1#

TypeScript类型检查器目前无法对conditional types进行太多分析,因为x1e0 f1-依赖于尚未指定的generic类型参数。它本质上 * 推迟 * 此类类型的求值,因此将其视为 * 不透明 *。它不知道哪些值可以或不可以赋值给此类类型,并且它在安全方面出错,发出了有关可赋值性的警告。
DateDeserialized<T>类型是一个条件类型,在stringsToDates()的主体中,类型T是一个泛型类型参数,编译器并不知道它是什么,所以DateDeserialized<T>对编译器是不透明的,当你返回body这样的值时(类型为T),编译器会将其与DateDeserialized<T>进行比较,无法验证其是否有效,并发出类似Type 'T' is not assignable to type 'DateDeserialized<T>'的警告。
将此行为与 ousidestringsToDates()进行比较。

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

编译器推断T已经用特定类型{foo: string; bar: string}指定,因此返回类型是DateDeserialized<{foo: string; bar: string}>,它不再是泛型。它将该类型完全求值为{ foo: string | Date; bar: string | Date; },因此对result的赋值成功。
因此,您的代码的问题是编译器无法分析DateDeserialized<T>是否为泛型T
现在,您可能期望通过显式地检查T类型的body,然后编译器将能够理解关于T的更具体的东西,以便类型DateDeserialized<T>将变得具体,它可以计算。毕竟,如果body === null || body === undefined为真,这不意味着T就是null | undefined吗?那么返回类型就是特定的类型DateDeserialized<null | undefined>也就是null | undefined
问题是,虽然检查body === null || body === undefined可以直接告诉你一些关于body的信息,但它对类型参数T本身没有任何作用,在检查之后,我们对T所知道的只是nullundefined在它的域中;它可能是string | null{a: number} | undefinedunknown或任何东西。而且这种复杂性没有被编译器很好地建模,所以它仍然放弃了。
不过,如果它能做得更好就好了。关于这一点,有各种各样的开放特性请求。这里最适用的可能是microsoft/TypeScript#33912,请求某种方法使用控制流分析来获得关于return语句中的条件类型的更多有用信息。如果这一点得到实现,它大概会修复或至少改善这种情况。但现在我们必须围绕它工作。
最简单的解决方法是接受编译器毫无准备地在广泛处理泛型条件类型的函数体内部进行任何有用的类型检查,并采取措施通过类型Assert等技术来抑制错误。
在这样的情况下,我通常会把有问题的函数转换成一个只有一个调用签名的重载函数。重载通常用于多个不同的调用签名,但它们也允许在调用者所看到的函数和实现者所看到的函数之间存在一个障碍,后者相对于前者的检查相当松散。
在您的示例中,我只是将泛型条件版本移到调用签名中,并在实现签名中使用“turn off type checking”any类型:

// 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({}));
    }
}

现在没有错误了。
注意,没有错误并不意味着函数体中的类型安全。这个解决方案的全部要点是抑制错误,因为编译器是不匹配的。这意味着如果你想在函数体中实现类型安全,它必须由 * 你 * 验证,而不是由编译器验证。所以我'我建议您对实现进行两次和三次检查,以确保返回的值确实是DateDeserialized<T>类型,或者至少足够接近而有用。
Playground代码链接

相关问题