用类型守住数据边界:构建健壮的变量体系
需求场景
你的订单系统项目进入了编码阶段。后端同事发来了第一批 API 文档,字段类型五花八门:有字符串形式的订单编号、数值形式的金额、布尔形式的支付状态、可能为空的备注信息。你打开编辑器准备定义这些数据结构,第一个问题随之而来:TypeScript 提供了哪些类型,每种类型的边界在哪里?
本章从项目数据建模的角度出发,逐一拆解 TypeScript 的类型体系,帮你在变量声明阶段就把数据的合法范围锁定。
原始类型:数据的原子单元
TypeScript 继承了 JavaScript 的原始类型,并为每种类型提供了编译期约束。
布尔型 boolean
订单的支付状态、商品的上架状态、用户的激活状态——这些只有两种取值的字段使用 boolean:
let isPaid: boolean = false;
let isActive: boolean = true;
一个常见陷阱:new Boolean() 返回的是对象而非原始布尔值,两者类型不同。
// 错误:Boolean 对象不能赋值给 boolean 原始类型
let confirmed: boolean = new Boolean(1);
// 正确:Boolean() 作为普通函数调用时返回原始布尔值
let confirmed: boolean = Boolean(1);
数值型 number
金额、数量、评分——凡是数值相关的字段都属于 number,涵盖整数、浮点数和各种进制:
let orderAmount: number = 199.99;
let itemCount: number = 3;
let hexColor: number = 0xff6600;
let binaryFlag: number = 0b1010;
let maxValue: number = Infinity;
let invalid: number = NaN; // NaN 在类型系统中仍属于 number
字符串型 string
订单编号、用户昵称、地址信息——文本类数据使用 string,支持模板字符串:
let orderId: string = "ORD-20260312-001";
let summary: string = `订单 ${orderId} 已创建`;
大整数 bigint
当系统需要处理超出 Number.MAX_SAFE_INTEGER 的流水号或哈希值时,使用 bigint:
let transactionSeq: bigint = 9007199254740993n;
let hashValue: bigint = 0xffffffffffffffffn;
// bigint 和 number 是完全独立的两套类型,不能混用
let wrong: bigint = 123; // 编译错误
编译目标 target 需要设为 es2020 或更高。
符号 symbol
symbol 生成全局唯一的标识符,常用于定义对象的私有属性键,避免命名冲突:
let traceId: symbol = Symbol("trace");
let requestId: symbol = Symbol("trace"); // 描述相同但值不同
console.log(traceId === requestId); // false
在业务代码中使用频率较低,更多出现在框架和底层库的实现中。
null 和 undefined
null 和 undefined 既是值又是类型:
let empty: null = null;
let missing: undefined = undefined;
默认配置下它们可以赋值给任何类型,但这恰恰是bug的温床。建议在 tsconfig.json 中开启 strictNullChecks:
// 开启 strictNullChecks 后
let price: number = null; // 编译错误
// 需要显式声明可空
let remark: string | null = null; // 正确
any 与 unknown:两种处理不确定数据的策略
any:关闭类型校验
any 允许变量接受任意类型的值,且不会对后续操作做任何检查:
let rawInput: any;
rawInput = 42;
rawInput = "text";
rawInput = { key: "val" };
rawInput.nonExistentMethod(); // 编译器不报错,运行时崩溃
any 最严重的问题是传播效应——它可以赋值给任何其他类型的变量,破坏整条类型链路:
let dangerousData: any = "not a number";
let safeCounter: number = dangerousData; // 编译器不报错
safeCounter * 10; // 运行时得到 NaN
any 的合理使用场景极其有限:老旧 JavaScript 项目的迁移过渡期、确实无法预知类型且你清楚风险的情况。
unknown:安全的类型未知
unknown 同样可以接收任意类型的值,但它在使用前强制要求类型判断:
let apiResponse: unknown;
apiResponse = true;
apiResponse = 42;
apiResponse = "data";
// 不做类型判断直接使用会报错
apiResponse.toFixed(2); // 编译错误
// 必须先缩小类型范围
if (typeof apiResponse === "number") {
apiResponse.toFixed(2); // 此处编译器知道是 number,通过
}
unknown 也不能直接赋值给其他类型:
let val: unknown = 42;
let num: number = val; // 编译错误
对比速查
| 能力 | any | unknown |
|---|---|---|
| 接收任意值 | 支持 | 支持 |
| 赋值给其他类型 | 支持 | 需先类型缩小 |
| 直接调用方法 | 支持 | 需先类型缩小 |
| 类型安全性 | 无 | 有 |
建议:遇到不确定类型时,优先使用 unknown。
void 和 never:函数返回值的两个极端
void:不关心返回值
当函数执行某种操作但不需要返回有意义的结果时,返回类型标注为 void:
function logOrder(orderId: string): void {
console.log(`Processing order: ${orderId}`);
}
void 类型的变量只能被赋值为 undefined:
let noValue: void = undefined;
never:永远不会到达
never 表示函数不会正常结束——要么抛出异常,要么进入无限循环:
function throwOrderError(msg: string): never {
throw new Error(`Order Error: ${msg}`);
}
function keepAlive(): never {
while (true) {
// 心跳检测循环
}
}
never 在穷尽性检查中有重要用途。当 switch 覆盖了所有分支后,default 中的变量类型会被推导为 never。如果后续新增了分支但忘记处理,编译器会报错:
type PaymentMethod = "card" | "cash" | "transfer";
function processPayment(method: PaymentMethod): string {
switch (method) {
case "card":
return "Charging card";
case "cash":
return "Accepting cash";
case "transfer":
return "Wire transfer";
default:
const guard: never = method; // 如果遗漏了某个分支,这里报错
return guard;
}
}
数组类型
声明语法
TypeScript 要求数组中的元素类型统一,有两种等价写法:
let quantities: number[] = [10, 20, 30];
let productNames: Array<string> = ["Laptop", "Phone", "Tablet"];
不符合类型约束的元素会被拒绝:
let scores: number[] = [85, 92, 78];
scores.push("excellent"); // 编译错误
需要混合类型时,使用联合类型:
let mixedLog: (string | number)[] = ["order-001", 200, "order-002", 404];
类型推断
声明空数组时需要注意:TypeScript 有一种”类型演化”(evolving types)机制——空数组初始被推断为 any[],随后根据实际添加的元素动态确定类型。这种机制不依赖 noImplicitAny 选项,但在开启严格模式后更容易观察到其行为。依赖这种隐式行为容易让代码意图不清晰,推荐始终显式标注类型:
// 不推荐:依赖类型演化,意图不明确
const items = []; // any[](evolving type)
items.push(100); // 类型演化为 number[]
items.push("completed"); // 类型演化为 (string | number)[]
// 推荐:显式标注类型,意图清晰
const records: (string | number)[] = [];
records.push(100); // ✅
records.push("completed"); // ✅
只读数组
对于不应被修改的配置数据,使用 readonly 修饰:
const statusCodes: readonly number[] = [200, 301, 404, 500];
statusCodes.push(502); // 编译错误
statusCodes[0] = 201; // 编译错误
也可以使用 as const 断言:
const regions = ["CN", "US", "EU"] as const;
// 类型为 readonly ["CN", "US", "EU"]
元组类型
元组是固定长度、每个位置可以有不同类型的数组。JavaScript 本身没有元组的概念,这是 TypeScript 的扩展。
let orderEntry: [string, number, boolean] = ["ORD-001", 299.99, true];
访问各位置的元素时,编译器知道对应的类型:
orderEntry[0].toUpperCase(); // string 方法,通过
orderEntry[1].toFixed(2); // number 方法,通过
元组在直接赋值时必须提供所有元素:
let pair: [string, number] = ["price"]; // 编译错误:缺少第二个元素
可选元素和扩展
元组支持可选元素(只能在末尾)和扩展运算符:
type LogEntry = [string, number?];
const entry1: LogEntry = ["info"];
const entry2: LogEntry = ["warn", 301];
type TaggedScores = [string, ...number[]];
const record: TaggedScores = ["Math", 95, 88, 72];
命名元组
TypeScript 4.0 起可以为元组成员命名,增强可读性:
type Coordinate = [lat: number, lng: number, alt?: number];
const location: Coordinate = [39.9042, 116.4074];
枚举类型
枚举将一组相关的命名常量组织在一起,提升代码的语义表达能力。
数字枚举
enum OrderStatus {
Pending, // 0
Processing, // 1
Shipped, // 2
Delivered, // 3
Cancelled // 4
}
let currentStatus: OrderStatus = OrderStatus.Shipped;
数字枚举支持反向映射:
console.log(OrderStatus[2]); // "Shipped"
也可以指定起始值:
enum Priority {
Low = 1,
Medium, // 2
High // 3
}
字符串枚举
字符串枚举的每个成员必须显式赋值,不支持反向映射,但可读性更好:
enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Delete = "DELETE"
}
常量枚举
使用 const 声明的枚举在编译时被内联替换,不产生运行时对象:
const enum Direction {
North,
South,
East,
West
}
let heading = [Direction.North, Direction.East];
// 编译后:let heading = [0, 2];
计算成员
枚举成员分为两类:常量成员(值在编译期即可确定,包括字面量和由其他常量成员组成的位运算表达式)和计算成员(值需要运行时才能确定)。计算成员之后的成员必须手动赋值,因为编译器无法在编译期推算下一个自增值:
enum Permission {
Read = 1 << 0, // 1 —— 常量成员(编译期可计算的位移表达式)
Write = 1 << 1, // 2 —— 常量成员
Execute = 1 << 2, // 4 —— 常量成员
All = Read | Write | Execute, // 7 —— 常量成员(由其他常量成员组合)
Custom = 100 // 常量成员(字面量)
}
// 真正的计算成员示例:值依赖运行时函数调用
enum FileAccess {
None, // 0
Read = 1, // 1
Write = 2, // 2
ReadWrite = Read | Write, // 3 —— 常量成员
Admin = "admin".length, // 5 —— 计算成员(运行时求值)
Guest = 1 // 必须手动赋值,因为前一个是计算成员
}
枚举的替代方案:as const 对象
社区对 enum 存在一定争议。enum 会生成运行时代码,在 isolatedModules 模式下 const enum 跨文件使用受限,且 tree-shaking 不友好。许多团队(包括 TypeScript 社区知名教育者 Matt Pocock 等)更推荐使用 as const 对象配合类型推导来达到类似效果:
// 用 as const 对象替代枚举
const OrderStatus = {
Pending: "pending",
Processing: "processing",
Shipped: "shipped",
Delivered: "delivered",
Cancelled: "cancelled",
} as const;
// 从对象推导出联合类型
type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
// "pending" | "processing" | "shipped" | "delivered" | "cancelled"
let status: OrderStatus = OrderStatus.Shipped; // 使用方式与枚举相同
这种方式没有运行时产物的额外开销,与 isolatedModules 完全兼容,且支持 tree-shaking。enum 在需要反向映射(数字 → 名称)或与已有的枚举风格 API 对接时仍有价值,但对于新项目,as const 对象是更推荐的选择。
类型推断
TypeScript 在许多场景下能自动推导变量类型,不需要显式标注:
let orderCount = 5; // 推断为 number
orderCount = "many"; // 编译错误
const appVersion = "2.0"; // 推断为字面量类型 "2.0"
但声明时未赋值的变量会被推断为 any:
let pending; // any
pending = "text"; // 合法
pending = 42; // 也合法
上下文推断
编译器能根据使用场景推导回调函数参数的类型:
const amounts = [100, 200, 300];
amounts.forEach(amt => {
console.log(amt.toFixed(2)); // amt 自动推断为 number
});
document.addEventListener("keydown", evt => {
console.log(evt.key); // evt 自动推断为 KeyboardEvent
});
标注策略
- 可省略:变量声明时立即赋值、回调函数参数、简单函数的返回值
- 建议显式标注:函数参数、对外暴露的公共 API、空数组初始化、复杂的嵌套结构
// 省略:类型从赋值一目了然
const taxRate = 0.13;
const currency = "CNY";
// 显式标注:函数参数无法从上下文推断
function applyDiscount(price: number, rate: number): number {
return price * (1 - rate);
}
类型别名
当类型表达式较长或需要复用时,使用 type 关键字定义别名:
type ProductCode = string;
type MoneyAmount = number;
type Formatter = (val: MoneyAmount) => string;
type CodeOrFormatter = ProductCode | Formatter;
function resolve(input: CodeOrFormatter): string {
if (typeof input === "string") {
return input;
}
return input(0);
}
类型别名在编译后被完全移除。同一作用域内不允许重复定义同名别名:
type Color = "red";
type Color = "blue"; // 编译错误:重复定义
模板字面量类型可以在类型层面做字符串拼接:
type Env = "dev" | "staging" | "prod";
type ConfigKey = `config_${Env}`;
// "config_dev" | "config_staging" | "config_prod"
字面量类型
TypeScript 允许将特定的值本身作为类型使用:
let status: "active" | "inactive" | "suspended";
status = "active"; // 通过
status = "deleted"; // 编译错误
let maxRetry: 3 | 5 | 10;
maxRetry = 5; // 通过
maxRetry = 7; // 编译错误
const 声明的变量自动推断为字面量类型,let 则推断为宽泛类型:
const mode = "production"; // 类型是 "production"
let mode2 = "production"; // 类型是 string
字面量类型配合联合类型,可以精确地约束一个变量的合法取值集合:
type LogLevel = "debug" | "info" | "warn" | "error";
function writeLog(level: LogLevel, message: string): void {
console.log(`[${level.toUpperCase()}] ${message}`);
}
writeLog("info", "Server started");
writeLog("fatal", "Crash"); // 编译错误
联合类型与交叉类型
联合类型
联合类型用 | 表示「多选一」:
let productId: string | number;
productId = "SKU-001";
productId = 10042;
productId = true; // 编译错误
使用联合类型时,只能访问各成员类型共有的属性和方法:
function getDisplayId(pid: string | number): string {
return pid.toString(); // string 和 number 都有 toString
}
function getIdLength(pid: string | number): number {
return pid.length; // 编译错误:number 没有 length
}
要访问特定类型的属性,必须先做类型缩小:
function formatProductId(pid: number | string): string {
if (typeof pid === "string") {
return pid.toUpperCase();
}
return `#${pid.toString().padStart(6, "0")}`;
}
交叉类型
交叉类型用 & 表示「同时满足」:
type HasName = { fullName: string };
type HasAge = { birthYear: number };
type HasEmail = { contactEmail: string };
type CustomerProfile = HasName & HasAge & HasEmail;
const customer: CustomerProfile = {
fullName: "Wang Lei",
birthYear: 1990,
contactEmail: "wanglei@example.com"
};
如果交叉的类型中存在同名但类型冲突的属性,该属性类型变为 never:
type X = { id: string };
type Y = { id: number };
type Z = X & Y;
// Z 的 id 类型为 string & number,即 never
类型缩小
TypeScript 通过控制流分析自动推断变量在不同分支中的具体类型:
// typeof 判断
function describe(val: string | number): string {
if (typeof val === "string") {
return `Text: ${val.toUpperCase()}`;
}
return `Number: ${val.toFixed(2)}`;
}
// switch 语句
function getPort(protocol: "http" | "https" | "ftp"): number {
switch (protocol) {
case "http": return 80;
case "https": return 443;
case "ftp": return 21;
}
}
// 真值检查
function greetUser(nickname: string | null): string {
if (nickname) {
return `Hello, ${nickname}`;
}
return "Hello, Guest";
}
typeof 在类型层面的使用
TypeScript 扩展了 typeof 的用法。在类型上下文中,typeof 可以从一个运行时变量反推出其类型:
const defaultConfig = {
timeout: 3000,
retries: 3,
baseUrl: "/api"
};
type AppConfig = typeof defaultConfig;
// { timeout: number; retries: number; baseUrl: string }
let customConfig: typeof defaultConfig = {
timeout: 5000,
retries: 5,
baseUrl: "/v2/api"
};
值上下文中的 typeof 返回字符串,类型上下文中的 typeof 返回类型。编译后,类型上下文中的 typeof 被完全移除。
包装对象类型
JavaScript 为 string、number、boolean 提供了对应的包装对象 String、Number、Boolean(首字母大写)。在 TypeScript 中这是一个需要警惕的陷阱:
// 正确:使用小写的原始类型
const greeting: string = "hello";
// 错误倾向:使用大写的包装对象类型
const greeting2: String = "hello"; // 虽然编译通过但不推荐
// 小写类型不接受包装对象
const wrapped: string = new String("hello"); // 编译错误
规则只有一条:在类型标注中永远使用小写的 string、number、boolean。
本章回顾
| 类型 | 用途 | 注意事项 |
|---|---|---|
boolean | 二值状态 | 不要使用 new Boolean() |
number | 数值数据 | 包含 NaN 和 Infinity |
string | 文本数据 | 支持模板字符串 |
bigint | 超大整数 | 与 number 不兼容 |
symbol | 唯一标识符 | 业务代码中较少使用 |
null/undefined | 空值 | 建议开启 strictNullChecks |
any | 关闭类型校验 | 有传播效应,尽量避免 |
unknown | 安全的未知类型 | 使用前必须类型缩小 |
void | 无返回值 | 用于函数返回类型 |
never | 不可达 | 用于异常函数和穷尽检查 |
| 数组 | 同类型元素集合 | number[] 与 Array<number> 等价 |
| 元组 | 固定结构的异构数组 | 必须显式标注类型 |
| 枚举 | 命名常量集合 | const enum 编译后被内联 |
| 类型推断 | 编译器自动推导 | 未赋值的变量推断为 any |
| 类型别名 | 给复杂类型命名 | 编译后完全移除 |
| 字面量类型 | 将具体值作为类型 | 常与联合类型搭配 |
| 联合类型 | 多选一 | 只能访问共有成员 |
| 交叉类型 | 同时满足 | 属性冲突变为 never |
购买课程解锁全部内容
告别类型错误:12 章掌握 TypeScript 工程实战
¥29.90