TypeScript 类型守卫与类型断言 — 安全的类型收窄术,一次讲透
联合类型声明了"我可能是 A 也可能是 B",但真正使用值的时候,TS 依然要求你证明"它现在到底是哪一个"。类型守卫(Type Guard)就是向编译器提供证据的手段;类型断言(Type Assertion)则是你拍胸脯担保的手段。一个是"讲道理",一个是"我说的"。今天我们把这个分寸感彻底建立起来。
目录
八、断言函数:
asserts x is T
一、为什么需要类型收窄?
1.1 问题现场
第 3 天我们写过联合类型,但声明联合只是开始,使用联合才是挑战:
// 设备编号可能是旧系统的数字,也可能是新系统的字符串
type DeviceId = string | number;
function printDeviceId(id: DeviceId) {
// ❌ 报错:number 上没有 toUpperCase
console.log(id.toUpperCase());
// ❌ 报错:string 上没有 toFixed
console.log(id.toFixed(2));
}
TS 报错的逻辑非常严谨:
id 的类型是 string | number
├── 如果此刻是 string → toUpperCase() 存在,toFixed() 不存在
└── 如果此刻是 number → toFixed() 存在,toUpperCase() 不存在
编译器无法确定"此刻"是哪个 → 只允许访问两者的【公共成员】
公共成员 = string 和 number 都有的方法 ≈ 几乎没有
1.2 收窄:给编译器提供证据
类型收窄(Narrowing) = 通过某种检查,让 TS 在特定代码块内把联合类型"缩小"为其中一个成员:
function printDeviceId(id: DeviceId) {
if (typeof id === "string") {
// ✅ 在这个分支里,TS 确定 id 是 string
console.log(id.toUpperCase()); // "CNC-001"
} else {
// ✅ 在这个分支里,TS 确定 id 是 number(排除了 string)
console.log(id.toFixed(2)); // "1001.00"
}
}
收窄的完整生命周期:
声明:id: string | number (宽)
↓ typeof id === "string" (证据)
分支内:id: string (窄)
↓ 离开 if 分支
恢复:id: string | number (宽,回到原点)
⭐ 核心心法:TS 的类型标注是静态的(编译期),而值的实际类型是动态的(运行时)。类型守卫就是用运行时可执行的检查,向编译器证明静态类型在当前代码块内可以缩小。守卫代码会被编译进 JS 真实运行——它不是写给编译器看的注释,而是真正生效的运行时检查。
1.3 收窄手段全家福(今天的主角们)
二、typeof 守卫:基础类型判断
2.1 基本用法
typeof 守卫是频率最高的收窄手段,用于 JS 的基础类型:
function formatValue(value: string | number) {
if (typeof value === "string") {
return value.trim(); // ✅ 此处是 string
}
return value.toFixed(2); // ✅ 此处是 number
}
typeof 能判断的类型字符串(只有这 8 个,全小写):
"string" | "number" | "boolean" | "bigint"
| "symbol" | "undefined" | "object" | "function"
2.2 ⚠️ typeof 的两个经典陷阱
陷阱 1:typeof null === "object"(JS 历史遗留 bug)
function process(input: string | null) {
// 想排除 null?❌ typeof null 是 "object",这个分支判断错了!
if (typeof input === "object") {
// 这里 input 是 never,编译器知道进不来
}
// ✅ 正确做法:直接用 null 判断(见"相等收窄")
if (input === null) return;
console.log(input.toUpperCase()); // ✅ 已收窄为 string
}
陷阱 2:typeof 区分不了"哪种对象"
const device = { temp: 65 };
const devices = [1, 2, 3];
const date = new Date();
console.log(typeof device); // "object"
console.log(typeof devices); // "object" ⚠ 数组也是 object
console.log(typeof date); // "object" ⚠ Date 也是 object
// typeof 无法区分普通对象/数组/Date —— 需要其他守卫:
Array.isArray(devices); // true ✅ 数组专用守卫
date instanceof Date; // true ✅ instanceof 登场
2.3 typeof 守卫的工业场景:配置参数兼容
/**
* 设置轮询间隔:兼容旧版传秒数、新版传毫秒数
*/
function setIntervalTime(time: number | string): number {
if (typeof time === "string") {
// 字符串配置:如 "5s"、"3000ms"
const num = parseInt(time, 10);
return time.endsWith("ms") ? num : num * 1000;
}
// 数字配置:直接就是毫秒
return time;
}
setIntervalTime("5s"); // 5000
setIntervalTime(3000); // 3000
setIntervalTime("3000ms"); // 3000
2.4 练习
// 练习 1:实现 formatMetric(value: string | number, unit: string)
// string → 原样返回 + 单位;number → 保留 1 位小数 + 单位
// 练习 2:实现 parseId(id: string | number | boolean)
// string → 去空格;number → 补零到 6 位;boolean → 编译器应阻止传入
三、truthiness 守卫与相等收窄
3.1 truthiness(真值)守卫
JS 中每个值都有真/假属性。if (x) 会自动排除所有 falsy 值:
// Falsy 六兄弟:false、0、""、null、undefined、NaN
function greet(name: string | null | undefined) {
if (name) {
// ✅ 收窄为 string(null/undefined 被排除)
console.log(`你好,${name}`);
} else {
console.log("你好,访客");
}
}
⚠️ truthiness 的坑:0 和 “” 是有效值时
// 温度 0°C 是有效数据!
function displayTemp(temp: number | null) {
if (temp) {
// ❌ temp 为 0 时走不进来 —— 0°C 被当成"没数据"了!
console.log(`温度:${temp}°C`);
}
}
// ✅ 正确:显式排除 null
function displayTemp2(temp: number | null) {
if (temp !== null) {
console.log(`温度:${temp}°C`); // 0 也能正常显示
}
}
这正是第 13 周 JS 学习中
||vs??陷阱的 TS 版本——同一个思想在两种语言形态下反复出现:0 和 “” 是有效值时,永远显式判断 null/undefined。
3.2 相等收窄(Equality Narrowing)
=== / !== 与具体值比较时,TS 会收窄类型:
interface Success { status: "success"; data: Device[] }
interface Failure { status: "error"; message: string }
function handle(res: Success | Failure) {
if (res.status === "success") {
// ✅ 收窄为 Success
console.log(res.data.length);
} else {
// ✅ 收窄为 Failure
console.error(res.message);
}
}
也可以与 null/undefined 比较:
function printId(id: string | null) {
if (id !== null) {
console.log(id.toUpperCase()); // ✅ string
}
}
// 简写:检查不等于 null 和 undefined 两个
function printId2(id: string | null | undefined) {
if (id != null) { // ⚠ 宽松不等:同时排除 null 和 undefined
console.log(id.toUpperCase()); // ✅ string
}
}
// 注意:这里用 != 不是 ==,是社区约定俗成的"非空检查"惯用法
// 与我们"永远用 ==="的铁律不冲突——它只用于 null/undefined 联合检查
3.3 switch 也触发相等收窄
type Level = "info" | "warn" | "error";
function getColor(level: Level): string {
switch (level) {
case "info": return "#2196f3"; // ✅ 此处 level 是 "info"
case "warn": return "#ff9800"; // ✅ 此处 level 是 "warn"
case "error": return "#f44336"; // ✅ 此处 level 是 "error"
}
}
3.4 练习
// 练习 1:实现 getAlertText(level: "info" | "warn" | "error" | null)
// null → "无告警";其余返回对应中文(提示/警告/严重)
// 练习 2:实现 safeTrim(str: string | null | undefined): string
// null/undefined → "";有值 → trim 后返回
四、in 守卫:属性探测
4.1 基本用法
"属性名" in 对象 检查属性是否存在,TS 据此收窄:
// CNC 有主轴转速,AGV 有电量 —— 结构不同的设备
interface CNC {
id: string;
spindleSpeed: number; // 特有
}
interface AGV {
id: string;
battery: number; // 特有
position: string; // 特有
}
function getDeviceDesc(device: CNC | AGV): string {
if ("spindleSpeed" in device) {
// ✅ 有 spindleSpeed 属性 → 收窄为 CNC
return `主轴转速 ${device.spindleSpeed}rpm`;
}
// ✅ 否则是 AGV
return `电量 ${device.battery}%,位于 ${device.position}`;
}
4.2 in 守卫的适用条件
// in 守卫有效的条件:属性是一方的【特有属性】
// 如果两边都有这个属性 → 无法收窄(收窄没意义)
interface A { x: number; y: number }
interface B { x: number; y: number; z: number }
function f(v: A | B) {
if ("z" in v) {
console.log(v.z); // ✅ B(z 是 B 特有)
} else {
console.log(v.x); // ✅ A
}
if ("x" in v) {
// ⚠ x 两边都有 → 不收窄,v 还是 A | B(但也不报错)
}
}
4.3 可选属性的坑
interface Config {
url: string;
token?: string; // 可选属性
}
function f(c: Config) {
if ("token" in c) {
// ⚠ 注意:token?: string 的类型是 string | undefined
// 即使属性存在,值也可能是 undefined(显式传了 token: undefined)
// 但正常情况下,in 检查后用起来是安全的:
console.log(c.token?.length); // ✅ 仍然要 ?. 兜底
}
}
4.4 in 守卫 vs 判别联合的选择
// 方式 1:in 守卫(依赖结构差异)
// 适合:第三方类型、无法修改的类型定义
// 方式 2:判别联合(依赖 kind/type 字段)—— 更推荐!
type Device =
| { kind: "cnc"; id: string; spindleSpeed: number }
| { kind: "agv"; id: string; battery: number };
function desc(d: Device) {
switch (d.kind) { // ⭐ 显式标签比属性探测更清晰、更不容易误判
case "cnc": return `CNC ${d.spindleSpeed}`;
case "agv": return `AGV ${d.battery}%`;
}
}
选择原则:自己定义类型 → 用判别联合(加 kind 字段);使用别人的类型 → 用 in 守卫。
4.5 练习
// 练习:定义 LineAlert { deviceId, line, message } 和 AreaAlert { deviceId, area, level }
// 实现 getAlertLocation(alert: LineAlert | AreaAlert)
// LineAlert → "产线 X 区";AreaAlert → "区域 Y(level 级)"
五、instanceof 守卫:类实例判断
5.1 基本用法
instanceof 检查值是否是某个类的实例(沿原型链查找——正是第 10 周/第 26 天学的原型链知识!):
class Device {
constructor(public id: string, public temp: number) {}
getLevel(): string {
return this.temp >= 80 ? "危险" : "正常";
}
}
class MockDevice extends Device {
constructor(id: string, temp: number, public lag: number) {
super(id, temp);
}
getLevel(): string {
return `${super.getLevel()}(模拟,延迟 ${this.lag}ms)`;
}
}
function showLevel(device: Device | MockDevice) {
// ⚠ 实际上 MockDevice 是 Device 的子类,收窄意义不大,举例用
if (device instanceof MockDevice) {
console.log(device.lag); // ✅ MockDevice 特有属性
}
}
5.2 更实用的场景:内置类判断
function parseTime(value: string | Date | number): string {
if (value instanceof Date) {
// ✅ Date 实例
return value.toLocaleTimeString("zh-CN");
}
if (typeof value === "number") {
// ✅ 时间戳
return new Date(value).toLocaleTimeString("zh-CN");
}
// ✅ 字符串
return new Date(value).toLocaleTimeString("zh-CN");
}
// 错误处理中的经典用法
function getErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message; // ✅ 收窄为 Error,能安全访问 message
}
return String(err);
}
5.3 instanceof 的局限
// 1. 只能用于类,不能用于 interface / type(它们编译后不存在!)
interface DeviceData { id: string }
const d = { id: "CNC-001" };
// d instanceof DeviceData; // ❌ 编译错误:DeviceData 只是类型,运行时不存在
// 2. 跨 realm(iframe / Node vm)会失效
// iframe 里的数组 instanceof(主窗口的 Array)→ false
// 3. 子类会通过父类的 instanceof 检查(继承链)
记忆口诀:instanceof 判断运行时存在的类(JS 类/内置类);interface/type 只是编译期的形状描述,运行时无影无踪——所以对象形状用 in / 判别联合 / 自定义守卫。
5.4 练习
// 练习:实现 formatInput(v: string | number | Date | string[]):
// Date → ISO 字符串;string[] → 逗号连接;其余 → String(v)
六、判别联合 + switch:黄金搭档(复习+深化)
6.1 第 3 天回顾 + 今天的新知识:穷尽检查
第 3 天学过判别联合(Discriminated Union)。今天补上最关键的一环——穷尽检查(Exhaustiveness Check):
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "triangle"; base: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.size ** 2;
case "triangle": return (shape.base * shape.height) / 2;
default: {
// ⭐ 穷尽检查:如果漏了任何 case,shape 会是那个漏掉的类型(不是 never)
const _exhaustive: never = shape;
return _exhaustive;
}
}
}
6.2 穷尽检查为什么重要
// 场景:三个月后,需求加了新形状
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "triangle"; base: number; height: number }
| { kind: "hexagon"; side: number }; // ⚠ 新增!
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.size ** 2;
case "triangle": return (shape.base * shape.height) / 2;
default: {
// ❌ 编译错误!shape 是 { kind: "hexagon"; side: number }
// 不能赋给 never —— TS 立刻提醒你漏处理了 hexagon!
const _exhaustive: never = shape;
return _exhaustive;
}
}
}
这是类型系统最优雅的时刻:新增分支后,所有漏改的地方编译期自动爆红,而不是运行时返回 undefined。工业软件最怕"静默失败",穷尽检查从根上杜绝了它。
6.3 工业场景:设备指令分发
/** 下发给设备的指令(判别联合) */
type Command =
| { type: "start"; deviceId: string; speed: number }
| { type: "stop"; deviceId: string; reason: string }
| { type: "reset"; deviceId: string; mode: "soft" | "hard" }
| { type: "calibrate"; deviceId: string; target: number };
function executeCommand(cmd: Command): string {
switch (cmd.type) {
case "start":
return `启动 ${cmd.deviceId},速度 ${cmd.speed}rpm`;
case "stop":
return `停止 ${cmd.deviceId},原因:${cmd.reason}`;
case "reset":
return `${cmd.mode === "soft" ? "软" : "硬"}复位 ${cmd.deviceId}`;
case "calibrate":
return `校准 ${cmd.deviceId} 至 ${cmd.target}`;
default: {
const _exhaustive: never = cmd;
return _exhaustive;
}
}
}
6.4 练习
// 练习:定义 WebSocket 消息联合类型:
// connect { event: "connect"; sessionId }
// data { event: "data"; payload: DeviceData[] }
// error { event: "error"; code: number; message: string }
// close { event: "close"; reason: string }
// 实现 handleMessage(msg),带穷尽检查
七、自定义类型守卫:类型谓词 is
7.1 问题:复杂判断无法自动收窄
前面的守卫都是"单行检查"。但真实业务判断往往是多条件的组合:
// 判断是否为有效设备数据:有 id 字符串 + temp 数字
function isValidDevice(v: unknown) {
return (
typeof v === "object" &&
v !== null &&
"id" in v &&
typeof (v as any).id === "string"
);
}
const input: unknown = JSON.parse('{"id":"CNC-001"}');
if (isValidDevice(input)) {
// ❌ 报错!TS 不知道 isValidDevice 返回 true 意味着什么
// input 还是 unknown,无法访问 input.id
console.log(input.id);
}
7.2 类型谓词:告诉编译器"返回 true 意味着什么"
/**
* 自定义类型守卫:判断值是否为有效设备数据
* 返回类型写成 "v is DeviceData" —— 类型谓词
*/
function isValidDevice(v: unknown): v is DeviceData {
return (
typeof v === "object" &&
v !== null &&
"id" in v &&
typeof v.id === "string" &&
"temp" in v &&
typeof v.temp === "number"
);
}
interface DeviceData {
id: string;
name: string;
temp: number;
}
if (isValidDevice(input)) {
// ✅ input 自动收窄为 DeviceData!
console.log(input.id, input.temp);
}
语法解析:
function 函数名(参数: 参数类型): 参数 is 目标类型 {
// 返回 boolean:true = 参数是目标类型;false = 不是
}
读法:v is DeviceData 读作"v 是 DeviceData"——这个函数返回 true 时,v 就是 DeviceData。
7.3 类型谓词的本质:编译器的信任
// ⚠ 类型谓词是"你向编译器的承诺",TS 不会检查你的逻辑对不对!
// 写一个逻辑错误的守卫:
function isString(v: unknown): v is string {
return typeof v === "number"; // ⚠ 逻辑写反了!
}
const v: unknown = 123;
if (isString(v)) {
console.log(v.toUpperCase()); // ✅ 编译通过,运行时崩溃!
}
结论:类型谓词 = 自动的 in/typeof 检查 + 手动的信任背书。写谓词逻辑时要格外小心,谓词的实现是运行时安全性的唯一防线。
7.4 工业场景 1:解析不可信的外部数据
/** 从 localStorage 恢复数据(内容可能是任意旧版本格式) */
function loadAlertHistory(): AlertRecord[] {
const raw = localStorage.getItem("alertHistory");
if (!raw) return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return []; // 解析失败 → 空历史
}
// ⭐ 用自定义守卫过滤出合法项
if (!Array.isArray(parsed)) return [];
return parsed.filter(isAlertRecord);
}
function isAlertRecord(v: unknown): v is AlertRecord {
if (typeof v !== "object" || v === null) return false;
const r = v as Record<string, unknown>;
return (
typeof r.id === "string" &&
typeof r.temp === "number" &&
(r.time === undefined || typeof r.time === "string")
);
}
7.5 工业场景 2:过滤数组同时收窄类型
// 数组 filter + 类型守卫 = "过滤 + 类型转换"一步到位
const mixed: (DeviceData | null | undefined)[] = [
{ id: "CNC-001", name: "机床", temp: 65 },
null,
undefined,
{ id: "AGV-002", name: "搬运车", temp: 42 }
];
// ❌ 普通回调:filter 后类型不变(还是带 null 的联合)
const bad = mixed.filter(d => d !== null);
// bad: (DeviceData | null | undefined)[]
// ✅ 类型守卫做回调:filter 后类型收窄!
const good = mixed.filter(
(d): d is DeviceData => d !== null && d !== undefined
);
// good: DeviceData[]
good.forEach(d => console.log(d.id)); // ✅ 无 null 检查烦恼
这是类型守卫最高频的使用姿势——filter + 谓词回调,数组清洗利器。
7.6 工业场景 3:null 安全的链式查找
function findById(devices: DeviceData[], id: string): DeviceData | undefined {
return devices.find(d => d.id === id);
}
const found = findById([], "CNC-001");
if (found) {
// find 的类型签名自带 undefined,配合 truthiness 守卫
console.log(found.temp);
}
// ⭐ TS 5.5+ 新特性:自动推断裸 return 的谓词(了解)
// TS 5.5 开始,像 () => d !== null 这种简单谓词可以不用显式写 is 了
// 但显式写仍是兼容性最好的方式
7.7 练习
// 练习 1:写 isDeviceData 守卫(id: string, name: string, temp: number, status: string)
// 用它过滤 unknown[] 得到 DeviceData[]
// 练习 2:写 isApiResponse 守卫:
// { status: "success", data: unknown[] } 或 { status: "error", message: string }
// 练习 3:把第 7.5 的例子改成"过滤掉温度非法(<20 或 >120)的设备"
八、断言函数:asserts x is T
8.1 类型谓词的"失败即崩"版本
自定义守卫用 if 处理两种情况。但有些场景非法数据就该直接报错(fail fast):
/**
* 断言函数:如果检查不通过,直接抛异常
* 语法:asserts 参数 is 类型
*/
function assertIsDeviceData(v: unknown): asserts v is DeviceData {
if (
typeof v !== "object" ||
v === null ||
!("id" in v) ||
typeof (v as { id: unknown }).id !== "string"
) {
throw new Error(`数据不是合法的设备数据:${JSON.stringify(v)}`);
}
}
// 使用:不需要 if!
const input: unknown = await fetchDevice();
assertIsDeviceData(input); // ⭐ 调用后,input 自动收窄为 DeviceData
console.log(input.id); // ✅ 直接用
8.2 谓词 vs 断言函数的选择
// 谓词(is):两种结果都有意义 → if/else 分支处理
if (isValidDevice(v)) {
render(v);
} else {
showEmpty(); // 非法数据是正常业务分支
}
// 断言(asserts is):非法即异常 → 直接炸出来
assertIsDevice(v); // 非法数据是程序 bug / 契约破裂
render(v); // 走到这里一定是合法的
8.3 内置的类似思想:Node.js assert
// Node.js 的 assert 模块(运行时校验,不收窄类型)
import assert from "node:assert";
assert(typeof input === "object"); // 不通过就抛错
// 但 input 不会收窄 —— TS 的 asserts 是它的"类型增强版"
8.4 练习
// 练习:写 assertIsAlertRecord(v: unknown): asserts v is AlertRecord
// 然后处理:JSON.parse 后的告警数据,非法直接抛"告警数据损坏"
九、类型断言:as 的正确与错误用法
9.1 基本语法
类型断言是你告诉编译器"相信我,我知道它在运行时是什么类型":
// 形式 1:as(推荐,JSX 中唯一可用)
const el = document.getElementById("app") as HTMLDivElement;
// 形式 2:尖括号(TS 特有语法,JSX 冲突,不推荐)
const el2 = <HTMLDivElement>document.getElementById("app");
9.2 合理用法 1:DOM 查询
// getElementById 返回 HTMLElement | null
const canvas = document.getElementById("chart") as HTMLCanvasElement | null;
if (canvas) {
const ctx = canvas.getContext("2d"); // ✅ CanvasElement 才有 getContext
// 如果不断言,HTMLElement 上没有 getContext,会报错
}
注意:这里断言为 HTMLCanvasElement | null 而不是 HTMLCanvasElement——保留了 null 检查的必要性,只断言"元素种类"这一个维度。
9.3 合理用法 2:收窄 any / unknown 的中间步骤
const raw: unknown = JSON.parse(configJson);
// 分步断言(每一步都有守卫背书,比较可控)
if (typeof raw === "object" && raw !== null && "port" in raw) {
const port = (raw as { port: unknown }).port;
if (typeof port === "number") {
startServer(port); // ✅ 安全
}
}
9.4 ⚠️ 危险用法:双 as 突破一切限制
// as unknown as X:两段断言,可以把任何类型强行转成任何类型
const value = "hello" as unknown as number; // 编译通过!
console.log(value.toFixed(2)); // 💥 运行时崩溃:toFixed is not a function
双 as = 完全关闭类型检查。它的存在价值:
// 唯一正当场景:确信库的类型定义有误(@types 写错了),临时绕过
const fixed = brokenLibValue as unknown as CorrectType;
// ⚠ 必须伴随注释说明原因,并尽快提 PR 修复类型定义
9.5 断言的编译期检查底线
// TS 只允许"足够重叠"的类型之间断言
const s = "hello";
const n = s as number;
// ❌ 编译错误:string 和 number 完全不重叠,不允许直接断言
// 但经过 unknown 中转就能绕过(这就是双 as 危险的原因)
const n2 = s as unknown as number; // ✅ 编译通过(危险!)
9.6 as any:逃生舱的正确用法
// 坏味道:为了消除报错随手 as any
function handle(data: DeviceData) {
const v = data as any; // ❌ 关掉了所有检查
v.anything(); // 编译通过,运行时随缘
}
// 可接受:局部、临时、有注释
// 例:调试时快速试错
// const debug = (data as any).internalField; // 调试用,提交前删除
原则:as any 是逃生舱不是常规门。每次写它之前先问:能不能用守卫/重写类型解决?
9.7 as const:断言家族的好成员(补充)
// as const:把值断言为最深度的只读字面量类型
const THEME = {
color: "#00ff88",
size: [1280, 720],
} as const;
// THEME 的类型:
// {
// readonly color: "#00ff88";
// readonly size: readonly [1280, 720];
// }
// 常见用途:字面量元组替代枚举
const DIRECTIONS = ["up", "down", "left", "right"] as const;
type Direction = (typeof DIRECTIONS)[number]; // "up" | "down" | "left" | "right"
as const与明天的"枚举与字面量类型"直接相关,先混个脸熟。
9.8 练习
// 练习 1:用 as 把 getElementById("search") 断言为 HTMLInputElement,读取 .value
// 练习 2:定义 LEVELS = ["info", "warn", "error"] as const,
// 用 typeof + 索引访问(预习第 6 天)得到 Level 联合类型
十、非空断言 ! 与确定性赋值 !
10.1 非空断言:排除 null/undefined 的快捷方式
function findDevice(id: string): DeviceData | undefined {
return devices.find(d => d.id === id);
}
// 方式 1:守卫(安全)
const d1 = findDevice("CNC-001");
if (d1) console.log(d1.temp);
// 方式 2:非空断言(赌它存在)
const d2 = findDevice("CNC-001")!;
console.log(d2.temp); // ✅ 编译通过,但 d2 若是 undefined → 运行时崩溃
! 的语义:我担保这里不是 null/undefined。赌错了,运行时报 Cannot read properties of undefined——正是第 1 天开篇那个"JS 的噩梦"。
10.2 非空断言的可接受场景
// 场景 1:紧随其后的代码手动确认过(意义有限,不如守卫)
// 场景 2:初始化逻辑保证了非空,但 TS 看不出来
let chart: echarts.ECharts | null = null;
onMounted(() => {
chart = echarts.init(el.value!); // ⭐ Vue 场景常见
});
function redraw() {
chart!.setOption(option); // ⚠ 赌 onMounted 已执行
// 更安全:if (chart) chart.setOption(option);
}
建议:初学阶段默认不用 !,一律用 if 守卫。等你被"三层嵌套 if"烦到的时候,再回来有节制地用。
10.3 确定性赋值断言(声明侧的 !)
// 变量声明时的 !:告诉 TS"我会先赋值再使用,别报'未初始化'错误"
let app: VueApp!; // 不写 ! 则类型是 VueApp | undefined
function bootstrap() {
app = createApp(App);
}
app.mount("#app"); // ✅ 编译通过(但你必须真的先调用 bootstrap)
两类 ! 的区别:
10.4 练习
// 练习:实现 getTempOrThrow(id: string): number
// 找到设备返回温度;找不到 throw Error —— 内部先用守卫实现,再对比 ! 版本
十一、any vs unknown:未知数据的两条路
11.1 any:放弃检查
let data: any = fetchData();
data.foo.bar.baz(); // ✅ 编译通过,运行时随缘
data = 123;
data(); // ✅ 还是能编译 —— any 传染一切接触它的代码
any 的本质是类型系统的豁免权:它兼容所有类型,也污染所有类型。
11.2 unknown:安全的 any
let data: unknown = fetchData();
data.foo; // ❌ 报错:unknown 上不能访问任何属性
data(); // ❌ 报错:不能调用
data + 1; // ❌ 报错:不能运算
// ✅ 唯一的用法:先收窄,再使用
if (typeof data === "object" && data !== null) {
// ... 续上今天的各种守卫
}
unknown 的规则:可以接收任何值,但使用前必须收窄——强制你走今天学的守卫流程。
11.3 对比表
11.4 工程实践:把 any 关在笼子里
// ✅ 外部输入统一 unknown(JSON、localStorage、第三方回调)
function parse(json: string): unknown {
return JSON.parse(json);
}
// ✅ 内部流转用守卫收窄
const v = parse('{"temp":65}');
if (isDeviceData(v)) render(v);
// ✅ 第三方库类型缺失时,宁可 unknown + 守卫,也别 any 一路裸奔
declare const legacyLib: {
getData: () => unknown; // 比 () => any 好
};
// tsconfig 中开启严格化(阶段初期就配好):
// "noImplicitAny": true —— 隐式 any 直接报错,逼你显式表态
11.5 练习
// 练习:实现 safeParse(json: string): unknown
// 再实现 getDevicesFrom(json: string): DeviceData[]
// 内部:parse → Array.isArray 过滤 → isDeviceData 过滤 → 返回
十二、实战场景全覆盖
12.1 场景一:API 响应的完整安全处理链
/** 后端响应结构 */
type ApiResponse =
| { status: "success"; data: DeviceData[] }
| { status: "error"; message: string };
/** 守卫:校验响应结构 */
function isApiResponse(v: unknown): v is ApiResponse {
if (typeof v !== "object" || v === null) return false;
const r = v as Record<string, unknown>;
if (r.status === "success") return Array.isArray(r.data);
if (r.status === "error") return typeof r.message === "string";
return false;
}
/** 守卫:校验单个设备 */
function isDeviceData(v: unknown): v is DeviceData {
if (typeof v !== "object" || v === null) return false;
const d = v as Record<string, unknown>;
return (
typeof d.id === "string" &&
typeof d.name === "string" &&
typeof d.temp === "number" &&
typeof d.status === "string"
);
}
/** 完整处理链:请求 → 断言响应 → 清洗数据 */
async function loadDevices(): Promise<DeviceData[]> {
const res = await fetch("/api/devices");
if (!res.ok) return []; // HTTP 层失败
const json: unknown = await res.json();
if (!isApiResponse(json)) return []; // 结构校验失败
if (json.status === "error") { // 相等收窄:错误分支
console.error("接口错误:", json.message);
return [];
}
// 相等收窄:成功分支 → 清洗数据(守卫 filter)
return json.data.filter(isDeviceData); // ✅ 双重保险
}
处理链分层:HTTP 状态 → 响应结构 → 数据项,三层校验层层递进——这是工业系统对接不可信数据的标准姿势。
12.2 场景二:WebSocket 消息分发
type WsMessage =
| { event: "connect"; sessionId: string }
| { event: "data"; payload: DeviceData[] }
| { event: "error"; code: number; message: string }
| { event: "close"; reason: string };
/** 消息入口:unknown → 守卫 → 分发 */
ws.onmessage = (e: MessageEvent) => {
let msg: unknown;
try {
msg = JSON.parse(e.data);
} catch {
return; // 非 JSON 直接丢弃
}
if (!isWsMessage(msg)) return; // 结构守卫(实现略,模式同 12.1)
// ⭐ 判别联合 + 穷尽检查分发
switch (msg.event) {
case "connect": onConnect(msg.sessionId); break;
case "data": onData(msg.payload); break;
case "error": onError(msg.code, msg.message); break;
case "close": onClose(msg.reason); break;
default: {
const _exhaustive: never = msg;
console.error("未处理的消息类型", _exhaustive);
}
}
};
12.3 场景三:设备数据的多态渲染
/** 设备多态:带 kind 判别字段 */
type Device =
| { kind: "cnc"; id: string; spindleSpeed: number; temp: number }
| { kind: "agv"; id: string; battery: number; position: string }
| { kind: "arm"; id: string; load: number; temp: number };
function renderCard(d: Device): string {
switch (d.kind) {
case "cnc":
return `<div class="card">${d.id} 转速${d.spindleSpeed}rpm</div>`;
case "agv":
return `<div class="card">${d.id} 电量${d.battery}%</div>`;
case "arm":
return `<div class="card">${d.id} 负载${d.load}kg</div>`;
default: {
const _exhaustive: never = d;
return _exhaustive;
}
}
}
12.4 场景四:表单输入的运行时校验
/** 表单原始输入:全是 unknown(来自用户) */
function handleFormInput(input: Record<string, unknown>): string[] {
const errors: string[] = [];
// 逐字段守卫校验
const name = input.name;
if (typeof name !== "string" || name.trim() === "") {
errors.push("设备名称不能为空");
}
const temp = input.temp;
if (typeof temp !== "number" || Number.isNaN(temp)) {
errors.push("温度必须是数字");
} else if (temp < -50 || temp > 200) {
errors.push("温度超出合理范围");
}
return errors;
}
十三、类比记忆:海关 vs 口头担保
把类型系统想成一个国家,值想入境:
分寸感:能用证件(守卫)就别用担保(断言)。担保只在证件系统覆盖不到的地方用(DOM 查询、旧库类型缺失)。
十四、常见坑点与最佳实践
坑点 1:truthiness 检查误杀 0 和 “”
// ❌ 0°C 消失了
if (temp) { render(temp); }
// ✅ 显式排除 null
if (temp !== null) { render(temp); }
坑点 2:typeof null === “object”
// ❌ 想判断对象却把 null 放进来
if (typeof v === "object") { v.id; } // null 时崩溃
// ✅ 双重检查
if (typeof v === "object" && v !== null) { /* ... */ }
坑点 3:in 守卫两边都有该属性
// id 两边接口都有 → 不收窄,白写
if ("id" in device) { /* 还是联合类型 */ }
// ✅ 找特有属性,或改用判别联合
坑点 4:instanceof 用于 interface
// ❌ interface 编译后不存在,不能 instanceof
if (x instanceof DeviceData) {}
// ✅ 类用 instanceof;interface 用 in / 守卫 / 判别联合
坑点 5:自定义守卫逻辑写错,编译器不背锅
// ⚠ 谓词是承诺不是证明 —— 逻辑错误照样编译通过
function isNumber(v: unknown): v is number {
return typeof v === "string"; // 写反了
}
// 守卫实现必须逐字段核对,这是运行时安全的唯一防线
坑点 6:断言成了拐杖
// ❌ 断言上瘾:明明可以守卫
const temp = (input as DeviceData).temp;
// ✅ 守卫先行
if (isDeviceData(input)) {
const temp = input.temp;
}
坑点 7:非空断言藏在链式调用里
// ❌ 一个 ! 掩盖一个潜在的 undefined
devices.find(d => d.id === id)!.temp.toFixed(1);
// ✅ 守卫或提供默认值
const found = devices.find(d => d.id === id);
const temp = found?.temp ?? 0;
最佳实践清单
收窄优先级:判别联合 > typeof/in/instanceof > 自定义守卫 > as 断言 >
!外部数据入口统一 unknown(JSON/fetch/localStorage/WS 消息)
switch 分发必带穷尽检查(default + never)
filter 清洗数组用谓词回调
as只用于 DOM 查询和类型定义缺失,且保留 null 维度双 as 必须写注释说明原因
new/配置类变量声明可
!,读取前仍建议守卫每个自定义守卫配单测(它承担运行时安全责任)
十五、自测挑战
Q1:typeof null 返回什么?想判断 null 应该用什么?
Q2:温度参数 temp: number | null,为什么 if (temp) 是危险的?正确写法?
Q3:in 守卫在什么情况下不生效(不收窄)?
Q4:为什么 interface 不能用 instanceof?什么可以?
Q5:穷尽检查 const _exhaustive: never = shape 的原理是什么?它防住了什么事故?
Q6:手写一个 isDeviceData(v: unknown): v is DeviceData,字段包括 id/name/temp/status 四个。
Q7:mixed.filter(d => d !== null) 和 mixed.filter((d): d is DeviceData => d !== null) 的结果类型有什么区别?
Q8:类型谓词 is 和断言函数 asserts is 的失败行为有什么不同?分别适用什么场景?
Q9:as unknown as T 为什么危险?它唯一正当的用途是什么?
Q10:any 和 unknown 都能接收任意值,核心区别是什么?为什么工程上推荐 unknown?
Q11:value! 和 let x: T! 两个 ! 分别叫什么?各断言什么?
Q12:完成 12.1 的完整 API 响应处理链,三层校验分别是哪三层?
十六、总结与知识图谱
类型守卫与类型断言(类型收窄术)
│
├── 编译器内置守卫(最可靠)
│ ├── typeof(8 种字符串;null 陷阱)
│ ├── truthiness(⚠ 0/"" 误杀)
│ ├── 相等收窄(=== / !== null)
│ ├── in(特有属性才收窄)
│ ├── instanceof(类/内置类;interface 不行)
│ └── 判别联合 + switch + 穷尽检查(never)
│
├── 自定义守卫(你的安检流程)
│ ├── 类型谓词 `v is T`(失败返回 false)
│ │ ├── 过滤数组(filter + 谓词回调)⭐ 高频
│ │ └── 解析外部数据(JSON/localStorage/WS)
│ └── 断言函数 `asserts v is T`(失败抛异常)
│ └── 契约校验、fail fast
│
├── 类型断言(口头担保)
│ ├── as(DOM 查询、收窄 any/unknown 中转)
│ ├── as const(只读字面量化 → 预告第 5 天)
│ ├── 双 as(⚠ 危险逃生舱,须注释)
│ └── as any(局部临时,提交前删)
│
├── 非空断言
│ ├── 表达式!(赌非空,初学少用)
│ └── 声明 let x: T!(确定性赋值)
│
└── any vs unknown(未知数据)
├── any:豁免权,污染接触的一切
└── unknown:强制走守卫(外部输入的标准类型)
一句话总结:守卫是给编译器证据,断言是给编译器承诺——证据越多,承诺越少,代码越安全。
延伸阅读
下一步
本文是 TypeScript 深入 系列的第 4 天。接下来的学习路线:
第 5 天:枚举与字面量类型 — 定海神针(enum 深浅对比、as const、const 断言与联合的配合)
第 6 天:keyof / typeof / 索引访问 — 类型钥匙(通往类型体操的大门)
第 7 天:第一关 BOSS 战 — 综合实战(本周全部知识融成一个工业数据处理模块)
学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。
每天花 2 小时,28 天通关 TypeScript 深入。加油!