paint-brush
掌握 TypeScript 中类型安全的 JSON 序列化by@nodge
12,526
12,526

掌握 TypeScript 中类型安全的 JSON 序列化

Maksim Zemskov11m2024/02/26
Read on Terminal Reader

本文探讨了使用 JSON 格式时在 TypeScript 中进行数据序列化的挑战。它特别关注 JSON.stringify 和 JSON.parse 函数的缺点。为了解决这些问题,它建议使用 JSONCompatible 类型来验证类型 T 是否可以安全地序列化为 JSON。此外,它还推荐使用 Superstruct 库来安全地从 JSON 进行反序列化。此方法提高了类型安全性并支持开发过程中的错误检测。
featured image - 掌握 TypeScript 中类型安全的 JSON 序列化
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


几乎每个Web 应用程序都需要数据序列化。这种需求会在以下情况下出现:


  • 通过网络传输数据(例如 HTTP 请求、WebSockets)
  • 在 HTML 中嵌入数据(例如,用于水合作用)
  • 将数据存储在持久存储中(例如 LocalStorage)
  • 在进程之间共享数据(例如 Web Worker 或 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; }


这段代码有什么问题?第一个问题是,在恢复评论时,我们会得到一个string类型,而不是updatedAt字段的Date类型。


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


JSON.stringify遇到无法以 JSON 格式表示的值时,就会发生类型转换。对于Date对象,我们得到一个字符串,因为Date对象实现了toJson()方法,该方法返回一个字符串而不是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 行为既不方便又不安全。如果我们尝试将字符串视为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类型的函数参数,因为“‘PostComment’类型中缺少类型‘string’的索引签名”。


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


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


在本例中, [key: string]是索引签名。它的意思是“这个对象可以有任何字符串形式的键,其值具有JSONValue类型”。那么,我们需要为PostComment类型添加索引签名,对吗?


 interface PostComment { authorId: string; text: string; updatedAt: number; // Don't do this: [key: string]: JSONValue; };


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


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


 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 兼容的类型。现在我们可以使用没有索引签名的数据类型的函数。


该函数现在使用从JSONCompatible<T>类型扩展的通用参数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 中、在工作程序之间传输数据等,都可以使用此方法。此外,当严格toJsonValue对象没有需要将索引签名分配给JSONValue类型的变量。


 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


在本例中,我们用unknown类型替换any返回类型。为什么选择unknown ?本质上,JSON 字符串可以包含任何内容,而不仅仅是我们期望接收的数据。例如,数据格式可能会在不同的应用程序版本之间发生变化,或者应用程序的另一部分可能会将数据写入相同的 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类型的更多信息,请参阅《使 TypeScript 真正成为“强类型”》一文。


结论

以下是旨在帮助安全 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 序列化的情况。


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


我希望这篇文章能为您提供一些新的见解。感谢您的阅读!

有用的链接