首页 / 文章 / TypeScript基础:从首个注解到泛型与严格模式

TypeScript基础:从首个注解到泛型与严格模式

对 TypeScript 类型系统的系统化讲解,涵盖基本类型与类型推断、区分联合类型、泛型、实用类型以及严格的 tsconfig 配置。

3750 词

每位 JavaScript 开发者都熟悉这种情形:代码在本地可以正常运行,部署后两天就会收到错误报告,原因是某个函数接收到了对象而非字符串,或者计算中混入了 undefined。JavaScript 并不会阻止你编写这样的代码,只是会在运行时出现故障,通常是在生产环境中。TypeScript 则将这种故障提前到你输入代码的那一刻就显现出来。本指南将从最初的 let x: number 开始,逐步介绍类型收窄、区分联合类型、泛型与实用类型,以及决定保护程度的编译器设置,帮助你自信地阅读和编写可用于生产环境的 TypeScript 代码。

TypeScript 是什么,又不是什么

TypeScript 是微软开发的一种开源语言,它为 JavaScript 增加了静态类型系统。这里的“静态”指的是编译器会在执行之前、即在编辑器中或编译时验证类型,而非在程序运行时才发现问题。有三点事实决定了它的其他所有特性:

  • 它是 JavaScript 的超集。任何语法上有效的 JavaScript 代码也都是有效的 TypeScript 代码,因此你是在扩展已有的知识而非从头开始。不过检查工具仍可能对这类代码报错,这正是其存在的意义。
  • 它能编译为普通的 JavaScript 代码。由于浏览器和 Node.js 都能运行 JavaScript,因此编译器 tsc 会移除相关注解,并生成普通的 .js 文件。
  • 类型在运行时消失。它们的存在是为了在开发过程中保护你。与Java不同,Java的类型信息会保留在编译后的字节码中,而TypeScript的类型在程序运行后就不再存在。
  • 同一个函数的不同之处

    这是一个用于计算两个数值之和的普通JavaScript函数:

    function add(a, b) {
      return a + b;
    }
    

    当传入字符串和数字时,它不会抛出错误,而是直接将它们拼接起来,只有当发票上的总金额显得异常时你才会注意到问题:

    add("10", 20); // "1020" — silently wrong
    

    通过指定参数类型和返回类型,可以明确定义函数的契约:

    function add(a: number, b: number): number {
      return a + b;
    }
    

    现在,在你仍在编辑代码时,同样的调用就会被拒绝,并且错误信息会直接显示在出问题的参数下方:

    add("10", 20);
    // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
    

    项目设置

    安装好Node.js后,你可以全局安装编译器并验证其功能:

    npm install -g typescript
    tsc --version
    

    在真实项目中,应将 TypeScript 作为本地开发依赖项进行安装,这样所有贡献者和 CI 服务器都能使用相同版本,随后生成配置文件:

    npm install typescript --save-dev
    npx tsc --init
    

    生成的 tsconfig.json 文件决定了编译器的严格程度以及现代化水平。较新版本的 tsc --init 已经默认启用 strict 模式,TypeScript 6.0 更是将严格模式设为默认值,但旧项目往往使用较为宽松的设置,后续才由团队逐步加强严格程度。下方的配置部分将涉及这一内容。

    一个最简单的示例文件会声明一个带类型的变量并输出其值:

    // app.ts
    let message: string = "Hello, TypeScript";
    console.log(message);
    

    先编译它,然后再运行生成的 JavaScript 代码:

    tsc app.ts     # produces app.js
    node app.js    # Hello, TypeScript
    

    在日常开发中,大多数项目都会避免这种两步流程,而是直接使用 ts-nodetsx 来运行文件,或者依赖 Vite、esbuild、webpack 等能够即时编译的打包工具。不过,手动操作一次仍然是值得的,因为这有助于建立正确的思维模式:输入 TypeScript,输出 JavaScript。

    基本类型与推断

    原始类型与数组

    原始类型的注解为 stringnumberboolean

    let username: string = "sanajit";
    let age: number = 26;
    let isActive: boolean = true;
    

    数组的写法是在元素类型后加上方括号:

    let scores: number[] = [90, 85, 78];
    let tags: string[] = ["typescript", "javascript"];
    

    通用形式 Array<number> 与上述写法含义完全相同;请选择一种风格并始终如一地使用:

    // equivalent generic syntax
    let ids: Array<number> = [1, 2, 3];
    

    让推断来完成显而易见的工作

    通常无需为已初始化的变量添加注解。TypeScript会从值中推断类型并加以约束:

    let city = "Ahmedabad"; // inferred as string
    city = 42;              // Error: Type 'number' is not assignable to type 'string'
    

    写成 let city: string = „Ahmedabad“ 并没有错,只是多余而已。对于整个语言来说,一个好的准则是:当值本身就能明确类型时依靠推断机制,而在值含义模糊或需要定义规范时(如函数参数、返回类型、空数组以及回调签名),则应明确写出类型。

    any、unknown、never 和 void

    这四种特殊类型几乎让每个初学者都犯错:

    • any 会关闭该值的类型检查。它只是一种应急手段而非真正的类型,过度使用它是代码库最终变成带有多余语法的JavaScript的最常见原因。
  • unknown 是安全的对应选项。任何值都可以赋给它,但必须通过检查来确定具体值后才能使用。对于形状尚不明确的 数据,比如验证前的 API 响应,这是最佳选择。
  • never 用于表示不可能存在的值,例如总是抛出异常或从不返回结果的函数。它的最实际用途是穷举检查,让编译器确认 switch 语句的每种情况都已被处理。
  • void 用于标记不返回任何有用结果的函数,比如事件处理程序或日志记录封装类。
  • 使用 unknown 时,只有在对应的判断分支内,typeof 检查才会启用字符串方法:

    function process(value: unknown) {
      if (typeof value === "string") {
        console.log(value.toUpperCase()); // safe — narrowed to string
      }
    }
    

    始终会抛出异常的函数被标记为 never,而仅产生副作用的函数则返回 void

    function fail(message: string): never {
      throw new Error(message);
    }function logAction(action: string): void {
      console.log(`Action: ${action}`);
    }
    

    关于在常见场景中替换 any 的具体方法,可参阅六种用于替换 any 的类型安全模式

    使用接口和类型别名描述对象

    对象的结构可以直接内联编写:

    const employee: { id: number; name: string; active: boolean } = {
      id: 1,
      name: "Amit",
      active: true,
    };
    

    这种方式仅适用一次,若在所有地方重复使用相同的内联结构,很快就会显得杂乱无章。通过 interfacetype 给该结构命名即可解决此问题。

    接口

    接口用于为对象结构命名,从而实现复用:

    interface Employee {
      id: number;
      name: string;
      department: string;
    }
    

    所有被该接口标注的对象都必须符合所声明的字段要求:

    const employee1: Employee = { id: 1, name: "John", department: "IT" };
    const employee2: Employee = { id: 2, name: "Sara", department: "HR" };
    

    问号用于标记可能不存在的属性:

    interface User {
      id: number;
      name: string;
      phone?: string; // may or may not be present
    }
    

    readonly 属性在对象创建时可以赋值,之后则不可:

    interface Product {
      readonly id: number;
      name: string;
    }
    

    试图重新赋值此类属性会导致编译时错误:

    const laptop: Product = { id: 100, name: "MacBook" };
    laptop.id = 200; // Error: Cannot assign to 'id' because it is a read-only property
    

    接口之间也可以相互继承,这样无需复制即可共享公共字段。可以先从一个基础形状开始:

    interface Person {
      name: string;
      age: number;
    }
    

    然后在其基础上构建更具体的形状:

    interface Employee extends Person {
      employeeId: number;
      department: string;
    }
    

    类型别名

    type 别名也可以为对象形状命名,但并不限于此。联合类型、基本类型和元组都可以通过这种方式赋予名称:

    type ID = string | number;
    type Point = { x: number; y: number };
    type Status = "pending" | "shipped" | "delivered";
    

    接口与类型的抉择

    对于常见的对象形状,两者在很大程度上可以互换使用。真正的区别在于:

    • 扩展:接口使用extends;类型别名则通过&交集运算符来组合不同结构。
    • 联合类型与原始类型:只有类型别名能够表示它们,例如type A = string | number
    • 声明合并:同名接口会合并为一个;类型别名则不能被重新声明。
    • 惯例:接口通常用于公共API的结构定义和类契约;而类型别名则适用于联合类型、元组以及映射型或条件型。

    许多团队遵循的规则是:当某个结构可能被扩展时,比如React属性、API模型和类契约,就使用interface;而对于联合类型、元组以及所有非对象类型,则使用type

    联合类型、缩小范围与交集

    联合类型

    联合类型表示一个值可以是多种类型中的一种:

    function printId(id: string | number) {
      console.log(`Your ID is ${id}`);
    }
    

    下面的两种调用都是被接受的,因为每个参数都匹配联合类型中的某个成员:

    printId(101);
    printId("A-204");
    

    类型收窄

    当一个值被定义为联合类型时,TypeScript 只允许对所有成员都有效的操作,直到你证明该值属于哪个具体成员。这种验证过程称为类型收窄,编译器会沿着代码的控制流执行这一过程:

    function formatValue(value: string | number) {
      if (typeof value === "string") {
        return value.toUpperCase(); // TypeScript knows it's a string here
      }
      return value.toFixed(2); // and here, it knows it's a number
    }
    

    除了 typeof 之外,常见的类型收窄工具还包括 Array.isArray()instanceofin 运算符以及与 nullundefined 的相等性检查。正是通过这些方式,TypeScript 在正常情况下保持沉默,而在出现异常情况时能够及时识别。

    用于状态管理的区分联合类型

    中级开发者最常忽略的模式是为联合类型的每个变体设置一个共享的字面量“标签”字段。首先,分别定义每种状态:

    type LoadingState = { status: "loading" };
    type SuccessState = { status: "success"; data: string[] };
    type ErrorState = { status: "error"; message: string };
    

    然后将它们组合起来,并根据标签进行切换:

    type FetchState = LoadingState | SuccessState | ErrorState;function render(state: FetchState) {
      switch (state.status) {
        case "loading":
          return "Loading...";
        case "success":
          return `Loaded ${state.data.length} items`;
        case "error":
          return `Failed: ${state.message}`;
      }
    }
    

    在每个 case 中,state 会被限定为对应的取值,因此 state.data 只在 "success" 分支中可用,而 state.message 只在 "error" 分支中可用。像同时存在数据和错误这样的不可能组合根本无法被表示出来。这避免了 UI 和 API 代码中出现“访问未定义属性”这类错误。通过添加一个将 state 赋值为 neverdefault 分支,就可以实现完整性检查:日后若新增第四种状态,编译器会指出所有遗漏该状态的 switch 语句。

    交集类型

    当联合类型表示“此或彼”时,交集类型则表示“此与彼”。我们可以从两个简单的结构开始:

    type Timestamped = { createdAt: Date };
    type Named = { name: string };
    

    交集要求一个对象同时具备两个类型的字段:

    type Record = Timestamped & Named;const item: Record = { name: "Invoice", createdAt: new Date() };
    

    关于这个示例需要注意的一点是:Record也是内置工具类型的名称。如果使用该名称定义自己的别名,就会在该文件中遮蔽全局的对应类型,这至少会带来混淆,因此在实际代码中建议使用更具体的名称。

    函数

    参数和返回值注解构成了函数契约的核心:

    function multiply(a: number, b: number): number {
      return a * b;
    }
    

    可选参数使用?标记,默认值可使参数变为可选并自动推断其类型,而剩余参数则可将任意数量的参数收集到带类型的数组中:

    // optional parameter
    function greet(name: string, title?: string): string {
      return title ? `${title} ${name}` : name;
    }// default parameter
    function createOrder(item: string, quantity: number = 1) {
      return { item, quantity };
    }// rest parameters
    function sum(...numbers: number[]): number {
      return numbers.reduce((total, n) => total + n, 0);
    }
    

    函数类型

    你还可以描述函数本身的结构,这对于回调函数和策略对象非常有用:

    type MathOperation = (a: number, b: number) => number;
    

    分配给该类型的函数会从注解中获取参数类型,因此 ab 无需再添加自己的注解:

    const subtract: MathOperation = (a, b) => a - b;
    

    类与访问修饰符

    TypeScript 类是带有类型化属性和访问修饰符的 JavaScript 类。下面的类首先声明了一个私有余额字段和一个只读的所有者字段:

    class Account {
      private balance: number;
      readonly owner: string;
    

    类的其余部分在构造函数中设置这些字段,并提供用于修改和读取余额的方法;外部无法访问私有字段:

      constructor(owner: string, initialBalance: number) {
        this.owner = owner;
        this.balance = initialBalance;
      }  deposit(amount: number): void {
        this.balance += amount;
      }  getBalance(): number {
        return this.balance;
      }
    }const acc = new Account("Priya", 1000);
    acc.deposit(500);
    console.log(acc.getBalance()); // 1500
    acc.balance; // Error: Property 'balance' is private
    

    这些修饰符的含义如下:

    • public 是默认值,可在任何地方访问。
    • private 仅能在类内部访问。
    • protected 可在类及其子类内部访问。
  • readonly字段在构造函数中可接受值,之后不允许重新赋值。
  • 需记住的是,private仅由编译器强制实现;在运行时该属性仍为普通字段。若需要真正的运行时隐私保护,JavaScript的#字段可满足此需求,相关权衡可在TypeScript私有字段与#语法:编译时隐私还是运行时隐私中了解。

    接口作为类契约

    接口可以定义类必须提供的功能:

    interface Shape {
      area(): number;
    }
    

    通过implements,编译器会验证类是否满足该契约,若缺少方法会在代码运行前给出提示。构造函数还使用了参数属性private radius,它可一步完成该字段的声明与赋值:

    class Circle implements Shape {
      constructor(private radius: number) {}  area(): number {
        return Math.PI * this.radius ** 2;
      }
    }
    

    泛型:保留类型的可重用代码

    通常在泛型的使用中,TypeScript才不再像是带注解的 JavaScript,而真正变成一种不同的工具。

    它们解决的问题

    一个用于返回数组中第一个元素的辅助函数可以用 any 来编写:

    function firstElement(arr: any[]) {
      return arr[0];
    }
    

    这样的代码可以运行,但在返回时类型信息会丢失,所有结果都是 any 类型:

    const num = firstElement([1, 2, 3]);   // typed as `any` — no help from the compiler
    const str = firstElement(["a", "b"]);  // also `any`
    

    类型参数可以捕获输入元素的类型,并将其用于返回值:

    function firstElement<T>(arr: T[]): T {
      return arr[0];
    }
    

    现在每次调用都能根据参数得到精确的结果类型:

    const num = firstElement([1, 2, 3]);   // inferred as number
    const str = firstElement(["a", "b"]);  // inferred as string
    

    T 是一个类型变量,TypeScript 会根据你传入的值来填充它。这样既能保持 any 的灵活性,又能获得具体类型的安全性。需要注意的是,对于空数组,在运行时 arr[0] 实际上为 undefined;启用 noUncheckedIndexedAccess 后,编译器会将其表示为 T | undefined

    泛型接口

    泛型也适用于接口。一个响应封装类就可以描述所有的接口:

    interface ApiResponse<T> {
      success: boolean;
      data: T;
    }
    

    有效载荷类型是在使用该封装类的地方指定的:

    const userResponse: ApiResponse<{ id: number; name: string }> = {
      success: true,
      data: { id: 1, name: "Sara" },
    };
    

    许多应用程序都是这样为 API 层定义类型的:使用一个 ApiResponse<T> 对象,重复用于处理用户数据、产品数据、订单数据以及后端返回的任何其他数据。

    限制类型参数

    有时 T 必须保证某些属性。可以将该要求表述为一个接口:

    interface HasLength {
      length: number;
    }
    

    然后使用 extends 对参数进行限制,这样只有具有 length 属性的类型才会被接受:

    function logLength<T extends HasLength>(item: T): void {
      console.log(item.length);
    }logLength("hello");       // OK — strings have .length
    logLength([1, 2, 3]);     // OK — arrays have .length
    logLength(42);             // Error: number doesn't have .length
    

    实用类型

    TypeScript 提供了泛型辅助类型,能够从现有类型派生出新类型,从而替代大量手动编写的样板代码。假设有一个用户模型:

    interface User {
      id: number;
      name: string;
      email: string;
      isAdmin: boolean;
    }
    

    可以派生出变体类型而无需重新声明它们:Partial 使所有属性变为可选,Readonly 使所有属性变为只读,Pick 保留选定的键,Omit 移除这些键,而 Record 则用于创建字典类型:

    // Every property becomes optional — perfect for "update" functions
    type UserUpdate = Partial<User>;// Every property becomes read-only
    type ImmutableUser = Readonly<User>;// Pick only the fields you need
    type UserPreview = Pick<User, "id" | "name">;// Everything except the fields you list
    type PublicUser = Omit<User, "email" | "isAdmin">;// A dictionary shape: keys of one type, values of another
    type UsersById = Record<number, User>;
    

    一个典型的应用场景是更新函数,它能够接受任意字段子集:

    function updateUser(id: number, changes: Partial<User>): void {
      // merge `changes` into the stored user
    }
    

    调用者只需传递发生变化的字段即可:

    updateUser(1, { name: "New Name" }); // no need to pass email, isAdmin, etc.
    

    该博客关于TypeScript内置实用类型的指南更深入地介绍了全部类型。

    枚举与字面量类型

    枚举

    枚举用于定义一组带名称的常量:

    enum OrderStatus {
      Pending,
      Shipped,
      Delivered,
    }
    

    随后可以通过枚举来引用这些值:

    let status: OrderStatus = OrderStatus.Shipped;
    

    默认情况下,枚举成员的编号从0开始。若赋值为字符串,则可显著简化日志记录与网络数据包的调试工作:

    enum Direction {
      Up = "UP",
      Down = "DOWN",
      Left = "LEFT",
      Right = "RIGHT",
    }
    

    字符串字面量联合类型通常更简单

    如今许多团队更倾向于使用字符串字面量联合类型,因为它不会生成额外的JavaScript代码,且行为更加可预测:

    type OrderStatus = "pending" | "shipped" | "delivered";
    

    编译器仍然会拒绝该集合之外的任何值:

    function updateStatus(status: OrderStatus) {
      // ...
    }updateStatus("shipped");  // OK
    updateStatus("cancelled"); // Error: not assignable to type 'OrderStatus'
    

    选择联合类型的另一个实际原因是:枚举会生成运行时代码,因此那些仅能去除类型信息的工具,比如 Node.js 内置的 TypeScript 支持,就无法识别枚举。

    模块与 tsconfig.json

    模块

    TypeScript 使用标准的 ES 模块语法。一个文件可以导出一个函数:

    // math.ts
    export function add(a: number, b: number): number {
      return a + b;
    }
    

    另一个文件则导入该函数:

    // app.ts
    import { add } from "./math";
    

    最重要的设置

    生成的配置文件会列出数十个选项,但只有少数几个真正决定了使用体验:

    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "strict": true,
        "noImplicitAny": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "outDir": "./dist"
      }
    }
    
    • strict: true 可以启用一系列严格的检查机制,包括 strictNullChecks,该选项要求必须明确处理 nullundefined。经验丰富的开发者认为这是不可或缺的,因为没有它 TypeScript 能发现的真实错误会少很多。
  • noImplicitAny 会在值本应自动回退为 any 而未经过用户明确指定的情况下报告错误。该选项早已包含在 strict 中,因此单独列出只是为了提高可读性。
  • target 决定了要生成哪个版本的 JavaScript,进而决定了需要为旧版运行环境重写多少现代语法。
  • 当你在禁用 strict 的情况下继承旧代码库时,可持续的做法是启用 strict 并逐个文件处理错误,而非不断使用 any 直到红色警告消失。

    各阶段的常见错误

    初学者

    • 为 TypeScript 本来就会推断出的值添加注解。
    • 一旦编译器报错就立即使用 any,而非尝试缩小实际类型范围。
  • 在可以进行代码审查的情况下,将编译器错误视为无关紧要的噪音而忽略。
  • 中级

    • 为那些本应由推理机制处理的单一形状声明interfacetype,徒增繁琐步骤却无实际意义。
    • 将数组定义为可变类型,并使用pushsplice来修改它们,而实际上它们应当是ReadonlyArray<T>类型并以不可变方式更新。
    • 忘记联合类型必须在限定具体类型后才能使用其成员,反而与编译器争执而非阅读它的提示信息。

    高级

    • 编写没有约束条件的泛型,使其可以表示任何类型,这实际上违背了使用泛型的初衷。
    • 为整个项目关闭strict模式,仅为了掩盖少量错误,而非修复这些错误或将其限制在特定范围内。
    • 将诸如 value as Type 这样的类型断言视为能够验证任何内容的手段。实际上,断言在运行时不会进行任何检查,它只是告诉编译器信任你。来自程序外部的数据,包括 API 响应、用户输入以及 localStorage 中的数据,都需要进行真正的验证。

    关键要点

    • TypeScript 为 JavaScript 添加了编译时检查机制;代码运行后类型就会消失,因此仍需对运行时的边界进行验证。
    • 为函数签名和空集合等结构添加注解,让类型推断处理其余部分。
    • 使用 interface 定义可扩展的对象结构,使用 type 表示联合类型、元组以及非对象类型。
    • 尽早学习区分联合类型,这是最能有效避免状态复杂代码中出现错误的模式。
    • PartialPickOmitRecord 等泛型与实用类型将零散的注解整合为真正的类型系统。
    • 请保持 strict: true 的设置。TypeScript 的价值在于能在早期发现那些否则会在后期以更高代价才被发现的错误,而只有让检查器能够正常工作才能实现这一点。

    相关阅读