从 localStorage 检索对象的类型定义
摘要
本文介绍了一种 TypeScript 解决方案,通过在自定义 Db 类中使用映射函数,在从 localStorage 检索对象时保留对象方法。
<p><a href="https://lobste.rs/s/aofhzo/type_definitions_retrieve_objects_from">评论</a></p>
查看缓存全文
缓存时间: 2026/07/02 10:10
# 从 localStorage 中检索对象的类型定义
来源:https://taxorubio.com/blog/type-definitions-to-retrieve-objects-from-localstorage/
如果你曾经把一个对象写入 `localStorage`,很可能遇到过这样的糟糕体验:读取回来时并没有直接返回一个带有其关联方法的完整对象。和所有 bug 一样,你发誓不会再犯,但我们在同一块石头上绊倒过多少次呢?最近我决定:上次的 bug 发生一次就够了,我要一劳永逸地解决这个问题。让我带你走一趟类型操作的奇妙之旅……
## 最初的问题
我有一个 `Db` 类,它是一个与 `Storage` 接口交互的简单控制器,并使用 JSON 格式存储数据:
```typescript
export class Db<T> {
private db: Storage;
constructor(db: Storage) {
this.db = db;
}
public get(key: string, mapFn: DbMapFn<T> | null = null): T {
const valueStr = this.db.getItem(key);
if (valueStr == null) {
throw new Error(`Could not get value from key "${key}"!`);
}
return JSON.parse(valueStr);
}
public set(key: string, value: T): void {
this.db.setItem(key, JSON.stringify(value));
}
// ...还有其他好东西!
}
```
看上去很简单,但它有一个一开始并不明显的致命缺陷。我们用一个简短的代码示例来重现:
```typescript
class Widget {
constructor(public readonly value: number) { }
public isEven(): boolean {
return this.value % 2 == 0;
}
}
(() => {
const db = new Db(localStorage);
const widget = new Widget(10);
console.log(widget.isEven()); // true,符合预期
db.set("widget", widget);
const widget2 = db.get("widget");
console.log(widget2.isEven()); // ERROR: widget2.isEven is not a function
})()
```
看起来像是编译器 bug,但并不是!问题在于 `JSON.parse` 返回 `any`,所以类型检查器会欣然将其转换为任何其他类型,并相信我们这些“成年人”知道自己在做什么。愚蠢的编译器,我连晚饭吃什么都不知道呢!
现在,我们得到的不是一个 `Widget` 对象,而是一个包含 `Widget` 属性但没有其方法的对象。我们仍然可以用 `widget2.value % 2` 手动测试奇偶性,但你我清楚,这在代码审查中通不过。
现在的问题是,我们需要将存储中取出的值映射回一个 `Widget` 对象,但我们又不希望这个操作变成繁文缛节,因为大量“异教徒”会定期造访我们的代码库。也就是说,下面的做法行不通:
```typescript
const widget2Data = db.get<{ value: number }>("widget");
const widget2 = new Widget(widget2Data.value);
console.log(widget2.isEven()); // true,但代价是什么?
```
重复声明每个属性及其类型很快就会让人厌烦。我们来看看一个初步的解决方案。
## 映射存储的返回值
确实需要一些仪式,但我们希望数据库 API 能让我们轻松做到。我们不必信任用户会转换所有对象,而是可以扩展 `get` 函数,让它接收一个映射策略:
```typescript
type DbMapFn<T> = (obj: T) => T;
class Db<T> {
// ...
public get(key: string, mapFn: DbMapFn<T> | null = null): T {
const valueStr = this.db.getItem(key);
if (valueStr == null) {
throw new Error(`Could not get value from key "${key}"!`);
}
const value: T = JSON.parse(valueStr);
return (mapFn != null) ? mapFn(value) : value;
}
}
```
这解决了我们的问题!差不多,但还不完全正确。它在运行时可以工作,但类型最多只是不稳定的。不过思路已经有了:当我们接收到 `mapFn` 参数时,可以用它根据用户提供的指令构建目标对象。对于我们的 `Widget`,是这样的:
```typescript
const widget2 = db.get("widget", obj => new Widget(obj.value));
console.log(widget2.isEven()); // true,但我还不满意
```
这里的问题是,编译器将 `obj` 视为 `Widget` 类型,但此时它还是一个没有方法的对象,因此不知情的用户会困惑于我们似乎重新创建了 `Widget` 对象,并可能尝试调用某些函数导致程序崩溃。我们可以将解析后的类型转换为 `any`,并相信用户能正确获取属性名,但我们不想信任用户!未来的自己很可能就是那个用户,而我不信任那个家伙能记得一周前写的东西!那么如何解决这个难题呢?如承诺的那样,我们来玩玩类型吧!
## 玩玩类型
我们想要的是:一个表示我们 `Widget`(或任何其他对象)刚刚反序列化后状态的类型。也就是说,去掉方法,只保留对公共数据属性的访问。事实上,在撰写本文时,在映射类型时无法访问私有属性(https://github.com/microsoft/TypeScript/issues/22677),保持这样可能是个好主意。
我将展示完整的类型转换,并逐步讲解:
```typescript
type NonMethodPropertiesOf<T> = {
[K in keyof T]: T[K] extends Function ? never : K
}[keyof T];
```
这是一个泛型类型映射,它映射泛型类型。意思是它接受一个类型 `T`,对其每个属性应用转换(映射),并用它们构造一个新类型 `NonMethodPropertiesOf<T>`。类型语法中最明显的部分是泛型 `<T>`,意味着我们可以将这个映射应用于任何类型。
接下来是 `keyof T`,你会看到它出现了两次。这是一个 TypeScript 操作符(https://www.typescriptlang.org/docs/handbook/2/keyof-types.html),它从我们泛型 `T` 的键(属性名称)创建联合类型(https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types)。举个例子,取这个简单的类:
```typescript
class XYZTuple {
constructor(
public x: number,
public y: string,
public z: boolean
) {}
}
```
这里,`keyof XYZTuple` 是 `"x" | "y" | "z"`,因为我们可以用这三个字符串索引这个对象。然后我们将 `keyof T` 传递给映射体,告诉它要迭代哪些键。
知道了这一点,我们可以这样理解映射体的内容:*对于 `T` 中的每个属性 `K`,仅当它的类型不是函数式的时候才保留它。* 第一个 `[K in keyof T]` 显式地表明我们正在检查某些泛型名称 `K` 的键,并预期它们是 `keyof T` 的一部分。因此,`T[K]` 是 `T` 中成员 `K` 的类型。
对于 `XYZTuple`,当映射处理 `x` 时 `T[K]` 是 `number`,处理 `y` 时是 `string`,处理 `z` 时是 `boolean`。为了转换类型,我们询问它是否 `extends Function`,即它是否是函数式类型。如果是,我们想要剔除它,所以将其转换为 `never`(一种无法实例化的类型)。否则,保持原样。
这看起来应该足够了,但这个映射只是列出了 `T` 中非函数的属性,并没有创建一个新类型。为此,我们需要应用转换:
```typescript
type WithoutMethods<T> = Pick<T, NonMethodPropertiesOf<T>>;
```
`Pick`(https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys)类型从 `T` 中创建一个子集类型,该子集只包含联合类型 `U` 中列出的属性。事实证明,`NonMethodPropertiesOf<T>` 返回的正是联合类型,在它列出 `T` 的所有非函数属性后,我们使用 `Pick` 通过只包含这些属性来应用转换。
然而,这里有个小问题。如果 `T` 是数组呢?我们从数组中移除了方法属性,而不是从它包含的类型中移除。这显然不是我们想要的,所以需要更新我们的转换器:
```typescript
type WithoutMethods<T> = T extends any[]
? Pick<T[0], NonMethodPropertiesOf<T[0]>>[]
: Pick<T, NonMethodPropertiesOf<T>>;
```
这就对了!现在,如果 `T` 是数组(即它 `extends any[]`),我们将把它映射为一个不带方法的类型数组。这里,`T[0]` 检索的是数组第一个元素的类型。请记住,我们不是在访问元素,而是在询问表达式 `T[0]` 的类型,也就是 `T` 的底层类型。
这样,我们终于可以更新数据库驱动类,让它与这个新类型一起工作:
```typescript
type DbMapFn<T> = (obj: WithoutMethods<T>) => T;
class Db<T> {
// ...
public get(key: string, mapFn: DbMapFn<T> | null = null): T {
const valueStr = this.db.getItem(key);
if (valueStr == null) {
throw new Error(`Could not get value from key "${key}"!`);
}
const value: WithoutMethods<T> = JSON.parse(valueStr);
return (mapFn != null) ? mapFn(value) : value as T;
}
}
```
现在,我们的用户可以清楚地看到发生了什么,因为他们无法再访问 `T` 的方法。他们还会在 `mapFn` 函数签名中看到 `WithoutMethods<T>` 类型,进一步提示他们可以做什么,有限制。注意,如果没有收到 `mapFn`,我们假设返回的对象不需要映射就能用它的方法构建。对于基本类型(如 `number` 或 `string[]`)来说就是如此,它们的方法是 JavaScript 核心引擎的一部分。
## 限制
这种技术有两个你必须知道的限制。首先,如前所述,它不适用于私有属性,因为 `keyof T` 不会列出它们。另一个则更微妙一些。TypeScript 将 getter 视为数据属性。这意味着以下代码会崩溃:
```typescript
class WithGet {
constructor (public x_: number) { }
get x(): number {
return this.x_;
}
}
// ERROR: obj.x is not a function!
db.get("with-get", obj => new WithGet(obj.x));
```
当你将它与存储在数据库中的不可变数据对象一起使用时,这种模式效果最好,因为它们几乎不携带私有类型,使得 getter 函数对它们的用例无关紧要。
## 结尾……我们完成了!
我喜欢用类型系统让编译器强制执行那些我只能通过注释和部落知识来表达的限制。这是一个很好的例子,说明极少的管道工作就能让我们的代码更可靠、更易于扩展。希望你喜欢这次关于映射类型的小小漫谈,并准备好将其应用在你的项目中!
相似文章
将引用稳定性变为一种类型
一篇关于使用幻影品牌将引用稳定性编码为 TypeScript 类型的博客文章,从而使 React props 能够强制要求数组、回调函数和对象在多次渲染中保持稳定。
Eidentic
Eidentic 是一个用于构建具有自我改进记忆能力的 AI 智能体的 TypeScript SDK。
TypeScript 如何分配联合类型
一篇深度文章,解释 TypeScript 如何在重载函数、方法接收者和条件类型中分配联合类型,并包含示例和解决方法。
解析,而非验证——在并不鼓励你这样做的语言中
一篇探讨在TypeScript中应用“解析,而非验证”原则的博客文章,展示了如何使用品牌类型(branded types)在解析后保留类型信息,尽管TypeScript的结构类型系统使得这种做法不如在Elm或Haskell等语言中那样自然。
microsoft/TypeScript
TypeScript 是一种用于大规模 JavaScript 应用的语言,它增加了可选类型。该仓库托管了 TypeScript 编译器及相关工具。