TypeScript 继续学习(学习用)

0 阅读35分钟

一、为什么需要 TypeScript?

JavaScript 是动态类型语言,变量类型在运行时才能确定。这带来了灵活性,但也容易写出意外错误:比如把数字当作函数调用,或者访问对象上不存在的属性。

TypeScript = JavaScript + 静态类型系统。在写代码时给变量标注类型,TS 编译器会在编译阶段检查类型是否匹配,将错误提前暴露出来。

另外,类型注解是一份天然的“文档”,编辑器能利用它给出精准的智能提示,极大提升开发效率。

注意:TypeScript 的类型只在编译时存在,编译后的 JS 代码中全部类型会被擦除,不会影响运行。


二、基础类型:给数据贴上明确的标签

1. 原始类型

概念:与 JavaScript 的七种原始类型对应(string, number, boolean, null, undefined, symbol, bigint)。

为什么需要:让编译器知道变量只能赋特定类型值,防止意外。

let name: string = "Alice";   // name 只能是字符串
let age: number = 30;         // age 只能是数字
let isDone: boolean = false;  // isDone 只能是布尔值
let u: undefined = undefined;
let n: null = null;
let sym: symbol = Symbol("key");
let big: bigint = 100n;

注意null 和 undefined 是所有类型的子类型(在 strictNullChecks 关闭时),但建议开启 strict 以强制区分,避免出错。

2. 数组

概念:存储一组相同类型的数据。

let list: number[] = [1, 2, 3];         // 元素只能是数字
let list2: Array<number> = [1, 2, 3];  // 泛型写法,等价

原理number[] 语法更简洁,Array<number> 是使用泛型接口。两者编译结果相同。

3. 元组 (Tuple)

概念:一个已知元素数量和各自类型的数组,各位置类型可不同。

为什么需要:函数返回多个不同值、坐标点等固定结构。

let tuple: [string, number] = ["Alice", 25];
// 访问 tuple[0] 是 string,tuple[1] 是 number
// 越界访问会报错,比如tuple[2]会报错
 
tuple.push(null)
console.log(tuple)// [ 'Alice', 25, null ]

注意:元组长度是固定的,但 push 等操作目前无法被完全限制(历史遗留),尽量避免。

4. 枚举 (Enum)

概念:一组有名字的常量。默认数字枚举,也可以字符串枚举。

为什么需要:代替魔法数字或字符串,让代码更可读。

enum Direction {
  Up,    // 0
  Down,  // 1
  Left,  // 2
  Right  // 3
}
let dir: Direction = Direction.Up;

字符串枚举不给数值,但每个成员必须初始化:

enum Status {
  Active = "ACTIVE",
  Inactive = "INACTIVE"
}

原理:编译后,枚举会生成一个双向映射的对象(数字枚举),字符串枚举则为单向对象。使用 const enum 编译时不生成对象,直接内联值,性能更优。

注意:枚举不是 TypeScript 的类型级概念,它同时产生类型和运行时值。在需要常量集合时使用,否则可用联合字符串类型替代。

5. any、unknown、void、never

这些类型具有特殊含义:

  • any:放弃类型检查,与普通 JS 一样。应当少用,因为它破坏了类型安全。
  • unknown:安全的“any”。表示任意类型,但使用前必须用类型守卫收窄
  • void:函数没有返回值(返回 undefined)。
  • never:永远不会有返回值的函数(总是抛出异常或死循环)。
let x: any = 10;
x.toUpperCase(); // 编译器不报错,但运行时可能出错

let y: unknown = 10;
if (typeof y === "string") {
  y.toUpperCase(); // 必须检查类型后才能用
}

function warn(): void {
  console.log("warning");
}

function error(): never {
  throw new Error("出错啦!");
}

原理unknown 是类型安全的顶类型(所有类型都可以赋给它,但它是不可操作的),any 既是顶类型又是底类型(它可赋给任何类型,不检查),never 是底类型(所有类型的子类型,没有具体值)。这个层级叫做类型兼容性


三、接口(Interface)与类型别名(Type)

1. 接口 Interface

概念:定义一个对象的形状(有哪些属性,分别是什么类型)。

为什么需要:为对象结构提供契约,保证对象拥有规定的属性和方法。

interface User {
  name: string;
  age: number;
  readonly id: number;  // 只读,创建后不能改
  email?: string;       // 可选属性
}
const user: User = { name: "Alice", age: 25, id: 1 };
 user.id = 2; // 报错!

原理:TypeScript 采用结构类型系统(鸭子类型),只要对象的结构满足接口要求即可,不需要显式声明实现。

2. 类型别名 Type

概念:给一个类型起个新名字,可以是任意类型。

type Point = {
  x: number;
  y: number;
};
type ID = string | number;

3. Interface 与 Type 的区别(面试重点)

InterfaceType
扩展方式extends 继承交叉类型 &
重复定义同名接口自动合并同名 type 报错
适用范围主要描述对象、函数等任何类型(联合、元组、映射等)
性能接口可以被缓存,更适合扩展类型别名只是别名
// 接口合并
interface A { x: number; }
interface A { y: number; }  // 合并 => { x: number; y: number; }

// 类型别名不支持合并
type B = { x: number; };
type B = { y: number; }; // 报错

建议:优先使用 interface 描述对象形状,当需要联合类型、重命名基本类型、元组等时用 type


四、联合类型与交叉类型

1. 联合类型 (Union)

概念:一个值可以是几种类型之一,用 | 分隔。

为什么需要:处理不确定的类型,如 string | number 的 ID。

let id: string | number = "abc";
id = 123; // 合法

注意:联合类型只能访问所有类型共有的属性。要使用特有属性,需先类型收窄(见下节)。

2. 交叉类型 (Intersection)

概念:将多个类型合并成一个类型,用 & 连接。

为什么需要:组合多个对象或接口的特性。

interface A { a: number; }
interface B { b: string; }
type C = A & B; // { a: number; b: string; }

原理:交叉类型是求并集(需同时满足),联合类型是求或集(满足其中一个即可)。交叉常用于合并多个对象类型,如混入模式。

2.1. 同名属性类型冲突 → 推导为 never

这是最常见的报错来源。当两个类型中同名属性的类型没有交集时,交叉后该属性的类型变成 never

interface X {
  value: string;
}
interface Y {
  value: number;
}
type Z = X & Y;
// Z 的类型相当于 { value: string & number }  =>  { value: never }

原理string & number 没有哪个值能同时是 string 又是 number,所以交点为空,即 never。实际使用时,你会收到类型错误:

const obj: Z = {
  value: "hello" // ❌ 报错:不能将类型 'string' 分配给类型 'never'
};

或者试图赋值时直接报错:

const test: Z = { value: 1 }; // ❌ 同样错误

2.2. 函数类型的交叉

如果冲突发生在函数类型上,交叉会形成函数重载,这通常不会报错,但调用时可能会限制为 never(取决于参数)。

type Fn1 = (x: string) => void;
type Fn2 = (x: number) => void;
type Fn = Fn1 & Fn2;
// 相当于 (x: string & number) => void => (x: never) => void

此时调用该函数:

declare const fn: Fn;
fn("hello"); // ❌ 报错:类型 'string' 的参数不能赋给类型 'never'
fn(123);     // ❌ 同样错误

函数交叉通常用于重载签名,正确的做法是使用函数重载声明或泛型。


2.3. 对象类型与方法签名的冲突

interface Logger {
  log(msg: string): void;
}
interface Printer {
  log(msg: number): void;
}
type Hybrid = Logger & Printer;
// log 的类型变成 ((msg: string) => void) & ((msg: number) => void)
// 即 (msg: string & number) => void  -> (msg: never) => void

调用时会报错,因为不能同时是 string 和 number


2.4. 使用条件类型检测 never

type IsNever<T> = [T] extends [never] ? true : false;
type Test = IsNever<Z['value']>; // true

2.5. 何时交叉类型不会报错?

  • 同名属性的类型兼容(如 string 和 string,或 {a:number} 和 {b:string} 合并成 {a:number; b:string})。
  • 属性和方法签名兼容(同一类型或子类型)。
  • 使用交叉扩展已有接口(如 type Extended = Base & { extra: boolean })。

2.6. 总结:交叉类型报错的根本原因

交叉类型本质上求的是“类型的交集” ,当交集为空时(即无法找到一个值同时满足两边),就会表现为 never,从而导致赋值错误。这是 TypeScript 保证类型安全的重要机制——它不会让你创造“既要求是 string 又要求是 number”的变量,因为那不可能存在。


五、类型守卫与类型收窄

概念:TypeScript 会分析代码逻辑,在条件判断后自动缩小变量的类型范围。

为什么需要:安全地使用联合类型中的特定方法。

function printId(id: string | number) {
  if (typeof id === "string") {
    // 这里 id 被收窄为 string
    console.log(id.toUpperCase());
  } else {
    // 这里 id 为 number
    console.log(id.toFixed(2));
  }
}

常用守卫

  • typeof:用于原始类型
  • instanceof:用于类实例
  • in:检查属性是否存在
  • 自定义类型谓词:arg is Type 形式
interface Cat { meow(): void; }
interface Dog { bark(): void; }

function isCat(animal: Cat | Dog): animal is Cat {
  return (animal as Cat).meow !== undefined;
}
if (isCat(pet)) {
  pet.meow(); // 这里 pet 是 Cat
}

原理animal is Cat 是类型谓词,函数返回 true 时,TypeScript 会将参数类型收窄为 Cat。这是从任意联合类型中提取类型的重要手段。

1. 什么是类型谓词?

类型谓词是 TypeScript 中一种特殊的返回值类型,语法为 parameterName is Type。它用于自定义类型守卫(User-Defined Type Guard)。当一个函数的返回类型是类型谓词时,TypeScript 会根据函数的 true/false 结果,在后续代码中自动收窄参数的类型。

简单说,就是告诉编译器:“如果这个函数返回 true,那么参数就是某个特定类型”。

2. 为什么需要自定义类型守卫?

TypeScript 的内置类型收窄(如 typeofinstanceofin)只能处理一些简单情况。当联合类型中的多个类型具有不同的结构时,我们可以自己编写更精确的判断函数。

例如, 有 Cat 和 Dog 两个接口:

interface Cat {
  meow(): void;
}
interface Dog {
  bark(): void;
}

现在有一个函数接收 Cat | Dog 类型的参数,我们想调用 meow(),但直接调用会报错,因为 Dog 上没有 meow 方法:

function makeSound(animal: Cat | Dog) {
  animal.meow(); // ❌ 报错:类型“Cat | Dog”上不存在属性“meow”
}

我们需要先判断它是 Cat 还是 Dog。可以使用 in 操作符:

if ("meow" in animal) {
  animal.meow(); // 这里 animal 被收窄为 Cat
}

但有时候判断逻辑更复杂,或者想要更清晰的语义,就可以使用自定义类型守卫。

3. 详解 isCat 函数

function isCat(animal: Cat | Dog): animal is Cat {
  return (animal as Cat).meow !== undefined;
}
  • 参数类型animal: Cat | Dog,表示传入的 animal 可能是 Cat 或 Dog。
  • 返回类型animal is Cat,这是一个类型谓词。它告诉 TypeScript:“如果这个函数返回 true,那么参数 animal 就是 Cat 类型”。
  • 函数体(animal as Cat).meow !== undefined。先用 as Cat 进行类型断言,将 animal 当作 Cat 来访问 meow 属性,然后判断它是否不等于 undefined。如果是,说明 animal 拥有 meow 方法,推断它是 Cat。

为什么这样做?
我们无法直接访问联合类型中不共有的属性,所以用类型断言临时“假设”它是 Cat,然后检查特有属性 meow 是否存在。这比 in 操作符更显式,适合复杂检查。

4. 使用 isCat 后的效果

function interact(animal: Cat | Dog) {
  if (isCat(animal)) {
    // 在这个分支里,animal 被自动收窄为 Cat
    animal.meow(); // ✅ 合法
  } else {
    // 这里 animal 被收窄为 Dog
    animal.bark(); // ✅ 合法
  }
}

正确和错误调用的例子:

const cat: Cat = { meow: () => console.log("Meow!") };
const dog: Dog = { bark: () => console.log("Woof!") };

// ✅ 正确使用
if (isCat(cat)) {
  cat.meow(); // 输出 "Meow!"
}

if (!isCat(dog)) {
  dog.bark(); // 输出 "Woof!"
}

// ❌ 错误使用:不能将 Cat 赋给 Dog 或反过来
// isCat 只接受 Cat | Dog,传入其他类型会报错
 isCat(42); // 报错:类型“number”的参数不能赋给类型“Cat | Dog”的参数

注意:自定义类型守卫只是编译时的类型收窄,不会在运行时保证类型安全。如果函数体实现有误(例如总是返回 true),可能会导致运行时错误。所以必须确保守卫函数的逻辑是正确的。


六、函数类型

概念:指定函数的参数类型和返回值类型。

为什么需要:明确调用方式,防止传入错误参数。

function add(x: number, y: number): number {
  return x + y;
}
//正确调用
add(1, 2);         // 返回 3
add(0.5, -3);      // 返回 -2.5
add(100, 200);     // 返回 300

//错误调用及原因:
add(1);            // ❌ 缺少第二个参数
add(1, 2, 3);      // ❌ 多余参数
add("1", 2);       // ❌ 第一个参数类型错误:string 不能赋给 number
add(1, "2");       // ❌ 第二个参数类型错误
add(true, false);  // ❌ boolean 不能赋给 number
//解释:`add` 的参数和返回值都明确为 `number`,TypeScript 会严格检查参数数量和类型。多传或少传都会报错

// 可选参数与默认值
function greet(name: string, title?: string): string {
  return title ? `${title} ${name}` : name;
}
//正确调用:
greet("Alice");               // 返回 "Alice"
greet("Bob", "Dr.");          // 返回 "Dr. Bob"
greet("Charlie", "");         // 返回 "Charlie"(因为空字符串是 falsy,取 name)
greet("Diana", "Professor");  // 返回 "Professor Diana"

//错误调用:
greet();                      // ❌ 缺少必选参数 name
greet(123);                   // ❌ name 必须是 string
greet("Eve", "Ms.", "extra"); // ❌ 多余参数
greet(true, false);           // ❌ 类型错误
greet("Frank", 5);            // ❌ title 只能是 string 或 undefined
//解释:`title?` 表示可选参数,可以传递 `string` 或 `undefined`,但不能传其他类型。在调用时,可选参数可以省略,但必选参数 `name` 必须提供。

// 剩余参数
function sum(...nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}
//正确调用:
sum();            // 返回 0(空数组求和)
sum(1);           // 返回 1
sum(1, 2, 3);     // 返回 6
sum(10, 20, 30, 40); // 返回 100

//错误调用:
sum(1, 2, "3");   // ❌ "3" 不是 number
sum([1, 2, 3]);   // ❌ 传入的是数组,不是展开的数字列表
sum(null);        // ❌ null 不能赋给 number
sum(undefined);   // ❌ undefined 不能赋给 number
//解释:`...nums: number[]` 表示收集所有剩余参数到一个 `number[]` 数组。调用时必须传入零个或多个 `number`,不能传入数组整体(除非使用展开语法 `sum(...[1,2,3])`,但那是将数组展开成参数)。传入非数字类型会报错。

注意:可选参数必须放在必选参数之后。函数重载(多个函数头签名)可以精确描述多种调用方式。

// 函数重载:声明多种调用形式
function combine(a: string, b: string): string;
function combine(a: number, b: number): number;
// 实现签名
function combine(a: any, b: any): any {
  return a + b;
}
combine("Hello ", "World"); // 返回 string
combine(1, 2);              // 返回 number
// combine("a", 2);         // 报错!没有匹配的重载

原理:重载声明只是类型层面的信息,实现签名必须兼容所有重载声明。编译器根据传入参数类型选择对应声明检查。

6.1、为什么要函数重载?

JavaScript 中经常有一些函数,根据传入参数的类型或数量,执行不同的逻辑,返回不同类型。例如 Array.prototype.slice

  • 不传参数时返回数组的浅拷贝(数组)
  • 传入数字时返回子数组(数组)

如果用 TypeScript 写这个函数,我们希望:

function slice(): number[];
function slice(start: number): number[];
function slice(start: number, end: number): number[];

三种调用方式返回的都是 number[],但这里签名已经多了。再比如一个 padLeft,传入数字和字符串的行为完全不同:

padLeft("hello", 10);   // 返回 "     hello",期望类型是 string
padLeft(123, "0");      // 返回 "00123",期望类型也是 string

虽然看起来简单,但当参数类型决定返回值类型,或者参数数量不同导致处理方式不同时,函数重载就能精确地捕获这些调用模式,提供更准确的类型检查。

没有重载,你只能写一个笼统的实现,用 any 或联合类型,这样会丢失调用时对参数约束的精度。


6.2、示例代码详解

6.2.1. 重载声明

来看回上面的代码

function combine(a: string, b: string): string;
function combine(a: number, b: number): number;

这两行被称为重载签名,它们定义了 combine 函数的两种合法调用方式:

  • 传入两个 string,返回 string
  • 传入两个 number,返回 number

这两个签名只用于类型检查,不包含函数体。TypeScript 会记住这些调用模式。

6.2.2. 实现签名

function combine(a: any, b: any): any {
  return a + b;
}

这是唯一真正的函数体,被称为实现签名。它的参数类型和返回类型必须兼容所有重载签名(通常用更宽泛的类型如 any、联合类型等来涵盖所有可能)。

在这里,a 和 b 是 any,返回值也是 any,因为实际运行时可能返回 string 或 number。函数体中的 a + b 根据 JavaScript 的 + 规则:字符串拼接或数字相加。

6.2.3. 实际调用与类型检查

combine("Hello ", "World"); // 返回 string,类型推断为 string
combine(1, 2);              // 返回 number,类型推断为 number

这两次调用分别匹配第一个和第二个重载签名。TypeScript 根据传入参数的类型,选择对应的重载签名,并知道返回值的精确类型。

6.2.4. 错误调用

// combine("a", 2); // ❌ 报错:没有匹配的重载

为什么报错?
因为 combine("a", 2) 的参数是 string 和 number,既不符合 (string, string),也不符合 (number, number)。TypeScript 在重载签名列表中逐个检查,找不到匹配项,于是抛出错误。即使实现签名用了 any,实现签名也不作为直接调用的类型签名,它只用来检查函数体是否兼容所有重载签名。


6.3、重载的规则和原理

  1. 重载签名写在实现签名前面,而且之间不能有函数体。
  2. 实现签名不可见给外部调用者。外部调用时只能看到重载签名列表。
  3. 实现签名的参数与返回类型必须兼容所有重载签名。通常使用联合类型或 any 来涵盖。
  4. 匹配时按顺序检查,第一个匹配的签名胜出(所以更具体的签名放在前面)。

6.4、生活类比

想象一个“榨汁机”:

  • 如果你放入苹果,它给你苹果汁。
  • 如果你放入橙子,它给你橙汁。
  • 你不能放入石头,因为榨汁机不接受石头。

重载签名就是这台榨汁机的使用说明书,上面写着:可以放苹果(返回苹果汁),可以放橙子(返回橙汁)。实现则是机器内部的刀片,管你放什么进去,它都会切,但说明书(类型系统)限制了你只能放苹果或橙子。


6.5、更多重载示例

例1:根据参数类型返回不同结果

function getInfo(id: number): { id: number; name: string };
function getInfo(name: string): { id: number; name: string }[];
function getInfo(param: number | string): any {
  if (typeof param === "number") {
    return { id: param, name: "Alice" };
  } else {
    return [{ id: 1, name: param }];
  }
}

getInfo(1);      // 返回 { id: 1, name: 'Alice' }
getInfo("Bob");  // 返回 [{ id: 1, name: 'Bob' }]

例2:参数个数不同

function print(value: string): void;
function print(value: string, times: number): void;
function print(value: string, times?: number): void {
  const repeat = times ?? 1;
  for (let i = 0; i < repeat; i++) {
    console.log(value);
  }
}

print("Hello");       // 输出一次
print("World", 3);    // 输出三次

七、泛型(Generics)—— 写出灵活又安全的代码

概念

泛型就像是“类型变量”,在定义函数、接口或类时不预先指定具体类型,而在使用的时候再确定。

生活类比:一个空盒子,你可以放进苹果,也可以放进书。盒子本身不限制内容,但当你放进去之后,它就确定了内容类型。

为什么需要泛型?

  • 保持代码复用性:同一个函数可以处理不同类型的数据。
  • 保证类型安全:使用泛型后,传入和返回的类型保持一致,不会丢失类型信息。

基础用法

function identity<T>(arg: T): T {
  return arg;
}

// 调用方式1:显式指定类型
let output1 = identity<string>("hello");
// 调用方式2:类型推断
let output2 = identity(123); // output2 为 number 类型

这里 <T> 就是泛型变量,可以理解为“类型的占位符”。identity 函数接收任意类型 T,并返回同类型值。

泛型约束

有些时候,你想限制泛型参数必须具有某些属性,比如有 length 属性。使用 extends 关键字:

interface Lengthwise {
  length: number;
}
function logLength<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}
logLength("abc"); // OK, string 有 length
logLength([1,2]); // OK
logLength(123); // 报错,number 没有 length

原理T extends Lengthwise 告诉编译器,T 必须满足 Lengthwise 类型(具有 length: number)。这样函数体内就可以安全访问 arg.length

泛型接口与泛型类

interface Result<T> {
  data: T;
  success: boolean;
}
const res: Result<string> = { data: "ok", success: true };

class Stack<T> {
  private items: T[] = [];
  push(item: T) { this.items.push(item); }
  pop(): T | undefined { return this.items.pop(); }
}
const numberStack = new Stack<number>();
numberStack.push(10);

注意:泛型在类中为实例方法提供类型安全,静态成员不能使用类的泛型。


八、高级类型:构建类型的强大工具

8.1、索引类型查询 keyof

概念

keyof T 取出类型 T 的所有键名,生成一个联合类型

为什么需要?

当你想限制某个变量只能是对象的合法键名时,而不是任意的 string,就可以用 keyof

示例

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

type PersonKeys = keyof Person; // "name" | "age"

使用场景

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person: Person = { name: "Alice", age: 25 };
const n = getProperty(person, "name"); // n 是 string
 getProperty(person, "email"); // ❌ 报错,email 不是 keyof Person

原理与注意

  • keyof 对对象类型返回其所有公共属性名的联合。
  • 对联合类型,keyof 返回所有联合成员共有的属性名(交集)。
  • 对 anykeyof any 是 string | number | symbol

8.2、索引访问类型 T[K]

概念

像访问对象属性一样访问类型,T[K] 得到类型 T 中属性 K 的类型。

示例

type PersonName = Person["name"]; // string
type PersonAge = Person["age"];   // number

结合 keyof

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

这里 T[K] 表示:返回值类型就是对象中该属性对应的类型。调用 getProperty(person, "name") 返回类型自动为 string

注意

  • K 必须满足 extends keyof T,否则报错。
  • 可以通过联合类型一次性获取多种属性的类型:Person["name" | "age"] 得到 string | number

8.3、映射类型(Mapped Types)

概念

基于已有类型的键,通过遍历键来生成一个新类型,并可以给每个属性加上修饰符(readonly?)或改变值的类型。

为什么需要?

比如你有一个 Todo 接口,想生成一个所有字段都可选的版本,或者所有字段只读的版本。手工重复定义非常繁琐且容易遗漏。

基础语法

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
type Partial<T> = {
  [K in keyof T]?: T[K];
};
  • [K in keyof T]:遍历 T 的所有键,K 在每次迭代中是一个键名字面量。
  • 右侧 T[K]:取出该键对应的类型。

示例

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type ReadonlyTodo = Readonly<Todo>;
// { readonly title: string; readonly description: string; readonly completed: boolean; }

type PartialTodo = Partial<Todo>;
// { title?: string; description?: string; completed?: boolean; }

更进一步:带条件的映射

你可以使用 as 子句对键进行重新映射(TypeScript 4.1+):

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User {
  name: string;
  age: number;
}
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number; }

这里 as 重命名了键,Capitalize 是内置工具类型,将首字母大写。

原理与注意

  • 映射类型生成新的对象类型,不会影响原类型。
  • 可以组合多个修饰符,例如 -readonly 移除只读,-? 移除可选。
  • 遍历的是 keyof T,所以只处理对象类型的属性。

8.4、条件类型(Conditional Types)

概念

根据某个类型是否满足条件,返回不同的类型。语法类似于三元表达式:T extends U ? X : Y

为什么需要?

很多工具类型需要根据输入类型的不同输出不同结果,比如提取函数返回类型、排除 null 等。

基础示例

type IsString<T> = T extends string ? true : false;
type A = IsString<"abc">; // true
type B = IsString<42>;    // false

分布式条件类型

当条件类型的左侧是裸类型参数(直接写成 T extends ...)且 T 是联合类型时,条件类型会自动分布到联合类型的每个成员上。

type ToArray<T> = T extends any ? T[] : never;
type StrOrNumArray = ToArray<string | number>; // string[] | number[]
// 而不是 (string | number)[]

这个特性是实现 ExcludeExtract 等工具类型的基础。

注意

  • 要避免分布,可以用元组包裹:[T] extends [U] ? ...
  • 条件类型中可以使用 infer 进行类型推断。

8.5、infer 关键字

概念

infer 只能在条件类型的 extends 子句中使用,用于声明一个待推断的类型变量。

为什么需要?

当你需要从一个复杂类型中提取出某个部分时(比如函数返回值类型、Promise 包裹的类型),infer 可以帮你“解包”。

示例:提取函数返回类型

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Fn = (x: number) => string;
type R = ReturnType<Fn>; // string

解释:

  • T extends (...args: any[]) => infer R:如果 T 是一个函数类型,那么推断出它的返回值类型并赋值给 R
  • 条件为真时返回 R,否则返回 never

更多应用

  • 提取数组元素类型:T extends (infer U)[] ? U : never
  • 提取 Promise 结果类型:T extends Promise<infer V> ? V : never
  • 提取构造函数实例类型:T extends new (...args: any[]) => infer I ? I : never

原理与注意

  • infer 只能在 extends 的 true 分支中使用。
  • 同一个条件类型可以同时有多个 infer,例如提取函数参数类型:T extends (...args: infer P) => any ? P : never

8.6、常用的内置工具类型

TypeScript 内置了许多基于上述特性实现的工具类型,掌握它们可以大幅提升开发效率。

1. Partial<T> —— 所有属性变为可选

作用:把类型 T 的所有属性都变成可选的(加上 ?)。

为什么需要:当你需要更新一个对象的某些字段时,可以传递部分属性而不必提供全部。

示例

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type PartialTodo = Partial<Todo>;

输出结果类型

{
  title?: string;
  description?: string;
  completed?: boolean;
}

使用效果

const todo1: PartialTodo = { title: "Learn TS" }; // ✅ 只传 title
const todo2: PartialTodo = {};                    // ✅ 空对象也行
 const todo3: Todo = { title: "Learn TS" };     // ❌ 缺少其他属性

解释Partial 内部遍历 T 的所有键,给每个属性前加上 ?,相当于把“必须填”变成了“选填”。


2. Required<T> —— 所有属性变为必选

作用:把类型 T 的所有属性都变成必填的(去掉 ?)。

为什么需要:有时从一个可选类型出发,需要生成一个所有字段都强制存在的类型。

示例

interface Config {
  host?: string;
  port?: number;
  debug?: boolean;
}

type RequiredConfig = Required<Config>;

输出结果类型

{
  host: string;
  port: number;
  debug: boolean;
}

使用效果

const cfg: RequiredConfig = {
  host: "localhost",
  port: 8080,
  debug: false
}; // ✅ 必须三个全给
 const cfg2: RequiredConfig = { host: "localhost" }; // ❌ 缺少 port 和 debug

解释Required 内部使用了 -? 修饰符,意思是“移除可选”,强制每个属性都必填。


3. Readonly<T> —— 所有属性变为只读

作用:把类型 T 的所有属性都变成只读的(加上 readonly)。

为什么需要:保证对象一旦创建就不能被修改,常用于配置对象或状态快照。

示例

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

type ReadonlyUser = Readonly<User>;

输出结果类型

{
  readonly name: string;
  readonly age: number;
}

使用效果

const user: ReadonlyUser = { name: "Alice", age: 25 };
user.name = "Bob"; // ❌ 报错:无法分配到 "name",因为它是只读属性

解释Readonly 内部遍历所有键,给每个属性前加上 readonly,防止修改。


4. Pick<T, K> —— 从 T 中挑选指定属性

作用:从类型 T 中只挑选出 K 中指定的属性,构造一个新类型。

为什么需要:当你只需要一个对象中的少数几个字段时,比如展示预览信息。

示例

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

输出结果类型

{
  title: string;
  completed: boolean;
}

使用效果

const preview: TodoPreview = {
  title: "Learn TS",
  completed: false
}; // ✅ 只含两个属性
const preview2: TodoPreview = { title: "TS" }; // ❌ 缺少 completed

解释Pick 第一个参数是源类型,第二个参数是要保留的键的联合类型。内部遍历 K,取 T[K] 作为值类型。


5. Omit<T, K> —— 从 T 中删除指定属性

作用:从类型 T 中剔除 K 中指定的属性,返回剩下的。

为什么需要:与 Pick 相反,当你知道不需要哪些字段时,用 Omit 更直观。

示例

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoWithoutDesc = Omit<Todo, "description">;

输出结果类型

{
  title: string;
  completed: boolean;
}

使用效果

const todo: TodoWithoutDesc = {
  title: "Learn TS",
  completed: true
}; // ✅ 不用写 description
const todo2: TodoWithoutDesc = { title: "TS" }; // ❌ 缺少 completed

解释Omit 内部先通过 Exclude<keyof T, K> 得到剩余的键,再配合 Pick 生成新类型。本质上相当于 Pick<T, Exclude<keyof T, K>>


6. Record<K, T> —— 构造对象类型

作用:创建一个对象类型,它的键都是 K 类型,值都是 T 类型。

为什么需要:快速定义字典、映射表、配置集等场景。

示例

type Page = "home" | "about" | "contact";
type PageInfo = Record<Page, { title: string }>;

输出结果类型

{
  home: { title: string };
  about: { title: string };
  contact: { title: string };
}

使用效果

const pages: PageInfo = {
  home: { title: "Home" },
  about: { title: "About" },
  contact: { title: "Contact" }
}; // ✅ 键必须全部包含
const pages2: PageInfo = { home: { title: "A" } }; // ❌ 缺少 about 和 contact

解释Record 第一个参数 K 必须能赋值给 string | number | symbol,第二个参数 T 是值的类型。内部遍历 K 的每个成员,构造一个属性,所有值类型都是 T


7. Exclude<T, U> —— 从联合类型中排除

作用:从联合类型 T 中剔除所有能赋值给 U 的成员。

为什么需要:去除联合类型中的某些特定类型。

示例

type Mixed = string | number | boolean;
type OnlyPrimitive = Exclude<Mixed, boolean>;

输出结果类型

string | number

使用效果

let x: OnlyPrimitive;
x = "hello"; // ✅
x = 42;      // ✅
x = true; // ❌ boolean 已被排除

解释Exclude 利用了条件类型的分布式特性,对 T 的每个成员执行 T extends U ? never : T,如果该成员能赋值给 U,就返回 never(从结果中消失),否则保留。


8. Extract<T, U> —— 从联合类型中提取

作用:从联合类型 T 中提取出所有能赋值给 U 的成员。

为什么需要:与 Exclude 相反,获取交集部分。

示例

type Mixed = string | number | boolean;
type StringOrBool = Extract<Mixed, string | boolean>;

输出结果类型

string | boolean

使用效果

let y: StringOrBool;
y = "abc";  // ✅
y = false;  // ✅
// y = 10;  // ❌ number 不在提取范围内

解释:内部实现是 T extends U ? T : never,只保留能赋值给 U 的成员。


9. NonNullable<T> —— 排除 null 和 undefined

作用:从类型 T 中剔除 null 和 undefined

为什么需要:确保一个值不为空,常用于严格模式下。

示例

type MaybeString = string | null | undefined;
type SureString = NonNullable<MaybeString>;

输出结果类型

string

使用效果

let s: SureString;
s = "hello"; // ✅
s = null;    // ❌
s = undefined; // ❌

解释:底层实现 T extends null | undefined ? never : T,把空值过滤掉。也可以写成 T & {} 达到相似效果。


10. ReturnType<T> —— 获取函数返回值类型

作用:提取一个函数类型的返回值类型。

为什么需要:当你需要复用某个函数的返回值类型,或者做依赖推断时。

示例

function createUser(name: string, age: number) {
  return { name, age, id: Math.random() };
}

type UserReturn = ReturnType<typeof createUser>;

输出结果类型

{
  name: string;
  age: number;
  id: number;
}

使用效果

const user: UserReturn = {
  name: "Alice",
  age: 25,
  id: 0.12345
}; // ✅ 结构与函数返回值一致
 const user2: UserReturn = { name: "Bob" }; // ❌ 缺少 age 和 id

解释ReturnType 内部用 infer 推断返回值类型。typeof createUser 获取函数类型,然后 extends (...args: any[]) => infer R ? R : any 提取 R


11. Parameters<T> —— 获取函数参数类型

作用:提取一个函数类型的参数列表类型,以元组形式返回。

为什么需要:需要复用某个函数的参数列表,或做参数转发。

示例

function createUser(name: string, age: number) {
  return { name, age };
}

type CreateUserParams = Parameters<typeof createUser>;

输出结果类型

[name: string, age: number]

使用效果

const args: CreateUserParams = ["Alice", 25]; // ✅ 按参数顺序和类型提供
const args2: CreateUserParams = [123, "Bob"]; // ❌ 类型顺序不对

解释:内部用 infer 提取:T extends (...args: infer P) => any ? P : never


12. Awaited<T> —— 递归解包 Promise

作用:获取 Promise 最终 resolve 的类型,支持嵌套 Promise

为什么需要:异步函数经常包装多层 Promise,想获得最终结果类型。

示例

type X = Promise<string>;
type Y = Promise<Promise<number>>;
type Z = Promise<Promise<Promise<boolean>>>;

type AX = Awaited<X>; // string
type AY = Awaited<Y>; // number
type AZ = Awaited<Z>; // boolean

输出结果类型

string, number, boolean

使用效果

async function fetchData(): Promise<string> {
  return "data";
}
type Data = Awaited<ReturnType<typeof fetchData>>; // string
// 直接得到 string,而不是 Promise<string>

解释Awaited 内部递归:T extends Promise<infer V> ? Awaited<V> : T,直到剥到非 Promise 类型为止。


综合应用举例

实际项目中,常常组合使用这些工具类型:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

// 返回给前端的用户信息,去掉 password
type PublicUser = Omit<User, "password">;

// 前端请求到的响应体数据类型
type UserResponse = ApiResponse<PublicUser>;
// 结果:{ data: { id: number; name: string; email: string }; status: number; message: string; }

// 更新用户时只传部分字段
type UpdateUserPayload = Partial<Pick<User, "name" | "email">>;
// 结果:{ name?: string; email?: string; }

8.7、高级类型组合实例:DeepReadonly

利用映射类型、条件类型和递归,实现深层只读:

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K];
};

interface Config {
  db: {
    host: string;
    port: number;
  };
  debug: boolean;
}

type ReadonlyConfig = DeepReadonly<Config>;
// { readonly db: { readonly host: string; readonly port: number }; readonly debug: boolean; }

这个例子综合了 keyof、映射类型、条件类型和递归,足以体现高级类型的强大。


九、类(Class)在 TypeScript 中的增强

9.1、类的基本写法(类型注解)

TypeScript 允许在类中为属性添加类型注解,构造函数参数也可以标注类型。

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

const p = new Point(10, 20);
p.x = 30; // ✅
p.x = "hello"; // ❌ 类型错误

解释:属性 x 和 y 的类型都是 number,任何赋值都会检查类型。如果没有显式声明属性,直接在 constructor 中 this.x = x 会报错,因为 TS 要求先声明属性。


9.2、访问修饰符 —— 控制属性和方法的可见性

访问修饰符决定了属性或方法可以在哪里被访问。TypeScript 提供了三个修饰符:

  • public(默认):任何地方都能访问。
  • protected:当前类和子类能访问,类外部不能访问。
  • private:只有当前类自己能访问,子类和外部都不能访问。

9.2.1. public 显式或默认

class Animal {
  public name: string;   // 等价于不写修饰符

  constructor(name: string) {
    this.name = name;
  }
}

const cat = new Animal("Kitty");
console.log(cat.name); // ✅ 外部可读
cat.name = "Tom";      // ✅ 外部可写

解释:不加修饰符就是 public,外部可随意访问。

9.2.2. protected —— 允许子类访问

class Animal {
  protected age: number;

  constructor(age: number) {
    this.age = age;
  }

  protected getAge(): number {
     // ✅ 内部可以访问 protected 属性
    return this.age;
  }
}

class Dog extends Animal {
  constructor(age: number) {
    super(age);
  }

  public showAge() {
    console.log(this.age);    // ✅ 子类可以访问 protected 属性
    console.log(this.getAge()); // ✅ 子类可以调用 protected 方法
  }
}

const dog = new Dog(3);
dog.showAge(); // 3
console.log(dog.age);      // ❌ 错误:属性“age”是受保护的,只能在类及子类中访问
dog.getAge();              // ❌ 错误

解释age 和 getAge() 都是 protected,只能在 Animal 内部及其子类 Dog 中访问,实例 dog 外部不能访问。这样的封装可以对外隐藏内部细节,又能让派生类继承。

9.2.3. private —— 只有自身能访问

class Animal {
  private secret: string;

  constructor(secret: string) {
    this.secret = secret;
  }

  public reveal() {   
    return this.secret; // ✅ 类内部可以访问
  }
}

class Cat extends Animal {
  constructor() {
    super("cat secret");
  }

  public tryReveal() {
    return this.secret; // ❌ 错误:属性“secret”为私有属性,只能在类“Animal”中访问
  }
}

const animal = new Animal("my secret");
console.log(animal.reveal()); // ✅
console.log(animal.secret); // ❌ 错误

解释private 只有当前类内部能访问,子类也不行。这用来隐藏真正的内部实现。

9.2.4. readonly —— 只读属性

class Person {
  readonly id: number;
  public name: string;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

const p = new Person(1, "Alice");
console.log(p.id); // ✅
 p.id = 2;       // ❌ 错误:无法分配到 "id",因为它是只读属性
p.name = "Bob";    // ✅ 非只读属性可以修改

解释readonly 属性只能在声明时或构造函数中赋值,之后不能修改。适合用作 ID、创建时间等不可变数据。

9.2.5. 参数属性 —— 简写形式

在构造函数参数前直接加上访问修饰符或 readonly,可以同时声明属性和赋值。

class Employee {
  constructor(
    public name: string,
    private salary: number,
    readonly id: number
  ) {
    // 不需要手动赋值,TS 会自动完成
  }
}

const emp = new Employee("Bob", 5000, 101);
console.log(emp.name); // ✅
console.log(emp.salary); // ❌ 私有属性
 emp.id = 102;            // ❌ 只读

解释public name 相当于在类中声明了 public name: string,然后在构造函数中自动把参数值赋给 this.name。这大大减少了样板代码。


9.3、抽象类 —— 不能被实例化的类

概念与为什么需要

当你有一个基类,它本身不应该被直接 new 出来,而应该由子类去实现具体逻辑时,就用抽象类。抽象类只描述“应该有什么”,不负责具体实现。

  • 用 abstract 关键字标记类或方法。
  • 抽象方法只有签名,没有方法体,子类必须实现。
  • 可以包含具体实现的方法(非抽象方法)。

示例

abstract class Shape {
  abstract getArea(): number; // 抽象方法:子类必须实现

  // 具体方法:子类可以直接用
  describe(): void {
    console.log("This shape's area is " + this.getArea());
  }
}

class Circle extends Shape {
  constructor(public radius: number) {
    super(); // 必须调用基类构造函数
  }

  // 实现抽象方法
  getArea(): number {
    return Math.PI * this.radius ** 2;
  }
}

 const shape = new Shape(); // ❌ 错误:不能创建抽象类的实例
const circle = new Circle(10);
circle.describe(); // This shape's area is 314.159...
console.log(circle.getArea()); // 314.159...

正确与错误

// ✅ 子类继承抽象类,实现了 getArea
class Rectangle extends Shape {
  constructor(public width: number, public height: number) {
    super();
  }
  getArea(): number {
    return this.width * this.height;
  }
}

// ❌ 没有实现抽象方法 getArea,会报错
class Square extends Shape {
  constructor(public side: number) {
    super();
  }
   // 缺少 getArea
}

解释:抽象类强制子类遵循约定的接口,让设计更清晰。


9.4、类实现接口 (implements)

概念与为什么需要

有时你想让一个类必须包含某些属性和方法,但不需要从某个父类继承实现。这时可以用接口定义契约,用 implements 来保证类符合接口。

示例

interface Printable {
  content: string;
  print(): void;
}

class Document implements Printable {
  content: string;

  constructor(content: string) {
    this.content = content;
  }

  print(): void {
    console.log(this.content);
  }
}

const doc = new Document("Hello TypeScript");
doc.print(); // Hello TypeScript

错误示例

// ❌ 缺少 content 属性或 print 方法
 class Report implements Printable {
  print() { console.log("empty"); }
 } 
// 报错:类“Report”缺少属性“content”

解释implements 只在类型层面进行检查,编译后不产生任何代码。一个类可以实现多个接口,用逗号分隔。


9.5、类既是值又是类型

在 TypeScript 中,class 声明既创建了运行时值(构造函数),也创建了类型(实例的类型)。

class Cat {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

// 作为类型使用
let cat: Cat;               // Cat 是实例的类型
cat = new Cat("Kitty");     // ✅
cat = { name: "Tom" };     // ✅ 结构类型兼容

// 作为值使用(构造函数本身)
const CatCtor: typeof Cat = Cat;
const anotherCat = new CatCtor("Jerry");

解释:当你写 let c: Cat 时,Cat 是实例的类型;当你写 typeof Cat 时,拿到的是构造函数本身的类型。这种双重性质在依赖注入、工厂函数中很有用。


9.6、重要注意事项

9.6.1. TypeScript 的 private 和 protected 只在编译时检查

编译成 JavaScript 后,这些修饰符会被移除,属性在运行时仍然是可访问的。

class Secret {
  private data = "hidden";
}
const s = new Secret(); 
(s as any).data; // 运行时可访问,但 TS 编译器会报错

真正的运行时私有:使用 ECMAScript 的 # 私有字段(硬性私有)。

class BankAccount {
  #balance = 0;  // JavaScript 原生私有字段

  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount();
account.#balance; // ❌ 语法错误(运行时也是私有的)

9.6.2. 构造函数参数用 public 简写时,注意与属性同名覆盖

class Foo {
  constructor(public x: number) {}
}
// 等价于:
class Foo {
  x: number;
  constructor(x: number) {
    this.x = x;
  }
}

9.6.3. 抽象类可以被继承但不能 new,而接口只能被 implements 或继承,不能实例化。


9.7、总结

特性作用关键字
访问修饰符控制可见性publicprotectedprivate
只读属性禁止修改readonly
参数属性声明并赋值一步到位构造函数的修饰符参数
抽象类强制子类实现特定方法abstract class
抽象方法无实现,由子类提供abstract method()
接口实现保证类满足接口契约implements

十、模块与命名空间

现代项目使用 ES Modules(import / export)。

// point.ts
export interface Point { x: number; y: number; }
export function draw(p: Point) { ... }

// app.ts
import { Point, draw } from "./point";

namespace 主要是早期用于组织全局代码的,现在已被 ES Modules 取代。了解即可,不推荐新项目使用。


十一、类型声明文件(.d.ts)

当你在 TypeScript 项目中使用 JavaScript 库时,TS 无法得知库的类型,需要声明文件来描述类型。

  • 社区大多已提供:npm install @types/库名
  • 如果没有,可自行编写 xxx.d.ts
// global.d.ts
declare module "*.css" {
  const content: { [className: string]: string };
  export default content;
}

// 声明全局变量或方法
declare global {
  interface Window {
    myApp: any;
  }
}

注意:声明文件只用于类型检查,不包含实现。


十二、tsconfig.json 关键配置

tsconfig.json 是 TypeScript 项目的配置文件,控制编译行为。

{
  "compilerOptions": {
    "target": "ES2020",       // 编译后的 JS 版本
    "module": "ESNext",       // 模块系统
    "lib": ["ES2020", "DOM"], // 使用的内置 API 声明
    "strict": true,           // 启用所有严格类型检查
    "esModuleInterop": true,  // 兼容 CommonJS 导入
    "skipLibCheck": true,     // 跳过声明文件检查,加速编译
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

最重要的选项是 strict: true,它一次性开启以下严格检查:

  • noImplicitAny:禁止隐式 any
  • strictNullChecks:严格空值检查
  • strictFunctionTypes:严格函数类型检查
  • 等等

初学者务必开启 strict,虽然一开始会多出很多报错,但这能帮你养成类型安全的习惯。


十三、面试常考实战题型

1. interface vs type(见第三节)

2. any vs unknown

unknown 是类型安全的顶类型,使用前必须检查类型。面试中经常要求说出两者区别并举例。

3. 泛型约束的应用

例如要求泛型参数必须具有某属性,或者根据泛型返回不同类型。

4. 提取数组元素类型

type ArrayElement<T> = T extends (infer U)[] ? U : never;
type El = ArrayElement<string[]>; // string

5. 让对象所有属性可选 / 必选 / 只读

使用 Partial<T>Required<T>Readonly<T>

6. as const 断言

将对象或数组变为字面量只读类型。

const colors = ["red", "green"] as const;
// 类型:readonly ["red", "green"]

作用:获得更精确的类型,且数据不可修改。

7. 双重断言

const el = document.getElementById("app") as unknown as HTMLCanvasElement;

不推荐,除非万不得已,一般先用类型守卫。

8. 编写自定义类型守卫

function isString(value: unknown): value is string {
  return typeof value === "string";
}