TypeScript 类型守卫的 4 个实战级别:从 `filter(Boolean)` 到自定义类型谓词

问题场景

线上一个搜索列表,用户反馈"点开详情页偶尔会白屏"。定位到代码:

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) 去掉了 nullundefined,但 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 中的 nullundefined

🟠 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> 守卫 未知数据源的类型校验

⚠️ 避坑指南

  1. 类型谓词函数不能写错逻辑 ------TS 不会验证守卫逻辑的正确性,u is User 全靠你保证返回值的准确性,写错了类型安全但运行时崩
  2. 不要对同一份数据多次守卫------优先合并条件,减少冗余判断
  3. filter(Boolean) 不是完全没用------它去掉了 falsy 值,但类型没有收窄,需要手动断言或配合守卫使用
  4. 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,让编译器为你兜底。

相关推荐
云浪2 小时前
拆开 WAV,看见声音——JavaScript 二进制数组实战全解析
前端·javascript·ecmascript 6
_瑞2 小时前
深入理解灵动岛
前端·ios·app
想你依然心痛2 小时前
嵌入式日志系统:分级日志、环形缓冲区与远程输出——运行时调试、非侵入
android·前端·javascript
这是个栗子2 小时前
【Vue代码分析】 import.meta.env.DEV:现代前端工程中的环境变量
前端·javascript·vue.js·环境变量
像牛奶却不是牛奶2 小时前
记录几个前端实用小函数
前端·javascript
IDIOT___IDIOT2 小时前
Windows 解锁后 Chrome 弹出白屏的排查与修复
前端·chrome
tuddy7894643 小时前
前端应用的离线暂停更新策略:原理、实现与最佳实践
前端
hunterandroid3 小时前
多线程与线程池:从 Thread 到高效并发
前端
用户69371750013843 小时前
AI Agent 里的 Loop 到底是什么?
android·前端·后端