拥抱 unknown,告别 any - TypeScript 类型安全最佳实践

TypeScript 的类型系统中,unknown 类型被誉为比 any 更安全的顶级类型。它的设计初衷是为了处理那些在编译时无法确定具体类型的数据,例如:

  • 网络 API 的响应数据
  • 部分第三方库的返回值
  • JSON.parse() 解析的结果
  • 用户输入或外部数据源

any 类型允许任意操作不同,unknown 强制开发者在使用其值之前进行类型检查和验证。这种设计哲学体现了 TypeScript 的核心价值:在编译时尽可能多地捕获潜在错误,同时保持运行时的类型安全。

为什么 unknown 优于 any

unknown 的核心优势在于其强制类型检查机制

  • 编译时保护:任何对 unknown 值的操作都需要先进行类型收窄
  • 运行时安全:避免了因类型假设错误而导致的运行时异常
  • 代码可维护性:明确表达了”此处类型不确定”的语义,提醒后续维护者注意

然而,在实际开发中,许多开发者面对 unknown 时会采用一些反模式,这些做法不仅削弱了类型安全性,还可能引入潜在的运行时风险。

反模式:危险的类型断言

问题分析

最常见但最危险的处理方式是使用类型断言(as)绕过类型检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 假设这是来自 API 的响应数据
const apiResponse: unknown = { id: 123, name: "Alice" };

// ❌ 反模式:直接进行类型断言
const user = apiResponse as { id: number; name: string };

// 表面上看起来正常
console.log(user.name); // "Alice"

// 但如果实际数据结构不符预期...
const malformedResponse: unknown = { id: 123, username: "Alice" }; // 注意:是 username 而不是 name
const malformedUser = malformedResponse as { id: number; name: string };

console.log(malformedUser.name); // undefined - 潜在的运行时错误!
// 后续操作可能导致 TypeError: Cannot read property of undefined

根本问题

类型断言本质上是在告诉 TypeScript 编译器:”相信我,我知道这个值的确切类型”。这种做法:

  1. 绕过了编译时检查TypeScript 不再验证类型的正确性
  2. 将风险转移到运行时:一旦数据结构不符合断言,程序就会出现不可预测的行为
  3. 失去了使用 unknown 的初衷:本质上和使用 any 没有区别

最佳实践:运行时验证与类型收窄

要安全地处理 unknown 类型,我们需要在运行时验证数据的实际结构,并基于验证结果进行类型收窄。以下介绍两种业界推荐的工具和方法。

方案一:使用 Zod 进行 Schema 验证

Zod 是一个功能强大的 TypeScript 优先的模式验证库,它提供了声明式的 Schema 定义运行时验证的双重能力。

核心优势

  • 类型安全的 Schema 定义Schema 本身就是类型信息的来源
  • 自动类型推断:验证通过后自动推断出精确的 TypeScript 类型
  • 详细的错误报告:提供可读性强的验证失败信息
  • 组合式设计:支持复杂数据结构的嵌套验证

实践示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { z } from 'zod';

// 1. 定义数据的 Schema
const UserSchema = z.object({
id: z.number().positive(), // 正整数
name: z.string().min(1), // 非空字符串
email: z.string().email(), // 有效的邮箱格式
age: z.number().int().min(0).max(120).optional(), // 可选的年龄字段
});

// 2. 自动推断类型(无需手动定义接口)
type User = z.infer<typeof UserSchema>;
// 等价于:
// type User = {
// id: number;
// name: string;
// email: string;
// age?: number;
// }

// 3. 处理 unknown 数据
const processApiResponse = (rawData: unknown) => {
try {
// 使用 parse() 进行严格验证
const user = UserSchema.parse(rawData);

// ✅ 此时 user 的类型已被安全地推断为 User
console.log(`用户 ${user.name} 的邮箱是 ${user.email}`);
return { success: true, data: user };
} catch (error) {
// Zod 提供详细的错误信息
if (error instanceof z.ZodError) {
console.error("数据验证失败:", error.issues);
return { success: false, errors: error.issues };
}
throw error;
}
};

// 4. 非抛出式验证
const safeParsing = (rawData: unknown) => {
const result = UserSchema.safeParse(rawData);

if (result.success) {
// ✅ result.data 的类型为 User
return result.data;
} else {
// ❌ result.error 包含验证错误详情
console.error("解析失败:", result.error.format());
return null;
}
};

方案二:使用 ts-pattern 进行模式匹配

ts-pattern 提供了函数式编程风格的模式匹配能力,特别适合处理**辨识联合类型(Discriminated Union)**的场景。

核心特性

  • 声明式模式匹配:类似于函数式语言的 match 表达式
  • 编译时完整性检查:确保所有可能的模式都被处理
  • 类型自动收窄:在每个匹配分支中自动推断精确类型
  • Exhaustive 检查:防止遗漏任何情况

实践示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { match, P } from 'ts-pattern';

// 定义 API 响应的联合类型
type APIResult =
| { status: 'success'; data: { users: User[]; total: number } }
| { status: 'error'; code: 'NETWORK_ERROR' | 'VALIDATION_ERROR'; message: string }
| { status: 'loading' }
| { status: 'empty' };

// 处理 unknown 类型的 API 响应
const handleAPIResponse = (response: unknown) => {
return match(response)
.with({ status: 'success' }, (res) => {
// ✅ res 的类型被自动收窄为 success 分支的类型
console.log(`成功获取 ${res.data.total} 条用户数据`);
return res.data.users;
})
.with({ status: 'error' }, (res) => {
// ✅ res 的类型被自动收窄为 error 分支的类型
console.error(`请求失败: ${res.code} - ${res.message}`);
throw new Error(res.message);
})
.with({ status: 'loading' }, () => {
console.log("数据加载中...");
return null;
})
.with({ status: 'empty' }, () => {
console.log("暂无数据");
return [];
})
.exhaustive(); // 编译时检查:确保所有情况都被覆盖
};

实际应用场景

场景一:API 响应处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 使用 Zod 处理 REST API 响应
const fetchUserProfile = async (userId: string): Promise<User | null> => {
try {
const response = await fetch(`/api/users/${userId}`);
const rawData: unknown = await response.json();

// 验证响应数据结构
const user = UserSchema.parse(rawData);
return user;
} catch (error) {
if (error instanceof z.ZodError) {
console.error("API 响应格式不正确:", error.issues);
} else {
console.error("网络请求失败:", error);
}
return null;
}
};

场景二:WebSocket 消息处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用 ts-pattern 处理 WebSocket 消息
type WebSocketMessage =
| { type: 'chat'; content: string; sender: string }
| { type: 'notification'; title: string; body: string }
| { type: 'system'; action: 'connect' | 'disconnect'; userId: string };

const handleWebSocketMessage = (rawMessage: unknown) => {
match(rawMessage)
.with({ type: 'chat' }, (msg) => {
displayChatMessage(msg.sender, msg.content);
})
.with({ type: 'notification' }, (msg) => {
showNotification(msg.title, msg.body);
})
.with({ type: 'system', action: 'connect' }, (msg) => {
console.log(`用户 ${msg.userId} 已连接`);
})
.with({ type: 'system', action: 'disconnect' }, (msg) => {
console.log(`用户 ${msg.userId} 已断开连接`);
})
.otherwise((msg) => {
console.warn("未知的消息格式:", msg);
})
.exhaustive();
};

更具体的可看我的另一篇文章:告别 if/else 和 switch:模式匹配在 TypeScript 中的新范式

工具选择指南

场景 推荐工具 原因
API 响应验证 Zod 提供详细的验证错误,支持复杂嵌套结构
联合类型处理 ts-pattern 声明式语法,编译时完整性检查

总结

unknown 类型的引入标志着 TypeScript 对类型安全性的进一步重视。它提醒我们:真实世界中的数据是不可预测的,我们需要在运行时验证这些数据的结构和内容

通过采用正确的处理方式:

  1. 拒绝类型断言的诱惑:不要用 as 绕过类型检查
  2. 拥抱运行时验证:使用 Zod 等工具确保数据结构的正确性
  3. 善用模式匹配:通过 ts-pattern 优雅地处理复杂的联合类型
  4. 建立防御性编程思维:始终假设外部数据可能不符合预期

这些实践不仅能够显著提高代码的健壮性和可维护性,还能帮助我们构建出真正类型安全的 TypeScript 应用程序。

PS类型安全不是编译时的一次性检查,而是贯穿整个应用生命周期的持续保障。只有将编译时检查与运行时验证相结合,我们才能充分发挥 TypeScript 的威力,构建出可靠、安全的现代应用。