65.9K
CodeProject 正在变化。 阅读更多。
Home

精通 TypeScript 中的类型安全 JSON 序列化

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2024 年 2 月 26 日

CPOL

9分钟阅读

viewsIcon

5174

如何利用 TypeScript 对 JSON 格式的数据进行类型安全的序列化和反序列化

引言

几乎所有 Web 应用程序都需要数据序列化。在以下情况下会出现此需求:

  • 通过网络传输数据(例如,HTTP 请求、WebSocket
  • 在 HTML 中嵌入数据(例如,用于水合)
  • 将数据存储在持久化存储中(例如 LocalStorage
  • 在进程之间共享数据(例如,Web Workers 或 postMessage

在许多情况下,数据丢失或损坏可能导致严重后果,因此提供一种方便且安全的序列化机制至关重要,该机制有助于在开发阶段尽可能多地检测错误。为此,使用 JSON 作为数据传输格式,并使用 TypeScript 进行开发过程中的静态代码检查非常方便。

TypeScript 是 JavaScript 的超集,应该能够无缝使用 JSON.stringifyJSON.parse 等函数,对吧?事实证明,尽管 TypeScript 有很多优点,但它并不天然理解 JSON 是什么,以及哪些数据类型可以安全地序列化和反序列化为 JSON。我们用一个例子来说明这一点。

TypeScript 中 JSON 的问题

例如,考虑一个将某些数据保存到 LocalStorage 的函数。由于 LocalStorage 无法存储对象,因此我们在此处使用 JSON 序列化。

interface PostComment {
  authorId: string;
  text: string;
  updatedAt: Date;
}

function saveComment(comment: PostComment) {
    const serializedComment = JSON.stringify(comment);
    localStorage.setItem('draft', serializedComment);
}

我们还需要一个函数来从 LocalStorage 中检索数据。

function restoreComment(): PostComment | undefined {
    const text = localStorage.getItem('draft');
    return text ? JSON.parse(text) : undefined;
}

这段代码有什么问题?第一个问题是,在恢复注释时,我们将为 updatedAt 字段获得 string 类型而不是 Date 类型。

发生这种情况是因为 JSON 只有四种基本数据类型(nullstringnumberboolean),以及数组和对象。无法在 JSON 中保存 Date 对象,以及 JavaScript 中存在的其他对象:functionsMapSet 等。

JSON.stringify 遇到无法以 JSON 格式表示的值时,会发生类型转换。对于 Date 对象,我们会得到一个 string,因为 Date 对象实现了 toJSON() 方法,该方法返回一个 string 而不是 Date 对象。

const date = new Date('August 19, 1975 23:15:30 UTC');

const jsonDate = date.toJSON();
console.log(jsonDate);
// Expected output: "1975-08-19T23:15:30.000Z"

const isEqual = date.toJSON() === JSON.stringify(date);
console.log(isEqual);
// Expected output: true

第二个问题是 saveComment 函数返回 PostComment 类型,其中日期字段为 Date 类型。但我们已经知道,实际上我们将收到 string 类型而不是 Date 类型。TypeScript 可以帮助我们找到这个错误,但为什么它没有呢?

事实证明,在 TypeScript 的标准库中,JSON.parse 函数的类型定义为 (text: string) => any。由于使用了 any,类型检查实际上被禁用。在我们的示例中,TypeScript 只是相信我们说该函数将返回一个包含 Date 对象的 PostComment

这种 TypeScript 行为不方便且不安全。如果我们尝试将 string 对象当作 Date 对象来处理,我们的应用程序可能会崩溃。例如,如果我们调用 comment.updatedAt.toLocaleDateString(),它可能会中断。

确实,在我们这个小例子中,我们可以简单地将 Date 对象替换为数字时间戳,这对于 JSON 序列化来说效果很好。但在实际应用程序中,数据对象可能非常庞大,类型可能定义在多个位置,并且在开发过程中识别此类错误可能是一项艰巨的任务。

如果我们能增强 TypeScript 对 JSON 的理解该怎么办?

处理序列化

首先,让我们弄清楚如何让 TypeScript 了解哪些数据类型可以安全地序列化为 JSON。假设我们要创建一个 safeJsonStringify 函数,其中 TypeScript 将检查输入数据格式以确保其可序列化为 JSON。

function safeJsonStringify(data: JSONValue) {
    return JSON.stringify(data);
}

在此函数中,最重要的部分是 JSONValue 类型,它表示可以表示在 JSON 格式中的所有可能值。实现非常简单:

type JSONPrimitive = string | number | boolean | null | undefined;

type JSONValue = JSONPrimitive | JSONValue[] | {
    [key: string]: JSONValue;
};

首先,我们定义 JSONPrimitive 类型,它描述了所有 JSON 基本数据类型。我们还根据以下事实包含 undefined 类型:在序列化时,值为 undefined 的键将被省略。在反序列化时,这些键将根本不会出现在对象中,在大多数情况下,这与相同的事情相同。

接下来,我们描述 JSONValue 类型。此类型使用了 TypeScript 描述递归类型的能力,递归类型是指向自身引用的类型。在这里,JSONValue 可以是 JSONPrimitiveJSONValue 的数组,或者是所有值均为 JSONValue 类型的对象。因此,类型为 JSONValue 的变量可以包含具有无限嵌套的数组和对象。其中的值也将被检查是否与 JSON 格式兼容。

现在我们可以使用以下示例测试我们的 safeJsonStringify 函数:

// No errors
safeJsonStringify({
    updatedAt: Date.now()
});

// Yields an error:
// Argument of type '{ updatedAt: Date; }' is not assignable to parameter of type 'JSONValue'.
//   Types of property 'updatedAt' are incompatible.
//     Type 'Date' is not assignable to type 'JSONValue'.
safeJsonStringify({
    updatedAt: new Date();
});

一切似乎都能正常工作。该函数允许我们将日期作为数字传递,但如果传递 Date 对象则会报错。

但让我们考虑一个更现实的例子,其中传递给函数的数据存储在一个变量中并具有描述的类型。

interface PostComment {
    authorId: string;
    text: string;
    updatedAt: number;
};

const comment: PostComment = {...};

// Yields an error:
// Argument of type 'PostComment' is not assignable to parameter of type 'JSONValue'.
//   Type 'PostComment' is not assignable to type '{ [key: string]: JSONValue; }'.
//     Index signature for type 'string' is missing in type 'PostComment'.
safeJsonStringify(comment);

现在,情况变得有点棘手了。TypeScript 不允许我们将类型为 PostComment 的变量分配给类型为 JSONValue 的函数参数,因为“类型 'string' 的索引签名在类型 'PostComment' 中丢失”。

那么,什么是索引签名,为什么会丢失呢?还记得我们是如何描述可以序列化为 JSON 格式的对象吗?

type JSONValue = {
    [key: string]: JSONValue;
};

在这种情况下,[key: string] 就是索引签名。它表示“此对象可以具有形式为 string 的任何键,其值的类型为 JSONValue 类型”。所以,看来我们需要为 PostComment 类型添加一个索引签名,对吧?

interface PostComment {
    authorId: string;
    text: string;
    updatedAt: number;

    // Don't do this:
    [key: string]: JSONValue;
};

事实上,这样做将意味着注释可以包含任何任意字段,这通常不是在应用程序中定义数据类型时期望的结果。

索引签名问题的真正解决方案来自 Mapped Types(映射类型),它允许递归地遍历字段,即使对于没有定义索引签名的类型也是如此。结合泛型,此功能允许将任何数据类型 T 转换为另一种与 JSON 格式兼容的类型 JSONCompatible<T>

type JSONCompatible<T> = unknown extends T ? never : {
    [P in keyof T]:
        T[P] extends JSONValue ? T[P] :
        T[P] extends NotAssignableToJson ? never :
        JSONCompatible<T[P]>;
};

type NotAssignableToJson =
    | bigint
    | symbol
    | Function;

JSONCompatible<T> 类型是一个映射类型,它检查给定类型 T 是否可以安全地序列化为 JSON。它通过遍历类型 T 中的每个属性并执行以下操作来实现:

  1. T[P] extends JSONValue ? T[P] : ... 条件类型会验证属性的类型是否与 JSONValue 类型兼容,确保它可以安全地转换为 JSON。在这种情况下,属性的类型保持不变。
  2. T[P] extends NotAssignableToJson ? never : ... 条件类型会验证属性的类型是否不可分配给 JSON。在这种情况下,属性的类型将被转换为 never,从而有效地将属性从最终类型中过滤掉。
  3. 如果这两个条件都不满足,则会递归检查类型,直到得出结论。这样,即使类型没有索引签名,它也能工作。

开头的 unknown extends T ? never :... 检查用于防止 unknown 类型被转换为空对象类型 {},这基本上等同于 any 类型。

另一个有趣的方面是 NotAssignableToJson 类型。它由两个 TypeScript 原语(bigint 和 symbol)以及 Function 类型组成,Function 类型描述了任何可能的函数。Function 类型在过滤掉任何不可分配给 JSON 的值方面至关重要。这是因为 JavaScript 中的任何复杂对象都基于 Object 类型,并且在其原型链中至少有一个函数(例如 toString())。JSONCompatible 类型会遍历所有这些函数,因此检查函数足以过滤掉任何无法序列化为 JSON 的内容。

现在,让我们在序列化函数中使用这个类型:

function safeJsonStringify<T>(data: JSONCompatible<T>) {
    return JSON.stringify(data);
}

现在,该函数使用泛型参数 T,并接受 JSONCompatible<T> 参数。这意味着它接受类型为 T 的参数 data,该参数应该是 JSON 兼容的类型。现在我们可以将函数用于没有索引签名的类型数据。

该函数现在使用泛型参数 T,它扩展自 JSONCompatible<T> 类型。这意味着它接受类型为 T 的参数 data,该参数应该是 JSON 兼容的类型。因此,我们可以将函数与缺少索引签名的类型数据一起使用。

interface PostComment {
  authorId: string;
  text: string;
  updatedAt: number;
}

function saveComment(comment: PostComment) {
    const serializedComment = safeJsonStringify(comment);
    localStorage.setItem('draft', serializedComment);
}

此方法可用于任何需要 JSON 序列化的场景,例如通过网络传输数据、在 HTML 中嵌入数据、将数据存储在 localStorage 中、在 Workers 之间传输数据等。此外,当需要将没有索引签名的严格类型化对象分配给 JSONValue 类型变量时,可以使用 toJsonValue 辅助函数。

function toJsonValue<T>(value: JSONCompatible<T>): JSONValue {
    return value;
}

const comment: PostComment = {...};

const data: JSONValue = {
    comment: toJsonValue(comment)
};

在此示例中,使用 toJsonValue 可以让我们绕过与 PostComment 类型中缺少索引签名相关的错误。

处理反序列化

在反序列化方面,挑战既简单又复杂,因为它同时涉及静态分析检查和对接收到的数据格式的运行时检查。

从 TypeScript 类型系统的角度来看,挑战非常简单。让我们看下面的例子:

function safeJsonParse(text: string) {
    return JSON.parse(text) as unknown;
}

const data = JSON.parse(text);
//    ^?  unknown 

在这种情况下,我们将 any 返回类型替换为 unknown 类型。为什么选择 unknown?本质上,JSON string 可能包含任何内容,而不仅仅是我们期望接收的数据。例如,不同应用程序版本之间的数据格式可能会发生变化,或者应用程序的另一部分可能会将数据写入同一个 LocalStorage 键。因此,unknown 是最安全、最精确的选择。

但是,处理 unknown 类型比仅指定所需数据类型不那么方便。除了类型转换之外,还有多种方法可以将 unknown 类型转换为所需的数据类型。其中一种方法是利用 Superstruct 库在运行时验证数据,并在数据无效时抛出详细错误。

import { create, object, number, string } from 'superstruct';

const PostComment = object({
    authorId: string(),
    text: string(),
    updatedAt: number(),
});

// Note: we no longer need to manually specify the return type
function restoreDraft() {
    const text = localStorage.getItem('draft');
    return text ? create(JSON.parse(text), PostComment) : undefined;
}

在这里,create 函数充当类型守卫,缩小类型到所需的 Comment 接口。因此,我们不再需要手动指定返回类型。

实现安全的反序列化选项只是故事的一半。同样重要的是,在处理项目中的下一个任务时不要忘记使用它。如果一个大型团队在项目上工作,这一点尤其具有挑战性,因为确保所有协议和最佳实践都得到遵循可能很困难。

Typescript-eslint 可以帮助完成此任务。此工具有助于识别所有不安全使用 any 的实例。具体来说,可以找到所有 JSON.parse 的用法,并确保已检查接收数据的格式。有关在代码库中摆脱 any 类型的更多信息,请阅读文章 Making TypeScript Truly "Strongly Typed"

结论

以下是旨在帮助实现安全 JSON 序列化和反序列化的最终实用函数和类型。您可以在准备好的 TS Playground 中进行测试。

type JSONPrimitive = string | number | boolean | null | undefined;

type JSONValue = JSONPrimitive | JSONValue[] | {
    [key: string]: JSONValue;
};

type NotAssignableToJson = 
    | bigint 
    | symbol 
    | Function;

type JSONCompatible<T> = unknown extends T ? never : {
    [P in keyof T]: 
        T[P] extends JSONValue ? T[P] : 
        T[P] extends NotAssignableToJson ? never : 
        JSONCompatible<T[P]>;
};

function toJsonValue<T>(value: JSONCompatible<T>): JSONValue {
    return value;
}

function safeJsonStringify<T>(data: JSONCompatible<T>) {
    return JSON.stringify(data);
}

function safeJsonParse(text: string): unknown {
    return JSON.parse(text);
}

这些可以在任何需要 JSON 序列化的场合使用。

我已经在我的项目中使用这种策略多年了,它通过在应用程序开发过程中及时检测潜在的错误而证明了其有效性。

希望本文能为您带来一些新的见解。感谢您的阅读!

有用链接

历史

  • 2024 年 2 月 26 日:初始版本
© . All rights reserved.