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;
}
与类型守卫区别:
is...返回 boolean,让调用者决定分支。assert...成功时正常继续,失败时抛错;之后调用者变量被收窄。
初学阶段用 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. 练习
- 写 isStatus 与 assertStatus,比较调用方式。
- 在断言函数中漏掉一个关联检查,解释为什么编译通过却仍可能崩溃。
- 将可变对象属性收窄改为局部捕获。
- 过滤 undefined,但保留空字符串,测试结果与类型。