搞定 TypeScript!进阶为现代高级前端工程师必不可少的路径

序章:为什么要学习 TypeScript

摘要: 本章是您 TypeScript 学习之旅的起点,但我们不急于罗列语法。相反,我们将首先深入探讨一个核心问题:我们为什么需要 TypeScript? 本章将直接面对您在原生 JavaScript 开发中可能遇到的真实痛点——那些难以追踪的运行时错误、模糊的函数契约和不可靠的数据结构。通过重现这些“阵痛”时刻,我们将共同揭示 TypeScript 作为解决方案的核心价值:它如何在编码阶段就为我们带来秩序、健壮性和可预测性,从而完成从“开发者”到“工程师”的第一次思维跃迁。

重要信息: 因为我们后续要学习Vue和React,他们的核心思想并非面向对象编程,按照我的规划,我会在学习Nest这个面向对象的后端框架去补充TS的面向对象知识点,包括装饰器等高级特性,我会在后续推出一个番外篇


0.1. JavaScript 的常见痛点

在拥抱一门新技术之前,我们必须清晰地认识到它究竟解决了什么问题。让我们一同回顾一下那些在原生 JavaScript 开发中,曾让我们头疼不已的经典场景。

0.1.1. 瓶颈一:动态类型引发的运行时错误

痛点复盘:这是我们都经历过的经典场景。一个函数在开发时运行良好,但上线后,一个意料之外的 undefined 传入,瞬间引发了 TypeError,导致应用崩溃。这种在编码阶段无法预见的“幽灵”错误,是 JavaScript 动态类型带来的最大困扰。

问题代码示例

1
2
3
4
5
6
7
function getProductTitle(product) {
// 我们满怀信心地认为 product 对象上一定有 title 属性
return product.title.toUpperCase();
}

// 实际运行时,传入了一个不符合预期的对象
getProductTitle({ name: "一个没有 title 的商品" });

核心问题:这类错误只有在代码被实际执行时才会暴露,我们渴望在代码提交之前,就能拥有对这类问题的绝对掌控力。


0.1.2. 瓶颈二:模糊的函数协作边界

痛点复盘:您精心编写了一个工具函数,它接收一个 config 对象。一个月后,当您自己或同事需要使用它时,那个曾经清晰的 config 结构早已模糊不清。我们不得不依赖可能早已过时的注释,或者花费大量时间去重新阅读源码。

问题代码示例

1
2
3
4
5
6
7
8
9
10
function createReport(config) {
// 同事记忆中的字段是 title,而不是 reportTitle
console.log(`正在创建报告: ${config.title}`);
}

// 调用者对数据结构记忆模糊,犯了拼写错误
createReport({
reportTitle: "2025年度销售额报告",
includeCharts: true
});

最可怕的不是报错,而是 静默失败。代码没有崩溃,但业务逻辑却走向了错误的分支,这类问题在大型应用中极难排查。


0.2. TypeScript 提供的解决方案

上述两大瓶颈,本质上都源于同一个问题:不确定性。TypeScript 的核心价值,就是用一套强大的类型系统,将这种不确定性从“运行时”消除,移至“编码时”。

0.2.1. 方案一:用类型定义建立可靠契约

解决方案:我们不再依赖“口头约定”或注释,而是使用 interfacetype 为所有数据结构创建一份 可被机器严格校验的“代码化契约”

重构代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第一步:定义契约
interface Product {
title: string;
}

// 第二步:函数应用契约
function getProductTitle(product: Product): string {
return product.title.toUpperCase();
}

// 尝试传入不符合契约的对象,编辑器会立刻报错!
// 错误: 类型“{ name: string; }”的参数不能赋给类型“Product”的参数。
// getProductTitle({ name: "一个没有 title 的商品" });

思维转变: 通过一份 interface 契约,我们将不可靠的外部数据,转化为了 100% 可预测的内部数据结构。任何非法访问,都会被 TypeScript 编译器在第一时间拦截。

0.2.2. 方案二:用函数签名固化协作边界

解决方案:TypeScript 的函数签名本身就是最强大、最精准的协作工具。我们为每个函数的输入(参数)和输出(返回值)都定义精确的类型。

重构代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface ReportConfig {
title: string;
includeCharts: boolean;
}

function createReport(config: ReportConfig): void {
console.log(`正在创建报告: ${config.title}`);
}

// 尝试使用错误的属性名,编辑器会立刻报错并给出修正建议!
// 错误: 对象文字可以只指定已知属性,但“reportTitle”不在类型“ReportConfig”中。你是否指的是“title”?
/*
createReport({
reportTitle: "2025 年度销售额报告",
includeCharts: true
});
*/

体验一下: 在支持 TypeScript 的编辑器中,当您调用函数时,一个包含所有参数、类型、甚至可选性的浮窗会立刻出现。这份由代码自动生成的“活文档”,远比任何手写注释都更可靠。

通过以上重构,我们看到 TypeScript 并非简单地为代码“添加注释”,它在本质上改变了我们的工作流。它将原本分散在文档、注释和大脑中的隐性知识,全部转化为 显性的、可被静态分析的代码

这门课程的使命,就是帮助您掌握这套强大的工程化思维。我们将从搭建一个专业的开发环境开始,一步步深入类型系统的核心,最终让您有能力驾驭任何复杂的前端应用。


第一章:打造坚不可摧的工程化起点

摘要: 欢迎来到第一章。在这里,我们将完成从“编写代码”到“构建工程”的第一次关键跃迁。本章将引导您使用 Vite 和 pnpm 快速搭建一个现代化的 TypeScript 项目,并首先对这个专业的工程“蓝图”进行全面解读。更重要的是,我们将一同解构 tsconfig.json 文件,您会发现它并非一张枯燥的选项列表,而是您为项目制定的“架构宣言”。我们将学习如何做出关键的工程决策,例如为什么 strict: true 是专业项目的“铁律”,以及如何通过路径别名来提升代码的可维护性。


1.1. 现代化项目蓝图:解构 Vite 项目结构

一个专业、高效的开发环境是工程化的起点。在 2025 年,我们选择 Vite,因为它提供了无与伦比的开发服务器启动速度和开箱即用的 TypeScript 支持。我们将使用 pnpm,一个速度更快、磁盘空间效率更高的包管理工具。

第一步:初始化项目

请打开您的终端,执行以下命令,创建一个名为 my-ts-app 的项目:

1
2
3
# 确保你已经安装了 pnpm (npm install -g pnpm)
# --template vanilla-ts 指定我们使用原生 JS + TypeScript 模板
pnpm create vite my-ts-app --template vanilla-ts

第二步:认知项目结构

命令执行完毕后,您将得到一个清晰、专业的工程骨架。让我们深入理解这个结构的内涵:

1
2
3
4
5
6
7
8
9
10
11
12
. 📂 my-ts-app
├── 📄 index.html # <-- Web 应用的入口 HTML 文件
├── 📄 package.json # <-- 项目的“身份证”,定义依赖和脚本
├── 📄 pnpm-lock.yaml # <-- 精确锁定依赖版本,保证团队环境一致性
├── 📂 public/
│ └── 📄 vite.svg # <-- 存放无需编译的静态资源
├── 📂 src/ # <-- 我们的主战场:所有源代码存放于此
│ ├── 📄 main.ts # <-- TypeScript 的主入口文件
│ ├── 📄 style.css # <-- 全局样式文件
│ ├── 📄 vite-env.d.ts # <-- Vite 提供的 TypeScript 类型声明
│ └── ... 其他模板文件
└── 📄 tsconfig.json # <-- 本章的核心:TypeScript 编译器的“宪法”
  • index.html: 这是应用的起点,它通过 <script type="module" src="/src/main.ts"></script> 引入了我们的 TypeScript 主入口文件。
  • src/main.ts: 所有 TypeScript 逻辑的起点。Vite 会从这里开始分析依赖,构建整个应用。
  • package.json: 定义了项目元信息、依赖(dependencies & devDependencies)以及可执行脚本(scripts),例如 pnpm run dev
  • tsconfig.json: 这是项目的灵魂。它告诉 TypeScript 编译器如何检查和编译我们的代码。

1.2. tsconfig.json:不是选项列表,而是架构宣言

请不要把它看作一份普通的配置文件。tsconfig.json 是您项目的“宪法”,它定义了代码的编写规范、编译规则和工程边界。您在这里做出的每一个决策,都将深刻影响代码的健壮性和可维护性。

Vite 为我们生成的默认 tsconfig.json 已经是一个很好的起点。接下来,我们将聚焦其中几个 最关键 的配置项,理解它们背后的工程化考量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"compilerOptions": {
// 代码将编译为 ES2022,利用最新 JS 特性(如顶层 await)
"target": "ES2022",
// 类型检查时可用的 API(现代 JS + 浏览器环境)
"lib": ["ES2022", "DOM", "DOM.Iterable"],
// 类字段行为与标准一致,避免初始化陷阱
"useDefineForClassFields": true,

// 输出标准 ES 模块,交由 Vite 等打包工具处理
"module": "ESNext",
// 按现代打包工具的方式解析模块
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
// 只做类型检查,不生成 js 文件
"noEmit": true,

// 兼容单文件转译工具(如 esbuild、Babel)
"isolatedModules": true,

// 严格模式,提升类型安全
"strict": true,
// 检查未使用的局部变量
"noUnusedLocals": true,
// 检查未使用的函数参数
"noUnusedParameters": true,
// 防止 switch 语句穿透 bug
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

1.3 铁律:"strict": true

痛点背景:回想一下序章中那些因为 nullundefined 导致的运行时崩溃。strict 模式就是为了从根源上终结这类问题而生的。

深度解析"strict": true 并非单个选项,而是一个“全家桶”,它会开启所有严格类型检查的选项(如 strictNullChecks, noImplicitAny 等)。这等于告诉 TypeScript 编译器:“请用最严格的标准来审查我的代码,不要放过任何潜在的类型风险。”

工程决策
2025-09-03

我应该在我的新项目里开启 strict: true 吗?

资深工程师

答案是唯一的、武断的:是,必须是。对于任何严肃的、期望长期维护的项目,关闭 strict 模式就等于主动放弃了 TypeScript 带来的 80% 的安全保障。

但这会让我的代码写起来更麻烦。

资深工程师

短期的“麻烦”,换来的是长期的健壮和可维护性。它会强迫你从一开始就思考所有边界情况,这正是一名专业工程师所必需的思维模式。

代码对比

1
2
3
4
5
6
// 在 "strict": false 的情况下
// 这段代码可以通过编译,但存在巨大的运行时风险
function getGreeting(name: string | null) {
// 如果 name 是 null, .toUpperCase() 会在运行时抛出错误
return `Hello, ${name.toUpperCase()}`;
}

1
2
3
4
5
6
7
8
9
10
11
12
// 在 "strict": true 的情况下
function getGreetingStrict(name: string | null): string {
// 编译器会立刻报错:'name' is possibly 'null'.
// return `Hello, ${name.toUpperCase()}`;

// 我们必须处理 null 的情况,代码才能通过编译
if (name) {
return `Hello, ${name.toUpperCase()}`;
} else {
return "Hello, Guest";
}
}

最佳实践: 始终保持 "strict": true。将它视为项目的“安全带”,从第一天就系好它。


1.4. 沟通协议:模块解析与路径别名

痛点背景:告别 import { logger } from '../../../../utils/logger'; 这种脆弱且丑陋的相对路径。

解决方案:通过 模块解析策略路径别名,让模块导入变得清晰、稳定。

1. 模块解析策略

tsconfig.json 中,确保使用 bundler 模式。

1
2
3
4
5
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}
  • 核心价值:让 TypeScript 的模块解析行为与 Vite 等现代打包工具完全对齐,从根源上避免“开发时正常,打包后报错”的问题。

2. 路径别名:双重配置,缺一不可

路径别名(如 @/*)必须同时在 TypeScript 和 Vite 中配置,才能在 代码提示项目运行 两个环节都生效。

步骤一:配置 tsconfig.json (为 TS 和 IDE 服务)

1
2
3
4
5
6
7
8
9
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
  • "baseUrl": 定义路径解析的基准目录(项目根目录)。

  • "paths": 创建别名规则,将 @/ 指向 src/

步骤二:配置 vite.config.ts (为 Vite 打包服务)

1
2
3
4
5
6
7
8
9
10
11
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
  • 依赖:此配置需要 Node.js 类型声明,请执行 pnpm add -D @types/node

最终效果对比

配置前import { logger } from '../../utils/logger';
配置后import { logger } from '@/utils/logger';

核心价值: 路径别名极大地提升了代码的 可读性可维护性。当您重构或移动文件时,其内部的导入路径无需任何修改。

完成上述配置后,即可启动项目。

1
2
3
4
5
# 1. 安装依赖
pnpm install

# 2. 启动 Vite 开发服务器
pnpm run dev

Vite 将启动一个具备 热模块替换 (HMR) 功能的开发服务器,任何代码修改都会即时在浏览器中响应,无需手动刷新。


1.5 本章小结

分类关键项核心价值
包管理pnpm(推荐) 高效、快速的现代包管理工具。
项目结构Vite 模板(推荐) 提供了一个清晰、专业、开箱即用的工程骨架。
核心配置strict: true(强制) 开启所有严格类型检查,是保证代码健壮性的基石。
模块解析moduleResolution: "bundler"(推荐) 确保 TS 编译器与打包工具的模块解析行为一致。
路径管理paths: { "@/*": [...] }(推荐) 提升代码可读性与可维护性,简化重构。

在打下坚实的工程化地基之后,我们将在下一章正式开始为真实世界的数据建模,学习如何使用 TypeScript 的核心类型系统来绘制我们应用的 “蓝图”。


第二章:TypeScript 核心类型系统 - 初级入门

摘要: 本章将,回归基础,系统性地、一步步地为“前端工程化求索者”构建起完整的 TypeScript 类型知识体系。我们将从最基础的类型注解开始,逐一掌握定义变量、函数、数组和对象所需的所有核心类型工具。从 2.5 节开始,我们会将这些理论知识应用到一个具体的 Todo List 案例中,让您亲眼见证类型系统如何将无序的 JavaScript 代码转化为健壮、可维护的工程化实践。


2.1. 类型化的第一步:类型注解与类型推断

让我们从 TypeScript 最基础、最核心的语法开始:如何为变量赋予类型。

2.1.1. 类型注解

类型注解是 TypeScript 的核心语法,我们使用 (: 类型) 的形式,来 显式地 告诉 TypeScript 一个变量应该是什么类型。

1
2
3
4
5
// 我们明确告诉 TypeScript,`appName` 变量的类型必须是 `string`
let appName: string = "TypeScript Learning";

// 如果我们尝试给它赋一个非字符串的值,编译器会立刻报错
// appName = 123; // 错误: 不能将类型“number”分配给类型“string”。

2.1.2. 类型推断

很多时候,我们不需要显式地添加类型注解。如果您在声明变量时就进行了初始化,TypeScript 会足够智能,自动 推断 出它的类型。

1
2
3
4
5
// 我们没有写 `: number`,但 TypeScript 知道 `version` 是 number 类型
let version = 5;

// 同样,尝试赋一个错误类型的值,依然会报错
// version = "5.0"; // 错误: 不能将类型“string”分配给类型“number”。

2.1.3. 工程实践:何时注解,何时推断?

工程决策
2025-09-03

既然有类型推断,我是不是可以不写类型注解了?

资深工程师

不。记住一个原则:如果一个变量的类型无法在声明时被立刻、清晰地确定,就必须使用类型注解。

比如?

资深工程师

函数参数、函数返回值、未初始化的变量。在这些地方,注解就是你和其他开发者之间最清晰的“契约”。对于像 let i = 0 这样类型一目了然的变量,则可以放心交给类型推断。


2.2. 原子构建块:掌握基础数据类型

2.2.1. 原始类型:string, number, boolean

1
2
3
let frameworkName: string = "Vite";
let year: number = 2025;
let isProduction: boolean = false;

2.2.2. 特殊的“空”值:nullundefined

1
2
let user: null = null;
let config: undefined = undefined;

strictNullChecks (包含在 strict: true 中) 模式下,nullundefined 是独立的类型,不能赋值给 stringnumber 等类型,这从根源上消除了大量的潜在错误。


2.2.3. ES6+ 的新成员:symbolbigint

1
2
3
4
// symbol 类型的值是唯一的、不可变的
const uniqueKey: symbol = Symbol("id");
// bigint 用于表示超大整数
const bigIntValue: bigint = 9007199254740991n;

2.2.4. 逃生舱与安全阀:any vs unknown

核心纪律: 在您的工程中,应将 any 视为“最后的手段”。当您不确定一个值的类型时,请优先使用 unknown,它会强迫您在运行时进行安全的类型检查,这正是 strict 模式所倡导的编程思想。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// any 类型放弃了所有类型检查,应极力避免使用
let anything: any = "可以是任何东西";
anything = 123;
anything.toFixed(2); // 不会报错,但运行时可能出错

// unknown 是一个更安全的选择
let safeAnything: unknown = "这是一个字符串";

// 直接操作 unknown 类型的变量是不被允许的
// safeAnything.toUpperCase(); // 错误: “safeAnything”的类型为“unknown”。

// 我们必须先进行类型检查,收窄其类型
if (typeof safeAnything === 'string') {
// 在这个块里,TypeScript 知道它是 string
console.log(safeAnything.toUpperCase());
}

2.2.5. 终点类型:never

never 类型表示一个永远不会正常返回的值的类型。例如,一个总是抛出错误的函数。

1
2
3
function throwError(message: string): never {
throw new Error(message);
}

2.3. 为行为建模:函数类型

2.3.1. 函数参数与返回值

1
2
3
4
5
6
7
8
9
// 定义一个函数,它接收两个 number 参数,并返回一个 number
function add(a: number, b: number): number {
return a + b;
}

// 如果一个函数没有返回值,我们使用 : void 来明确表示
function logMessage(message: string): void {
console.log(message);
}

2.3.2. 可选、默认与 rest 参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// greeting 是一个带有默认值的参数
// name 是一个必选参数
function greet(name: string, greeting: string = "Hello"): string {
return `${greeting}, ${name}!`;
}

// age 是一个可选参数
function userInfo(name: string, age?: number): void {
if (age) {
console.log(`${name} is ${age} years old.`);
} else {
console.log(`Name: ${name}`);
}
}

// ...numbers 是一个 rest 参数,它将所有剩余的参数收集到一个数组中
function sum(...numbers: number[]): number {
return numbers.reduce((total, current) => total + current, 0);
}

2.3.3. 函数重载

为一个函数提供多个不同的调用签名,以应对不同的输入情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 为 format 函数提供两个重载签名
function format(value: string): string;
function format(value: number): string;
// 这是函数的实现签名,它必须兼容所有的重载签名
function format(value: string | number): string {
if (typeof value === "string") {
return value.trim();
}
return value.toFixed(2);
}

const formattedString = format(" hello "); // "hello"
const formattedNumber = format(123.456); // "123.46"

2.4. 为结构建模 I:数组与元组

2.4.1. 数组 (Array)

1
2
3
4
5
6
// 方式一:类型 [] (推荐,更简洁)
const numbers: number[] = [1, 2, 3, 4];
const names: string[] = ["Alice", "Bob"];

// 方式二:Array <类型> (泛型语法,我们将在下一章深入)
const scores: Array<number> = [100, 99, 98];

2.4.2. 元组 (Tuple)

元组是一个 已知长度固定类型顺序 的数组。

1
2
3
4
5
6
7
// 定义一个元组类型,表示 HTTP 响应
let httpResponse: [number, string];
httpResponse = [200, "OK"];

// 尝试错误的顺序或长度都会导致编译时错误
// httpResponse = ["OK", 200]; // 错误:顺序不匹配
// httpResponse = [200, "OK", true]; // 错误:长度不匹配

2.5. 实战演练:为 Todo List 应用建模

理论学习已经足够,现在是时候将这些“原子构建块”组合起来,解决真实世界的问题了。我们将以第一章创建的 Vite 项目为基础,逐步为一个 Todo List 应用构建类型系统。

2.5.1. 第一步:对象字面量类型

痛点: 我们需要定义应用的状态 state,它是一个包含 todos 数组和 currentFilter 字符串的对象。在 JS 中,这个对象的结构是模糊的。

解决方案: 使用 对象字面量类型,直接在变量声明时,用 {} 内联地定义对象的形状。

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 清理 main.ts,并写入以下内容

import './style.css';

// 使用对象字面量类型为 state 定义初始形状
const state: {
todos: any[]; // todos 数组里的对象还是 any,这是下一步要解决的
currentFilter: string; // currentFilter 暂时还是一个普通的 string
} = {
todos: [
{ id: 1, text: "学习 TypeScript 核心类型", completed: true },
{ id: 2, text: "编写一个 Todo List 应用", completed: false },
],
currentFilter: "all"
};

console.log(state);

这解决了 state 对象本身的结构问题,但 todos 数组内部的对象依然是 any,我们的核心风险并未解除。内联类型也无法复用。

2.5.2. 第二步:interface

痛点: any[] 无法保证 Todo 项的内部结构一致性。我们需要一个可复用的、专门描述 Todo 项的“契约”。

解决方案: 引入 interface,为我们的核心数据建模。

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import './style.css';

// 1. 使用 interface 为我们的核心数据 Todo 项建立一个不可篡改的“契约”
interface Todo {
readonly id: number; // id 是只读的,一旦创建不可修改
text: string;
completed: boolean;
dueDate?: Date; // dueDate 是可选的,可以不存在
}

const state: {
todos: Todo[]; // <-- 将 any [] 替换为我们刚刚创建的 Todo [] 契约!
currentFilter: string;
} = {
todos: [
{ id: 1, text: "学习 TypeScript 核心类型", completed: true },
{ id: 2, text: "编写一个 Todo List 应用", completed: false },
// { id: 3, content: "...", done: "no" } // 任何不符合 Todo 接口的对象都会立刻报错!
],
currentFilter: "all"
};

思维转变: 通过 interface Todo,我们完成了从“创建随意对象”到“实例化数据模型”的转变。state.todos 数组现在受到了这份契约的严格保护。

2.5.3. 第三步:type 别名与联合类型

痛点: state.currentFilter 还是一个普通的 string,我们依然面临“魔术字符串”的风险(例如,误写成 "actives")。

解决方案: 引入 type 别名,结合 字面量联合类型,来创建一个精确、有限的状态集合。

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 清理 main.ts,并写入以下内容

import './style.css';

// 1. 使用 interface 为我们的核心数据 Todo 项建立一个不可篡改的“契约”
interface Todo {
readonly id: number; // id 是只读的,一旦创建不可修改
text: string;
completed: boolean;
dueDate?: Date; // dueDate 是可选的,可以不存在
}


// 2. 使用 type 别名和字面量联合类型,为筛选状态创建一个精确的类型
type Filter = "all" | "active" | "completed";

// 3. 我们可以进一步用 type 把整个应用的状态也模型化
type AppState = {
todos: Todo[];
currentFilter: Filter;
}

const state: AppState = {
todos: [
{ id: 1, text: "学习 TypeScript 核心类型", completed: true },
{ id: 2, text: "编写一个 Todo List 应用", completed: false },
],
currentFilter: "all"
};

console.log(state);

2.5.4. 第四步:枚举 (enum)

enum 也可以用来解决“魔术字符串/数字”的问题,它会创建一个真实的 JavaScript 对象。

1
2
3
4
5
6
7
8
9
enum FilterEnum {
All = "ALL",
Active = "ACTIVE",
Completed = "COMPLETED"
}

// 应用
let currentFilter: FilterEnum = FilterEnum.All;
console.log(currentFilter); // 输出 "ALL"

在现代前端开发中,由于 type 创建的字面量联合类型在编译后会被擦除(零运行时开销),且同样能提供完整的类型安全和智能提示,因此它通常是比 enum 更受青睐的选择,所以我们简单了解 enum 即可,他不是最佳实践


2.5.5. 工程选型思辨:interface vs type

工程决策
2025-09-03

interfacetype 都能定义对象,我到底该用哪个?

资深工程师

记住这个武断但极其实用的最佳实践:优先使用 interface 来定义对象的形状,只有在 interface 做不到时,才使用 type

什么是 interface 做不到的?

资深工程师

就像我们刚刚做的,定义 Filter 联合类型。此外,定义元组、或需要用到交叉、映射等高级类型操作时,都必须用 typetype 是你的瑞士军刀,但对于定义 API 契约和对象结构,interface 更专注、更清晰。


2.7. 类型断言:当比编译器更懂类型时

在前面的小节中,我们已经学会了如何为应用内部的数据(如 statetodos)建立精确的类型契约。然而,在真实的开发场景中,我们经常需要与 TypeScript 类型系统之外的世界进行交互——最典型的例子就是浏览器 DOM。这时,我们就会遇到一个新问题:TypeScript 对这些外部元素的认知是保守的、宽泛的,而我们作为开发者,却拥有更精确的“上下文信息”。

痛点背景:我们需要获取页面上的一个输入框元素,并读取它的 value 值。

第一步:在 index.html 中添加一个输入框

1
<input type="text" id="todo-input" placeholder="输入新的待办事项..." />

第二步:在 main.ts 中尝试获取并操作它

文件路径: src/main.ts

1
2
3
4
5
const inputEl = document.querySelector('#todo-input');

// 🔴 编译错误!
// 属性“value”在类型“Element”上不存在。
console.log(inputEl.value);

问题分析: document.querySelector 是一个通用的 DOM API,它不知道我们想获取的是什么具体类型的元素。因此,出于严谨,TypeScript 推断 inputEl 的类型是 Element | null。通用的 Element 类型上,并不存在 value 这个属性,只有 HTMLInputElement 这样的具体子类型才有。

解决方案: 这时,我们就需要使用 类型断言 。它是一种方式,让我们可以明确地告诉 TypeScript 编译器:“相信我,我知道这个变量的真实类型比你推断的更具体。”

2.7.1. 使用 as 关键字

这是最常用、也是官方推荐的断言语法。

文件路径: src/main.ts (修正代码)

1
2
3
4
5
6
7
8
// 使用 as 关键字进行类型断言
const inputEl = document.querySelector('#todo-input') as HTMLInputElement;

// ✅ 正确!现在 TypeScript 知道 inputEl 是一个输入框元素
// 我们可以安全地访问它的 value 属性,并获得智能提示
if (inputEl) { // 做好 null 检查
console.log(inputEl.value);
}

as 语法是现代 TypeScript 的首选,因为它在 .tsx (React/Vue JSX) 文件中不会产生语法冲突。

2.7.2. 遗留的“尖括号”语法

在早期版本的 TypeScript 中,也使用尖括号语法进行断言。

1
const inputEl = <HTMLInputElement>document.querySelector('#todo-input');

最佳实践: 避免使用 尖括号语法。在 React 或 Vue 项目中,尖括号会被解析为 JSX/TSX 标签,从而导致语法混淆和编译错误。请始终坚持使用 as 关键字。

2.7.3. 断言的双面性:信任与风险

核心思维: 类型断言是一把双刃剑。它赋予了我们覆盖编译器类型推断的权力,但同时也意味着 我们将为这个“断言”的真实性负全部责任。如果断言错误,编译器不会再为我们提供保护,错误将直接暴露在运行时。

一个危险的例子

1
2
3
4
5
6
7
// 我们的断言是错误的,#todo-input 是一个 input,不是 img
const imgEl = document.querySelector('#todo-input') as HTMLImageElement;

// 这行代码可以通过编译,因为我们“欺骗”了 TypeScript
// 但在运行时,当 JS 引擎尝试在 input 元素上访问 src 属性时,
// 它会返回 undefined,或在后续操作中引发 TypeError。
console.log(imgEl.src);

🤔 思考一下
类型断言和类型转换有什么本质区别?例如,variable as stringString(variable)

本质区别:类型断言 不会 对变量进行任何运行时的检查或数据转换,它只在编译时起作用,纯粹是给编译器看的“类型提示”。而 String(variable) 则是一个真实的 JavaScript 函数调用,它会在 运行时 将变量转换为字符串。


2.7 本章小结

工具 / 语法核心作用说明与场景
interface定义对象结构专注于描述对象的“形状”,如 interface Todo { ... }
type创建类型别名更灵活,可为任何类型起别名,如 type AppState = { ... }
联合类型限定取值范围结合 type 创建联合或字面量类型,如 `type Filter = “all”
value as Type(推荐) 强制指定类型开发者向编译器保证类型正确。常用于 DOM 操作或处理 any 类型。
<Type>value(不推荐) 类型断言旧语法功能同 as,但在 JSX 中会引起语法冲突,应避免使用。

第三章:收窄与守护:驾驭类型的不确定性

摘要: 欢迎来到第三章。在上一章,我们学会了如何使用联合类型 (|) 来为变量定义多种可能性。但“定义”只是第一步,“使用”才是真正的挑战。本章,我们将直面操作联合类型时遇到的核心痛点,并系统性地学习 TypeScript 提供的强大武器库——类型守护 (Type Guards)。您将掌握如何使用 typeofin 以及自定义的 is 谓词,在代码块中智能地 收窄 类型范围,将不确定的联合类型转化为精确的、可安全操作的具体类型。这不仅是编写无错代码的技巧,更是构建健壮、可预测应用的思维模式。


在本章中,我们将循序渐进,像侦探一样,学会如何从模糊的可能性中推断出确切的真相:

  1. 首先,我们将直面 联合类型的操作困境,体会为什么需要类型收窄。
  2. 接着,我们将掌握最基础的守护工具 typeof,解决原始类型的判断问题。
  3. 然后,我们将学习使用 in 操作符,来安全地区分拥有不同属性的对象。
  4. 最后,我们将打造自己的终极武器——自定义类型守护,将复杂的类型判断逻辑封装成可复用的“认证函数”。

3.1. 痛点呈现:联合类型的操作困境

在上一章,我们学会了使用联合类型来增强代码的灵活性。例如,一个函数可能接收一个 string 或者一个 number。但这种灵活性也带来了新的问题。

场景复现:我们想编写一个函数 printValue,如果传入的是字符串,就打印它的大写形式;如果传入的是数字,就打印它保留两位小数的形式。

问题代码示例:

1
2
3
4
5
6
7
8
9
10
11
function printValue(value: string | number) {
// 🔴 编译错误!
// 属性“toUpperCase”在类型“string | number”上不存在。
// 属性“toUpperCase”在类型“number”上不存在。
console.log(value.toUpperCase());

// 🔴 同样会编译错误!
// 属性“toFixed”在类型“string | number”上不存在。
// 属性“toFixed”在类型“string”上不存在。
console.log(value.toFixed(2));
}

核心问题: 在 TypeScript 对 value 进行类型检查时,它只能保证这个变量是 string number 中的一种。因此,任何只在其中一个类型上存在的方法(如 toUpperCasetoFixed),都不能被安全调用。

为了解决这个问题,我们需要一种机制,能在函数内部的特定代码块中,让 TypeScript “确信”这个变量此刻的具体类型。这个过程,就叫做 类型收窄


3.2. 类型守护 I:typeof

解决方案: 对于原始数据类型(string, number, boolean, symbol, bigint, undefined),最简单直接的类型守护就是使用 JavaScript 的 typeof 操作符。

重构代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function printValue(value: string | number): void {
// 使用 typeof 进行类型守护
if (typeof value === "string") {
// 在这个 if 代码块内,TypeScript 已经智能地将 value 的类型收窄为 `string`
// 鼠标悬停在下面的 `value` 上,你会看到它的类型就是 string
console.log(value.toUpperCase()); // ✅ 正确!
} else {
// 在 else 代码块内,TypeScript 推断出 value 的类型必定是剩下的 `number`
console.log(value.toFixed(2)); // ✅ 正确!
}
}

printValue("hello world");
printValue(123.456);

typeof 是处理原始类型联合类型的首选工具,它简单、高效,并且利用了 JavaScript 的原生能力。


3.3. 类型守护 II:in 操作符

typeof 对原始类型非常有效,但当我们面对不同的对象类型时,它就无能为力了(因为 typeof 对任何对象(null 除外)都只会返回 "object")。

痛点呈现:在我们的 Todo 应用中,假设除了 Todo,还有一种 Note 类型。我们需要一个函数来处理这两种类型的对象。

场景代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 在 `src/types.ts` 中可以补充这些类型
interface Todo {
id: number;
text: string;
completed: boolean;
// Todo 特有的方法
toggle(): void;
}

interface Note {
id: number;
content: string;
// Note 特有的属性
color: 'yellow' | 'blue' | 'green';
}

type ListEntry = Todo | Note;

function processEntry(entry: ListEntry) {
// 🔴 编译错误!属性 `toggle` 在类型 `Note` 上不存在
entry.toggle();

// 🔴 编译错误!属性 `color` 在类型 `Todo` 上不存在
console.log(`Color is: ${entry.color}`);
}

解决方案: 使用 in 操作符。in 操作符可以检查一个对象自身或其原型链上是否存在某个属性。TypeScript 能够理解这个检查,并据此收窄类型。

重构代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function processEntry(entry: ListEntry): void {
console.log(`Processing entry #${entry.id}`);

// 使用 in 操作符进行类型守护
if ("completed" in entry) {
// 在这个块内,TypeScript 知道 entry 的类型是 Todo
// 因为只有 Todo 类型拥有 "completed" 属性
console.log(`Todo item: ${entry.text}`);
// entry.toggle(); // 如果 toggle 方法已实现,这里就可以安全调用
} else {
// 在这个块内,TypeScript 推断 entry 的类型必定是剩下的 Note
console.log(`Note with color ${entry.color}: ${entry.content}`);
}
}

3.4. 终极武器:自定义类型守护 (is)

in 操作符很棒,但如果我们的判断逻辑比较复杂,或者需要在多个地方重复使用,那么将这个逻辑封装起来会是更好的选择。

解决方案: 创建一个返回类型为 类型谓词 (parameterName is Type) 的函数。这种函数就是自定义类型守护。

重构 processEntry 的例子:

第一步:创建一个自定义类型守护函数

1
2
3
4
5
6
// 这个函数返回一个布尔值,但它的类型签名非常特殊
// `entry is Todo` 就是类型谓词
// 它告诉 TypeScript:如果这个函数返回 true,那么你就可以确信传入的 `entry` 参数是 `Todo` 类型
function isTodo(entry: ListEntry): entry is Todo {
return (entry as Todo).completed !== undefined;
}

第二步:在我们的逻辑中使用它

1
2
3
4
5
6
7
8
9
10
11
function processEntryWithCustomGuard(entry: ListEntry): void {
console.log(`Processing entry #${entry.id}`);

// 使用自定义类型守护函数
if (isTodo(entry)) {
// 在这个块内,TypeScript 同样能将 entry 的类型收窄为 Todo
console.log(`Todo item: ${entry.text}`);
} else {
console.log(`Note with color ${entry.color}: ${entry.content}`);
}
}

思维转变: 自定义类型守护,让我们将业务逻辑(“如何判断一个东西是 Todo”)与类型系统进行了关联。这使得我们的代码不仅更安全,而且 可读性可复用性 大大增强,代码本身就在“述说”它的意图。


3.5. 本章核心速查总结

守护方式核心用途适用场景
typeof检查原始数据类型当联合类型包含 string, number, boolean 等时。
in检查对象上是否存在属性(推荐) 当需要区分不同形状的接口 (interface) 或对象类型时。
自定义 (is)封装可复用的类型判断逻辑当类型判断逻辑复杂,或需要在多处重复使用时。

3.6. 高频面试题与陷阱

面试官深度追问
2025-09-03

请解释一下什么是 TypeScript 的“类型收窄”,以及你通常用哪些方法来实现它?

类型收窄是指在特定的代码分支中,TypeScript 编译器能够根据上下文,将一个宽泛的类型(如联合类型)推断为一个更具体的类型的过程。这是在 strict: true 模式下保证类型安全的关键。我主要使用三种方法:1. typeof 用于判断原始类型;2. in 操作符用于判断对象上是否存在某个唯一的属性;3. 自定义类型守护函数(使用 is 谓词),用于封装可复用的、更复杂的判断逻辑。

很好。那么,在什么情况下你必须选择使用自定义类型守护,而不是简单地使用 typeofin

当类型判断的依据不是单一的属性或类型时。例如,一个对象可能需要同时满足多个条件才能被确认为某个特定类型,或者判断逻辑涉及到复杂的计算。将这些复杂的逻辑封装在一个 is 函数中,可以让业务代码更清晰,也便于统一维护和测试这个判断逻辑本身。


第四章:打造随需应变的“通用模具” - 泛型

摘要: 欢迎来到第四章。在本章,我们将完成一次从“具体实现”到“抽象建模”的思维升级。我们将直面当前 Todo 应用中因处理不同数据类型而导致的逻辑重复问题,并引入 TypeScript 的核心利器——泛型——来解决它。您将不再编写一次性的函数,而是学会创建如同“通用模具”般的、可适应多种数据类型的、高度复用且类型安全的函数与接口。我们将立即在 Todo 应用中实践,重构出一个通用的 API 数据结构,让您切身感受泛型在真实工程中的威力。


4.1. 痛点呈现:重复的逻辑与失控的 any

随着应用变得复杂,我们不可避免地需要处理多种不同类型的数据。让我们在 Todo 应用中模拟这个场景,并看看它如何立刻导致代码冗余。

4.1.1. 场景:为应用添加“用户”数据

假设我们的 Todo 应用现在需要显示当前的用户信息。首先,我们需要在项目中定义用户的“契约”。

第一步:创建 types.ts 并定义 User 接口

为了更好地组织代码,我们创建一个专门存放类型定义的文件。

文件路径: src/types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 我们之前定义的 Todo 和 Filter 类型也移到这里
export interface Todo {
readonly id: number;
text: string;
completed: boolean;
dueDate?: Date;
}

export type Filter = "all" | "active" | "completed";

// 新增 User 接口
export interface User {
id: number;
name: string;
email: string;
}

export type AppState = {
todos: Todo[];
currentUser: User[] | null; // 用户可能存在,也可能未登录
currentFilter: Filter;
}

第二步:更新 main.ts 以使用新类型

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
import './style.css';
// 从 types.ts 导入类型
import type { Todo, User, AppState, Filter } from './types';

const state: AppState = {
todos: [
{ id: 1, text: "学习 TypeScript 核心类型", completed: true },
{ id: 2, text: "编写一个 Todo List 应用", completed: false },
],
currentUser: { id: 1, name: "Prorise", email: "contact@prorise.com" },
currentFilter: "all"
};

4.1.2. 痛点:重复的查找逻辑

现在,我们需要一个函数根据 ID 查找 Todo,也需要一个几乎完全一样的函数来根据 ID 查找用户。

文件路径: src/main.ts (在 state 定义下方添加)

1
2
3
4
5
6
7
8
9
function findTodoById(id: number): Todo | undefined {
return state.todos.find(todo => todo.id === id);
}

function findUserById(id: number): User | undefined {
// 假设我们有一个用户列表
const users: User[] = [state.currentUser!];
return users.find(user => user.id === id);
}

问题显而易见:这两个函数的 逻辑完全相同,唯一的区别就是处理的数据类型(TodoUser)不同。在工程中,这种重复是“万恶之源”,它增加了维护成本,违反了 DRY (Don’t Repeat Yourself) 原则。


4.1.3. 失败的抽象:使用 any 导致的类型安全丧失

一个自然的冲动是使用 any 来创建一个通用函数。让我们看看为什么这是一个陷阱。

1
2
3
4
5
6
7
8
9
10
11
function findItemById_any(items: any[], id: number): any {
return items.find(item => item.id === id);
}

const foundTodo = findItemById_any(state.todos, 1);

// 灾难发生:
// 1. 类型丢失:foundTodo 的类型是 any,我们失去了所有代码提示。
// 2. 运行时风险:我们可以调用任何不存在的方法,编译器不会警告我们。
// 这行代码可以通过编译,但在运行时会因为 toUpperCase 不存在而崩溃。
console.log(foundTodo.text.toUpperCase());

any 以牺牲类型安全为代价换取了灵活性,这对于追求健壮性的工程师来说是不可接受的。它让我们回到了序章中的“混沌”状态。


4.2. 泛型 <T> 的引入与泛型约束

现在,让我们引入真正的“利器”——泛型。泛型允许我们编写一个函数,并为其中的类型创建一个“占位符”(通常表示为 <T>),在使用该函数时再填入具体的类型。

4.2.1. 重构:创建一个通用的 findItemById<T> 函数

文件路径: src/utils.ts (我们创建一个新的工具文件)

1
2
3
4
5
6
7
// T 是一个类型变量,一个“占位符”。
// 这个函数接收一个 T 类型的数组和一个 number 类型的 id,
// 并返回一个 T 类型的值或 undefined。
export function findItemById<T>(items: T[], id: number): T | undefined {
// 这里会出现一个编译错误,这是我们下一步要解决的
return items.find(item => item.id === id);
}

痛点分析: TypeScript 报错是因为,它只知道 T 是“某种类型”,但它不能保证“某种类型”一定拥有一个 id 属性。我们需要给泛型添加约束。

4.2.2. 泛型约束 (extends):让泛型更“聪明”

解决方案: 我们使用 extends 关键字来告诉 TypeScript,传入的类型 T 必须满足 某个“形状”。

文件路径: src/utils.ts (修改 findItemById 函数)

1
2
3
4
5
// 我们约束 T 必须是这样一个对象:它至少要有一个 number 类型的 id 属性。
export function findItemById<T extends { id: number }>(items: T[], id: number): T | undefined {
// 现在 TypeScript 知道 item 上一定有 id 属性,错误消失了。
return items.find(item => item.id === id);
}

第三步:在 main.ts 中使用我们的通用工具

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import './style.css';
// 从 types.ts 导入类型
import type { Todo, User, AppState, Filter } from './types';
import { findItemById } from './utils'; // 导入我们的泛型函数

const state: AppState = {
todos: [
{ id: 1, text: "学习 TypeScript 核心类型", completed: true },
{ id: 2, text: "编写一个 Todo List 应用", completed: false },
],
currentUser: [
{ id: 1, name: "Prorise", email: "contact@prorise.com" },
{ id: 2, name: "John", email: "john@example.com" },
],
currentFilter: "all"
};

// 调用泛型函数
// 1. 查找 Todo
const todo = findItemById(state.todos, 1);
// todo 的类型被正确推断为 Todo | undefined,拥有 text, completed 等属性

console.log(todo?.text);

// 2. 查找 User
const user = findItemById(state.currentUser!, 1);

console.log(user?.name);



我们成功地用一个 完全类型安全 的泛型函数替换了两个重复的函数。这就是泛型在工程化中的核心价值:在不牺牲类型安全的前提下,实现最高程度的代码复用。


4.3. 泛型接口:为 API 响应建立通用契约

痛点: 我们的应用将来会从 API 获取各种数据:Todo 列表、用户列表、配置项等。这些 API 响应通常有统一的结构,例如:

1
{ status: 200, message: "OK", data: [...] }

为每一种数据都写一个 TodoApiResponse, UserApiResponse 接口,又会陷入重复的泥潭。

解决方案: 使用泛型接口,创建一个可复用的 ApiResponse<T> 模板。

第四步:在 types.ts 中定义泛型接口

文件路径: src/types.ts

1
2
3
4
5
6
7
8
9
// ... 其他类型定义 ...

// 定义一个通用的 API 响应“包装器”
// T 代表了 `data` 属性中具体的数据类型
export interface ApiResponse<T> {
status: number;
message: string;
data: T;
}

第五步:在 api.ts 中应用泛型接口

文件路径: src/api.ts (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Todo, User, ApiResponse } from './types';

// 模拟 API 地址
const API_BASE = "https://api.example.com";

// 获取 Todo 列表的函数
// 它的返回值是一个 Promise,Promise 的解析值符合 ApiResponse <Todo[]> 契约
export async function fetchTodos(): Promise<ApiResponse<Todo[]>> {
// 实际项目中这里是真实的 fetch 调用
// return fetch(`${API_BASE}/todos`).then(res => res.json());

// 此处为模拟数据
return Promise.resolve({
status: 200,
message: "Success",
data: [
{ id: 1, text: "从 API 获取 Todo", completed: false }
]
});
}

// 获取单个用户的函数
export async function fetchUser(id: number): Promise<ApiResponse<User>> {
return Promise.resolve({
status: 200,
message: "Success",
data: { id, name: "API User", email: "api@example.com" }
});
}

现在,我们有了一个统一的、可复用的 ApiResponse<T> 接口来描述所有来自后端的数据结构,极大地提升了代码的一致性和可维护性。


4.4 本章小结

类型工具核心价值在我们 Todo 应用中的实践
泛型函数 <T>消除代码重复,同时保持类型安全。创建了一个通用的 findItemById 工具函数,可用于查找任何带 id 的对象。
泛型约束 extends为泛型添加规则,使其更智能、更安全。约束了 findItemById 的参数类型必须包含 id 属性。
泛型接口创建可复用的数据结构“模板”。定义了标准的 ApiResponse<T> 接口,统一了所有 API 响应的形状。

在本章,我们引入了泛型这一强大的抽象工具,将 Todo 应用的可复用性提升到了一个新的层次。我们学会了不再为每一个具体类型编写重复逻辑,而是去思考和构建通用的解决方案。在下一章,我们将面对一个新的挑战:随着应用逻辑增多,main.ts 文件将变得越来越臃肿。


第五章:解锁类型系统的高级魔法 - 高级泛型

摘要: 欢迎来到第五章。在本章,我们的思维将再次跃迁——从“使用类型”到“编程 类型”。我们将直面 Todo 应用在功能迭代(如“部分更新”)中遇到的新瓶颈,并引入 TypeScript 的高级武器库:映射类型条件类型。您将学会如何像炼金术士一样,对现有类型进行裁剪 (Pick/Omit)、转换 (Partial/Required) 和逻辑判断 (extends/infer),创造出全新的、精确的类型。这不仅是技巧的学习,更是理解 Vue/React 等现代框架背后“类型魔法”的关键一步。

注意: 接下来的章节难度会稍有提高,我还是会尽量的秉承我们的讲解风格,但能否听懂这章节以及听懂这章节的用处在乎你如果是一个追求高层次的 Typescript 学习者,还是你只是想在项目中使用 Typescript ,那么这就是一道分水岭,如果你认为后续的章节太难,而又想过度到 Vue3 的话,没问题,后续的章节对您的帮助都不会太大


5.1. 痛点呈现:如何优雅地处理“部分更新”

随着我们的 Todo 应用变得越来越真实,一个核心需求出现了:更新一个已存在的待办事项。让我们看看在当前知识体系下,实现这个功能会遇到什么尴尬的局面。

第一步:在我们的 types.ts 中添加 AppState

为了代码的清晰,我们将整个应用的状态也模型化。

文件路径: src/types.ts

1
2
3
4
5
6
// 确保存在如下的状态模型
export type AppState = {
todos: Todo[];
currentUser: User[] | null; // 用户可能存在,也可能未登录
currentFilter: Filter;
}``

第二步:在 main.ts 中添加 updateTodo 函数的“天真”实现

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Todo, AppState, Filter } from './types'; // 引入 AppState

// ... state 定义 (请确保 state 符合 AppState 类型)
const state: AppState = {
// ...
};

// ... 其他函数 函数 ...

// “天真”的 updateTodo 函数
function updateTodo(id: number, updatedTodo: Todo): void {
const todoToUpdate = state.todos.find(todo => todo.id === id);
if (todoToUpdate) {
// 通过传入进来的新对象合并我们的旧对象
Object.assign(todoToUpdate, updatedTodo);
}
}

第三步:暴露痛点

现在,假设我们只想将一个 Todo 的状态切换为“已完成”,我们该如何调用 updateTodo

1
2
3
4
5
6
7
8
9
10
11
12
// 场景:只想把 id 为 2 的 todo 标记为完成
const todoToUpdate = findItemById(state.todos, 2);

if (todoToUpdate) {
// 痛点来了!
// 为了只修改 `completed` 属性,我们却被迫传入一个完整的 Todo 对象,
// 甚至不得不把旧的 `text` 和 `id` 也带上。
updateTodo(2, {
...todoToUpdate,
completed: true
});
}

核心问题: 函数的类型签名 (updatedTodo: Todo) 过于严格,缺乏灵活性。它要求我们为了修改一小部分数据,而兴师动众地提供一个完整的数据结构。这在工程实践中是繁琐且低效的。


5.2. 映射类型:类型的“批量转换”

为了解决上述痛点,TypeScript 提供了一种强大的元编程能力——映射类型。它允许我们基于一个现有类型,通过某种规则“批量转换”出另一个新类型。

5.2.1. Partial<T>: 完美的更新载荷 (Payload)

Partial<T> 是 TypeScript 内置的一个映射类型,它的作用是将类型 T 的所有属性都变为 可选的

第四步:使用 Partial<T> 重构 updateTodo

文件路径: src/main.ts (修改 updateTodo 函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... 其他代码 ...

// 重构后的 updateTodo 函数
// 我们将第二个参数的类型从 Todo 改为 Partial <Todo>
function updateTodo(id: number, payload: Partial<Todo>): void {
const todoToUpdate = state.todos.find(todo => todo.id === id);
if (todoToUpdate) {
// payload 中有什么属性,就更新什么属性
Object.assign(todoToUpdate, payload);
console.log(`新更新的state.todos ${JSON.stringify(state.todos)}`);
}
}

// 现在,调用变得无比优雅和精确
updateTodo(2, { completed: true }); // 只想更新 completed,就只传 completed
updateTodo(1, { text: "学习 TypeScript 高级类型" }); // 只更新 text

思维转变: Partial<T> 让我们学会了创建专门用于“更新”场景的类型。我们不再传递完整的数据实体,而是传递一个描述“变化”的、类型安全的 载荷 (Payload)。这是现代状态管理(如 Redux/Pinia)思想的基石。


5.2.2. 核心基石:深入理解 keyof 操作符

痛点: 在我们能随心所欲地“裁剪”类型之前,我们必须先学会如何获取一个类型的所有“钥匙”——也就是它的所有属性名。

解决方案: keyof 操作符正是为此而生。它接收一个对象类型,并返回一个由该对象所有属性名组成的 字符串字面量联合类型

1
2
3
4
5
6
7
8
9
10
import { Todo } from './types';

// 使用 keyof 获取 Todo 接口的所有键
type TodoKeys = keyof Todo;

// 鼠标悬停在 TodoKeys 上,你会看到它的类型是:
// "id" | "text" | "completed" | "dueDate"

let key: TodoKeys = "text"; // 正确
// key = "description"; // 错误: "description" 不在 TodoKeys 联合类型中

keyof 是理解 PickOmit 的前置钥匙。K extends keyof T 这种泛型约束,正是利用 keyof 来保证我们只能对一个类型已存在的属性进行操作。


5.2.3. Pick<T, K>Omit<T, K>:类型的“裁剪”

除了 Partial,还有两个极其常用的映射类型,用于创建现有类型的子集。

  • Pick<Type, Keys>: 从 Type挑选Keys 联合类型中指定的属性。
  • Omit<Type, Keys>: 从 Type忽略Keys 联合类型中指定的属性。

第五步:在我们的 Todo 应用中实践

场景: 假设我们有一个“预览”组件,它只需要显示 Todo 的 textcompleted 状态,不需要 iddueDate

文件路径: src/types.ts

1
2
3
4
// ... 其他类型 ...

// 使用 Pick 创建一个只包含 text 和 completed 的“预览”类型
export type TodoPreview = Pick<Todo, "text" | "completed">;

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
function renderTodoPreview(preview: TodoPreview): void {
console.log(`[预览] ${preview.text} - ${preview.completed ? '已完成' : '未完成'}`);
// 这一行就会编译错误了
// console.log(preview.id);
}

const todo = findItemById(state.todos, 1);
if (todo) {
// preview 对象上没有 id 属性,访问会导致编译错误
// 但是我没呢 findItemById 是返回的一个完整的 todo 对象,所以他是可以访问 id 属性的
renderTodoPreview(todo);
}

PickOmit 是前端开发中处理组件 Props 的利器。它们能帮助我们精确地控制一个组件应该接收哪些数据,不多也不少,从而实现组件之间清晰的边界和依赖关系。


5.3. 条件类型:类型世界的 if-else

如果说映射类型是“批量转换”,那么条件类型就是“逻辑判断”。它允许 TypeScript 根据一个类型是否满足某个条件,来决定最终应用哪个类型。

5.3.1. extends 关键字的妙用

条件类型的语法非常像 JavaScript 的三元运算符:

SomeType extends OtherType ? TrueType : FalseType;

这里的 extends 不再是类的继承,而是类型系统中的一个逻辑判断,意为“SomeType 是否可以赋值给 OtherType”。

场景: 我们需要一个 process 函数,如果传入的是 string,就返回 string;如果传入的是 number,就返回 number。但如果传入的是 nullundefined,我们希望返回 never 类型,让这种调用在类型层面就变得“不可能”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义一个条件类型
type Processable = string | number;
type ProcessResult<T> = T extends Processable ? T : never;

function process<T>(value: T): ProcessResult<T> {
if (typeof value === 'string' || typeof value === 'number') {
return value as ProcessResult<T>;
}
throw new Error("Invalid value");
}

let result1 = process("hello"); // result1 的类型是 string
let result2 = process(123); // result2 的类型是 number
// let result3 = process(true); // 编译错误!因为 ProcessResult <boolean> 是 never

5.4. 实践:掌握核心内置函数工具类型

痛点: 在大型项目中,我们经常需要编写一些工具函数来处理或包装其他函数(例如,添加日志、缓存等)。在 JS 中,我们无法静态地知道被包装函数的参数和返回值类型,只能依赖 any

解决方案: TypeScript 提供了 Parameters<T>ReturnType<T> 这两个强大的工具类型,它们都基于 infer 实现,可以精准地“捕获”任何函数的签名。

5.4.1. Parameters<T>: 获取函数参数类型

Parameters<T> 接收一个函数类型 T,并返回一个由该函数所有参数类型组成的 元组类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function addTodo(text: string, dueDate?: Date): number {
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
dueDate
};
state.todos.push(newTodo);
return newTodo.id;
}

// 捕获 addTodo 函数的参数类型
type AddTodoParams = Parameters<typeof addTodo>;
// AddTodoParams 的类型是: [text: string, dueDate?: Date | undefined]

5.4.2. ReturnType<T>: 获取函数返回类型

ReturnType<T> 接收一个函数类型 T,并返回该函数的返回值类型。

1
2
3
// 捕获 addTodo 函数的返回类型
type AddTodoReturn = ReturnType<typeof addTodo>;
// AddTodoReturn 的类型是: number

5.4.3. 实践:创建一个类型安全的 logFunctionCall 工具

现在,我们将这两个工具类型组合起来,创建一个通用的、完全类型安全的函数日志记录器。

文件路径: src/utils.ts

1
2
3
4
5
6
7
8
9
10
11
12
// F 必须是一个函数类型
export function logFunctionCall<F extends (...args: any[]) => any>(
fn: F,
...args: Parameters<F> // ...args 的类型被精确地定义为 F 的参数类型
): ReturnType<F> { // 返回值类型被精确地定义为 F 的返回类型

console.log(`${fn.name} 被调用,参数:`, ...args);
const result = fn(...args);
console.log(`${fn.name} 返回:`, result);

return result;
}

main.ts 中使用它:

1
2
3
4
5
6
7
8
import { logFunctionCall } from './utils';
// ...

// 使用 logFunctionCall 来包装 addTodo
const newId = logFunctionCall(addTodo, "完成第四章笔记");

// 尝试使用错误的参数类型,会立刻得到编译时错误!
// logFunctionCall(addTodo, 123); // 错误: 类型“number”的参数不能赋给类型“string”的参数。

5.5 本章小结

高级类型核心价值在我们 Todo 应用中的实践
Partial<T>将所有属性变为可选(关键) 优雅地实现了 updateTodo 函数,使其能接收“部分更新”的数据。
keyof获取类型的所有键是理解 Pick/Omit 的基础,用于创建类型安全的泛型约束。
Pick/Omit从类型中挑选/排除属性创建了一个精确的 TodoPreview 类型,用于只需部分数据的场景。
Parameters<T>捕获函数参数类型元组结合 ReturnType 创建了类型安全的 logFunctionCall 通用工具函数。
ReturnType<T>捕获函数返回值类型同上,保证了包装后函数的返回值类型与原函数完全一致。