问题场景
线上一个搜索列表,用户反馈"点开详情页偶尔会白屏"。定位到代码:
ts
interface User {
id: number;
name: string;
avatar?: string; // 可能为 undefined
email?: string; // 可能为 undefined
metadata?: Record<string, unknown>;
}
// 从缓存中获取的用户列表
const cachedUsers: (User | null | undefined)[] = getFromCache();
// ❌ 自以为过滤干净了
const validUsers = cachedUsers.filter(Boolean);
validUsers.forEach(user => {
renderAvatar(user.avatar); // 🚨 user.avatar 类型还是 string | undefined
sendEmail(user.email); // 🚨 user.email 类型还是 string | undefined
});
filter(Boolean) 去掉了 null 和 undefined,但 TypeScript 不知道 ------在类型系统眼里,validUsers 仍然是 (User | null | undefined)[]。
更隐蔽的问题:user.avatar 的可选属性在过滤后并没有变确定,TypeScript 说它可能是 undefined,但你心里知道"既然有 user 对象,头像应该存在"------直到有一天某个用户真的没有上传头像,页面裂了。
类型守卫不是"可有可无的糖",它是防止运行时崩溃的第一道防线。
原因分析
TypeScript 的类型收窄(narrowing)不是万能的,它有以下几个"盲区":
| 写法 | TS 能否收窄 | 原因 |
|---|---|---|
arr.filter(Boolean) |
❌ | Boolean 作为回调,TS 不理解其类型影响 |
arr.filter(x => x !== null) |
❌ | 普通箭头函数不改变 filter 的类型泛参 |
if (user) { ... } |
✅ | 块级作用域内的类型守卫有效 |
if (typeof x === 'string') |
✅ | 内置类型守卫 |
if ('avatar' in user) |
✅ | in 操作符收窄 |
user satisfies User |
❌ | 仅检查,不收窄 |
问题核心:Array.prototype.filter 的 TypeScript 类型签名默认不会改变元素类型。 你要主动告诉它:"过滤之后,类型变了"。
解决方案:4 个级别的类型守卫
🟢 Level 1:基础版 --- 显式类型谓词
把 filter 的回调写成类型谓词(type predicate):
ts
// ❌ 普通回调
const valid = cachedUsers.filter(u => u !== null && u !== undefined);
// 类型仍然是 (User | null | undefined)[]
// ✅ 类型谓词
const valid = cachedUsers.filter(
(u): u is User => u !== null && u !== undefined
);
// 类型:User[] ✅
写法关键:返回值类型写 : u is User ,这告诉 TypeScript:"当这个函数返回 true 时,参数 u 就是 User 类型"。
🟡 Level 2:通用工具函数
如果项目里到处都要过滤 null/undefined,抽一个工具:
ts
// utils/type-guards.ts
function isNonNull<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
// 使用
const validUsers = cachedUsers.filter(isNonNull);
// validUsers: User[] ✅
const items: (string | null | undefined)[] = ['a', null, 'b', undefined];
const clean = items.filter(isNonNull);
// clean: string[] ✅
NonNullable<T> 是 TS 内置类型,自动去除 T 中的 null 和 undefined。
🟠 Level 3:自定义复杂类型守卫
真实场景:数组中有多种接口类型,需要区分对待。
ts
// 两种不同类型的响应
type SuccessResponse = { status: 'success'; data: { id: number; name: string } };
type ErrorResponse = { status: 'error'; error: { code: number; message: string } };
type ApiResponse = SuccessResponse | ErrorResponse;
const responses: ApiResponse[] = await Promise.allSettled(
urls.map(fetchData)
).then(r => r.filter(isFulfilled).map(r => r.value));
// ✅ 自定义类型守卫:只保留成功响应
const successes = responses.filter(
(r): r is SuccessResponse => r.status === 'success'
);
// successes: SuccessResponse[] ✅
// 每个元素都确定有 data.id, data.name
// ✅ 提取错误信息
const errors = responses.filter(
(r): r is ErrorResponse => r.status === 'error'
);
// errors: ErrorResponse[] ✅
这是 discriminated union(可辨识联合类型)的经典用法 ------利用 status 字段区分类型。
🔴 Level 4:异步类型守卫 + 多属性校验
最复杂的实战场景:通过网络请求校验后,把模糊类型收窄为精确类型。
ts
// 从未知来源的数据
const rawData: unknown = await fetchUserData();
// ✅ 自定义类型守卫函数
function isCompleteUser(obj: unknown): obj is Required<User> {
if (!obj || typeof obj !== 'object') return false;
const user = obj as Record<string, unknown>;
return (
typeof user.id === 'number' &&
typeof user.name === 'string' &&
typeof user.avatar === 'string' && // ✅ 排除了 undefined
typeof user.email === 'string' // ✅ 排除了 undefined
);
}
// 使用后,所有属性都是确定的 string/number,没有可选属性
if (isCompleteUser(rawData)) {
rawData.avatar; // string(不再是 string | undefined)
rawData.email; // string(不再是 string | undefined)
renderAvatar(rawData.avatar); // ✅ 安全
}
Required<T> 是 TS 内置工具类型,把 T 的所有可选属性变为必选。
实操代码:一个完整的实战案例
ts
// ===== 场景:用户搜索列表 + 详情展示 =====
interface RawUser {
id: number;
name: string;
email?: string;
phone?: string;
avatar?: string;
department?: string;
}
interface CompleteUser extends Required<RawUser> {
// 所有可选属性都变必选
}
interface DepartmentStats {
department: string;
count: number;
members: CompleteUser[];
}
// 🛡️ 类型守卫 1:用户是否完整
function isCompleteUser(user: RawUser): user is CompleteUser {
return (
typeof user.email === 'string' &&
typeof user.phone === 'string' &&
typeof user.avatar === 'string' &&
typeof user.department === 'string'
);
}
// 🛡️ 类型守卫 2:按部门分组
function groupByDepartment(
users: CompleteUser[]
): Map<string, DepartmentStats> {
const groups = new Map<string, CompleteUser[]>();
for (const user of users) {
const existing = groups.get(user.department) ?? [];
existing.push(user);
groups.set(user.department, existing);
}
// 转换为最终格式
const result = new Map<string, DepartmentStats>();
for (const [dept, members] of groups) {
result.set(dept, { department: dept, count: members.length, members });
}
return result;
}
// 🛡️ 类型守卫 3:部门统计数组
function processUsers(rawUsers: RawUser[]) {
// 第一步:排除 null/undefined
const definedUsers = rawUsers.filter(isNonNull);
// 第二步:只保留完整用户
const completeUsers = definedUsers.filter(isCompleteUser);
// 第三步:分组统计
const stats = groupByDepartment(completeUsers);
// 遍历时所有类型都是确定的 ✅
stats.forEach((stat) => {
console.log(`${stat.department}: ${stat.count}人`);
stat.members.forEach(m => renderAvatar(m.avatar)); // avatar: string ✅
});
return {
total: rawUsers.length,
complete: completeUsers.length,
incomplete: definedUsers.length - completeUsers.length,
departments: stats.size,
};
}
类型守卫让数据流中的每一步类型都精确匹配运行时实际值。
要点总结
| 级别 | 技术 | 场景 |
|---|---|---|
| Level 1 | u is Type 类型谓词 |
filter 过滤 null/undefined |
| Level 2 | isNonNull<T> 通用守卫 |
项目全局通用过滤 |
| Level 3 | discriminated union 守卫 | 多类型数组分类处理 |
| Level 4 | obj is Required<T> 守卫 |
未知数据源的类型校验 |
⚠️ 避坑指南
- 类型谓词函数不能写错逻辑 ------TS 不会验证守卫逻辑的正确性,
u is User全靠你保证返回值的准确性,写错了类型安全但运行时崩 - 不要对同一份数据多次守卫------优先合并条件,减少冗余判断
filter(Boolean)不是完全没用------它去掉了 falsy 值,但类型没有收窄,需要手动断言或配合守卫使用satisfies不改变类型 ------obj satisfies T只做校验,不会收窄类型,不要用它替代类型守卫
ts
// ❌ satisfies 不会收窄
const u = raw satisfies User;
u.avatar; // 仍然是 string | undefined
// ✅ 正确的收窄方式
if (isCompleteUser(raw)) {
raw.avatar; // string
}
一条经验原则
过滤数据之后,类型应该随之变化。 如果在 filter/map/find 之后你还在手动
as断言,说明类型守卫缺失------用u is T代替as T,让编译器为你兜底。