第二章: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 中会引起语法冲突,应避免使用。