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,让编译器为你兜底。

相关推荐
To_OC18 小时前
写了三遍 Todo List,我终于搞懂了 React 父子组件到底怎么通信
前端·javascript·react.js
勇往直前plus19 小时前
Vue3(篇一) 核心概念——响应式模板语法与组件基础
前端·javascript·vue.js
咩咩啃树皮19 小时前
第43篇:Vue3计算属性(computed)完全精讲——缓存机制、依赖计算、业务最优解
前端·vue.js·缓存
徐小夕21 小时前
花了一周,3亿tokens,我开源了一款 Word 文档智能审查平台,文稿自动质检+可视化分析,告别低效人工审核
前端·算法·github
kyriewen1 天前
别再乱用useEffect了——你写的10个里有8个不该存在
前端·javascript·react.js
Ivanqhz1 天前
Rust &‘static str浅析
java·前端·javascript·rust
IT_陈寒1 天前
SpringBoot这个分页坑,我踩了三天才爬出来
前端·人工智能·后端
颜酱1 天前
05 | 召回前置准备:根据业务数据库生成各数据库(读取配置阶段)
前端·人工智能·后端
zandy10111 天前
衡石 Agentic BI的ReAct 推理框架在 Agentic BI 中的工程化实践
前端·javascript·react.js
罗超驿1 天前
JavaEE进阶之路:从Web架构原理到HTML标签全解析
前端·html·web·javaee