跳到主要内容
预计阅读 35 分钟

用类型守住数据边界:构建健壮的变量体系

需求场景

你的订单系统项目进入了编码阶段。后端同事发来了第一批 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

nullundefined 既是值又是类型:

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; // 编译错误

对比速查

能力anyunknown
接收任意值支持支持
赋值给其他类型支持需先类型缩小
直接调用方法支持需先类型缩小
类型安全性

建议:遇到不确定类型时,优先使用 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 为 stringnumberboolean 提供了对应的包装对象 StringNumberBoolean(首字母大写)。在 TypeScript 中这是一个需要警惕的陷阱:

// 正确:使用小写的原始类型
const greeting: string = "hello";

// 错误倾向:使用大写的包装对象类型
const greeting2: String = "hello"; // 虽然编译通过但不推荐

// 小写类型不接受包装对象
const wrapped: string = new String("hello"); // 编译错误

规则只有一条:在类型标注中永远使用小写的 stringnumberboolean

本章回顾

类型用途注意事项
boolean二值状态不要使用 new Boolean()
number数值数据包含 NaNInfinity
string文本数据支持模板字符串
bigint超大整数number 不兼容
symbol唯一标识符业务代码中较少使用
null/undefined空值建议开启 strictNullChecks
any关闭类型校验有传播效应,尽量避免
unknown安全的未知类型使用前必须类型缩小
void无返回值用于函数返回类型
never不可达用于异常函数和穷尽检查
数组同类型元素集合number[]Array<number> 等价
元组固定结构的异构数组必须显式标注类型
枚举命名常量集合const enum 编译后被内联
类型推断编译器自动推导未赋值的变量推断为 any
类型别名给复杂类型命名编译后完全移除
字面量类型将具体值作为类型常与联合类型搭配
联合类型多选一只能访问共有成员
交叉类型同时满足属性冲突变为 never

购买课程解锁全部内容

告别类型错误:12 章掌握 TypeScript 工程实战

¥29.90