第二篇:定制业务场景的类型守卫 —— 从 DeepReadonly 到运行时校验,根治数据污染
第二篇:定制业务场景的类型守卫 —— 从 DeepReadonly 到运行时校验,根治数据污染
为什么需要自定义类型守卫?
TypeScript 的内置类型(如 Readonly、Partial)只能处理一层结构。在 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 object对null会返回true。必须显式排除null,否则DeepReadonly<null>会错误地进入递归分支。
使用 ReadonlyArray 而非普通数组,可以彻底禁止 push、pop 等变异方法。
关于 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' };
务实的解决方案:
- 对象字面量直接传入:TypeScript 对对象字面量有多余属性检查
- 使用运行时校验库:
zod、io-ts、valibot等能同时提供类型推导和精确校验
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>类型并不完美,强烈推荐使用运行时校验库如zod或io-ts,它们能同时提供类型推导和精确校验。- 思考:如何实现一个
NonNullableProps<T>,递归排除 T 中所有null和undefined的属性(可选)?提示:使用映射类型和Exclude。