07 类型收窄、类型守卫与断言函数

对应项目:assertCustomerLoaded()、verify-token.service.ts。

1. TS 不会仅凭“我知道有值”相信你

function uppercase(value: string | null): string {
  return value.toUpperCase(); // 错误:可能为 null
}

写出真实检查后,TS 根据路径排除 null:

function uppercase(value: string | null): string {
  if (value === null) throw new Error("缺少字符串");
  return value.toUpperCase();
}

收窄(narrowing)指的是:在某条路径里,值的可能类型范围变小了。它不改变值,也不执行自动转换。

2. 常用检查,各适合什么?

写法 典型用途 注意
typeof value === "string" primitive 检查 不验证邮箱等业务格式
value !== null 排除 null 仍可能是 undefined
Array.isArray(value) 区分数组 不验证数组元素
"field" in value 判断对象有某属性 先排除 null,且会看原型链
error instanceof Error Error 实例 外部抛错可能不是 Error

typeof null === "object",数组的 typeof 也是 object,因此“是 object”并不意味着“是合法请求对象”。

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

这个检查只是让后续能安全检查字段,不承诺它是某个业务 DTO;例如 Date 也可能通过这类宽泛对象检查。

3. 自定义 value is T:判断后收窄调用者变量

interface CustomerSummary {
  customer_id: number;
  first_name: string;
}

function isCustomerSummary(value: unknown): value is CustomerSummary {
  if (!isRecord(value)) return false;
  return typeof value.customer_id === "number"
    && Number.isSafeInteger(value.customer_id)
    && value.customer_id > 0
    && typeof value.first_name === "string";
}

function getName(value: unknown): string {
  if (!isCustomerSummary(value)) throw new Error("数据结构不符合预期");
  return value.first_name;
}

value is CustomerSummary 是类型谓词。它告诉 TS:函数返回 true 时,你可以把参数视为 CustomerSummary。

编译器不会证明函数体真的检查完所有条件。若函数永远 return true,也可能被相信。所以谓词必须对应真实检查,重要边界应有测试。

额外字段不会被删除;名字长度、邮箱格式等规则也不会凭接口定义自动执行。

4. asserts:通过则继续,不通过就中断

function assertCustomerSummary(
  value: unknown,
): asserts value is CustomerSummary {
  if (!isCustomerSummary(value)) {
    throw new Error("缺少有效客户摘要");
  }
}

function getName(value: unknown): string {
  assertCustomerSummary(value);
  return value.first_name;
}

与类型守卫区别:

初学阶段用 function 声明写断言函数最直观。箭头形式的断言函数在某些用法下要求被调用名称有显式类型标注,否则可能出现 TS2775。

5. 当前完整地址关联如何表达?

import type Customer from "../../models/customer";
import type Address from "../../models/address";
import type City from "../../models/city";
import type Country from "../../models/country";

type LoadedCity = City & { country: Country };
type LoadedAddress = Address & { city: LoadedCity };
type LoadedCustomer = Customer & { address: LoadedAddress };

function assertCustomerLoaded(
  customer: Customer,
): asserts customer is LoadedCustomer {
  if (!customer.address?.city?.country) {
    throw new Error("查询缺少完整地址关联");
  }
}

mapper 调用检查后,就能访问 customer.address.city.country。这是将“这个查询必须加载完整关联”的契约集中在一处。

它只检查关联对象存在,不验证每个 SELECT 列、Date 类型或真实数据库字段。因此还要让 attributes/include 与 mapper 保持一致,并做查询/映射测试。

如果业务本来允许关联为空,则应该在 DTO 中表达 null,而不是硬断言完整。

6. as 与 !:不收集新的运行时证据

const loaded = customer as LoadedCustomer;
const address = customer.address!;

两者都只是让 TS 按你的承诺使用。没有 include 的实例仍不会突然有 address。

有价值的事实应尽量通过检查、受控查询封装或明确初始化流程表达。断言并非绝对禁止,但不能把断言当校验器。

7. 回调中为什么可能又说“属性不存在”?

function printEmail(customer: { email?: string }, numbers: number[]): void {
  if (customer.email !== undefined) {
    numbers.forEach(() => {
      console.log(customer.email.toLowerCase()); // 可能报错
    });
  }
}

customer 是可变对象,属性可能在回调运行前改变;TS 不一定把外面的属性收窄长期保留。

捕获已经检查过的值更明确:

function printEmail(customer: { email?: string }, numbers: number[]): void {
  const email = customer.email;
  if (email === undefined) return;
  numbers.forEach(() => console.log(email.toLowerCase()));
}

现代 TS 对部分变量在闭包中的收窄已有改进,不能说“进入任何回调都丢失收窄”。可变对象属性与稳定局部值仍应区分。

await 也不是“一定取消所有收窄”的语法;但其他代码可能在等待期间修改共享对象。对需要稳定使用的 primitive,局部捕获更容易说明事实。

8. filter(Boolean) 不总能获得你想要的类型

const values: Array<string | undefined> = ["ANN", undefined, ""];
const filtered = values.filter(Boolean);
// 通常仍是 (string | undefined)[],且运行时空字符串也被删除。

明确排除 undefined:

const filtered = values.filter(
  (value): value is string => value !== undefined,
);

现代 TS 对某些简单过滤表达式能自动推断类型谓词,但显式写法有助于复盘。不要让真假判断顺带删掉合法的 0、false 或空字符串。

9. 联合类型:用可区分字段组织结果

type ParseResult =
  | { ok: true; value: number }
  | { ok: false; message: string };

function formatResult(result: ParseResult): string {
  if (result.ok) return String(result.value);
  return result.message;
}

ok 字段有互斥字面量,TS 能确定各分支的字段。不是所有名为 code 的字段都具有这种性质;若另一分支 code 是任意 string,可能重叠,见 10 章。

10. 练习

  1. 写 isStatus 与 assertStatus,比较调用方式。
  2. 在断言函数中漏掉一个关联检查,解释为什么编译通过却仍可能崩溃。
  3. 将可变对象属性收窄改为局部捕获。
  4. 过滤 undefined,但保留空字符串,测试结果与类型。

下一章:泛型入门。参考:Narrowing。