第二篇:定制业务场景的类型守卫 —— 从 DeepReadonly 到运行时校验,根治数据污染

第二篇:定制业务场景的类型守卫 —— 从 DeepReadonly 到运行时校验,根治数据污染

为什么需要自定义类型守卫?

TypeScript 的内置类型(如 ReadonlyPartial)只能处理一层结构。在 Redux/React 状态管理、配置对象深冻结、API 返回数据严格校验等场景下,我们需要深层的不可变类型、精确匹配类型等。同时,类型守卫(Type Guard)能让我们在运行时校验数据类型,弥补类型系统只存在于编译期的局限。

深度只读 DeepReadonly

手动递归处理每一层属性:

typescript
type DeepReadonly<T> = { readonly [P in keyof T]: T[P] extends object ? T[P] extends null ? T[P] // null 也是 object,需要特殊处理 : DeepReadonly<T[P]> : T[P]; }; // 更健壮的版本,同时处理数组和 null type DeepReadonly<T> = { readonly [P in keyof T]: T[P] extends (infer U)[] ? ReadonlyArray<DeepReadonly<U>> : T[P] extends object ? T[P] extends null ? null : DeepReadonly<T[P]> : T[P]; }; // 测试 interface Config { a: number; b: { c: string; d: number[] }; } const config: DeepReadonly<Config> = { a: 1, b: { c: 'hello', d: [1, 2] } }; // config.b.c = 'world'; // 报错:只读 // config.b.d.push(3); // 报错:ReadonlyArray 没有 push 方法

关键修正null 在 JavaScript 中 typeof null === 'object',所以 T[P] extends objectnull 会返回 true。必须显式排除 null,否则 DeepReadonly<null> 会错误地进入递归分支。

使用 ReadonlyArray 而非普通数组,可以彻底禁止 pushpop 等变异方法。

关于 Exact<T>:类型层面的局限

TypeScript 是结构类型系统,允许对象拥有未声明的属性。在某些场景(如表单提交、序列化)我们要求对象不能有多余字段。

类型层面无法完美实现 Exact<T>。以下尝试都有缺陷:

typescript
// 尝试1:利用 never 排除多余键,但无法处理嵌套对象 type ExactAttempt<T, U extends T> = U & Record<Exclude<keyof U, keyof T>, never>; // 尝试2:品牌标记,只是心理安慰 type BrandedExact<T> = T & { __exact: 'exact' };

务实的解决方案

  1. 对象字面量直接传入:TypeScript 对对象字面量有多余属性检查
  2. 使用运行时校验库zodio-tsvalibot 等能同时提供类型推导和精确校验
typescript
import { z } from 'zod'; const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().optional() }); // 精确匹配:多余字段会被 strip 或报错 type User = z.infer<typeof UserSchema>; const user = UserSchema.parse(unknownData); // 运行时校验 + 类型推导

实战:验证 API 返回数据的类型守卫

假设后端返回的数据可能不符合预期,我们需要在运行时进行校验,并让 TypeScript 知道类型已过滤。使用 is 关键字定义类型守卫:

typescript
interface User { id: number; name: string; email?: string; } function isUser(data: unknown): data is User { return ( typeof data === 'object' && data !== null && typeof (data as Record<string, unknown>).id === 'number' && typeof (data as Record<string, unknown>).name === 'string' && ((data as Record<string, unknown>).email === undefined || typeof (data as Record<string, unknown>).email === 'string') ); } // 使用 const raw: unknown = await fetchUser(); if (isUser(raw)) { console.log(raw.name.toUpperCase()); // raw 类型为 User }

结合 DeepReadonly,我们可以将校验后的数据转换为只读版本,防止后续意外修改:

typescript
function toDeepReadonly<T>(obj: T): DeepReadonly<T> { return obj as DeepReadonly<T>; } const safeUser = toDeepReadonly(raw);

高阶:利用 instanceof 和自定义类

当使用 class 时,instanceof 是天然的类型守卫。我们可以封装一个工厂:

typescript
class Validator<T> { constructor(private check: (data: unknown) => data is T) {} isValid(data: unknown): data is T { return this.check(data); } } const userValidator = new Validator<User>(isUser); if (userValidator.isValid(raw)) { /* raw 类型为 User */ }

总结与思考

  • DeepReadonly 在状态管理(如 Redux)中防止意外变更非常有价值,配合 immer 更佳。
  • 务必处理 null 的特殊情况,这是递归类型的常见陷阱。
  • 类型守卫是连接编译时和运行时的桥梁,尤其在处理 unknown 类型时,能避免大量断言。
  • Exact<T> 类型并不完美,强烈推荐使用运行时校验库如 zodio-ts,它们能同时提供类型推导和精确校验。
  • 思考:如何实现一个 NonNullableProps<T>,递归排除 T 中所有 nullundefined 的属性(可选)?提示:使用映射类型和 Exclude
返回知识中心