
0. 先搞清楚:TypeScript 到底在解决什么?
你看,JavaScript 是个好东西——你把代码交给浏览器,它原样执行。但问题就出在"原样"两个字上。
function add(a, b) {
return a + b;
}
add(2, 3); // 5,没问题
add("2", 3); // "23",咦?字符串拼接了
add(2, "3"); // "23",照样拼
add({}, 3); // "[object Object]3",彻底乱套
函数 add 心里想的是"两个数相加",但 JavaScript 不管这些。你喂它什么,它就按自己的规则硬算。等到程序跑了一万次、在生产环境崩了,你才知道 a 不知道什么时候变成了一个对象。
TypeScript 干的事只有一件:在代码运行之前,替你把"这个变量应该是什么"钉死,并且检查你有没有违反自己的约定。
function add(a: number, b: number): number {
return a + b;
}
add(2, 3); // ✅
add("2", 3); // ❌ 报错:Argument of type 'string' is not assignable to parameter of type 'number'
关键点:TypeScript 只在你写代码的时候报错,编译成 JavaScript 后这些检查全部消失。 它不让你半夜被 undefined is not a function 叫醒,就这么简单。
类型不是"额外工作",类型是"把你脑子里对数据的假设写出来"。你不写,这些假设就藏在某个角落,等到出事才暴露。
1. 类型注解与类型推断
1.1 显式注解(你亲口告诉编译器)
冒号 : 是"这个东西是什么类型"的记号。
let age: number = 18;
let name: string = "前端王睿";
let isStudent: boolean = true;
let hobbies: string[] = ["物理", "打鼓", "开锁"];
1.2 类型推断(编译器比你聪明,能猜出来)
很多时候你不用写类型,TypeScript 看一眼右边就能推断出来:
let age = 18; // 推断为 number
let name = "前端王睿"; // 推断为 string
let nums = [1, 2, 3]; // 推断为 number[]
age = "十八"; // ❌ 报错:不能把 string 赋给 number
那什么时候该写、什么时候该让它推断? 一条简单规则:
- 变量初始化时就赋值 → 让编译器推断,别多写。
- 函数参数、函数返回值、对象结构这些"对外契约" → 显式写出来,让读代码的人一眼看清边界。
// 参数和返回值都写出来,这是函数的"接口合同"
function greet(name: string): string {
return `你好,${name}`;
}
2. 基本类型全家福
| 类型 | 说明 | 例子 |
|---|---|---|
number |
所有数字(没有 int/float 之分) |
42, 3.14, 0b1010
|
string |
字符串 |
"hi", `值=${x}`
|
boolean |
布尔 |
true, false
|
null |
空值(已赋值但为空) | let x = null |
undefined |
未定义(还没赋值) | let x: number | undefined |
any |
退出类型检查(危险!) | let x: any = 1 |
unknown |
类型安全的 any(推荐) |
let x: unknown = 1 |
void |
函数无返回值 | function log(): void {} |
never |
永远不会发生的值 | 抛异常的函数 |
object |
非原始类型 | { a: 1 } |
any 与 unknown 的区别(很多人栽在这)
any 等于对编译器说"别管了",它关掉了所有检查——这是把枪口对准自己:
let data: any = "字符串";
data.foo.bar.baz(); // 编译通过,但运行时必崩
unknown 是"我不知道是什么,但你用之前必须证明你知道":
let data: unknown = "字符串";
data.length; // ❌ 报错:unknown 上不能直接访问属性
if (typeof data === "string") {
data.length; // ✅ 收窄后可用,data 被推断为 string
}
记住:能写
unknown就不要写any!
3. 数组与元组
3.1 数组
let scores: number[] = [90, 85, 77];
let names: Array<string> = ["a", "b"]; // 另一种等价写法(泛型,见第6节)
3.2 元组(Tuple)——"位置固定、类型固定的小数组"
普通数组要求"所有元素同一类型"。但现实中很多东西是按位置区分的,比如坐标、键值对:
// 第一个是名字(string),第二个是分数(number)
let student: [string, number] = ["前端王睿", 95];
student[0].toUpperCase(); // ✅ string 方法可用
student[1].toFixed(1); // ✅ number 方法可用
student = [95, "前端王睿"]; // ❌ 顺序错了,报错
4. 对象类型与接口(Interface)
4.1 内联对象类型
let person: { name: string; age: number } = {
name: "前端王睿",
age: 18
};
4.2 接口(命名的对象结构契约)
当同一个结构出现多次,就该给它起个名字——这就是 interface:
interface Person {
name: string;
age: number;
email?: string; // ? 表示可选属性
readonly id: number; // readonly 表示创建后不能改
}
const p: Person = {
name: "前端王睿",
age: 18,
id: 1
};
p.age = 19; // ✅
p.id = 2; // ❌ 报错:Cannot assign to 'id' because it is a read-only property
p.email = "f@x.com"; // ✅ 可选属性可以补上
4.3 接口可以"继承"——extends
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
const d: Dog = { name: "旺财", breed: "柴犬" }; // 必须同时有 name 和 breed
4.4 类型别名 type 与接口 interface 怎么选?
两者大部分时候能互换:
type Person = { name: string; age: number };
interface Person2 { name: string; age: number }
区别(记住这几条就够了):
- 接口可以被声明合并(多次声明同名接口会自动合并),
type不行。 -
type能表示任何类型(联合、基础类型、元组等),接口主要用于对象。 - 官方建议:描述对象/类的结构用
interface;其他复杂类型组合用type。
// 声明合并:同一个 interface 名字出现两次,自动合并
interface User { name: string; }
interface User { age: number; }
// 最终 User = { name: string; age: number }
5. 联合类型与交叉类型
5.1 联合类型(Union)——"或者是 A,或者是 B"
function printId(id: number | string) {
console.log(`ID 是 ${id}`);
}
printId(101); // ✅
printId("abc"); // ✅
printId(true); // ❌ 既不是 number 也不是 string
但注意:联合类型下,你只能使用"所有候选类型都有的方法":
function printId(id: number | string) {
id.toUpperCase(); // ❌ number 没有 toUpperCase,报错
// 必须先收窄:
if (typeof id === "string") {
id.toUpperCase(); // ✅ 这里 id 被收窄为 string
}
}
5.2 交叉类型(Intersection)——"既是 A,又是 B"
type A = { x: number };
type B = { y: number };
type C = A & B; // 同时拥有 x 和 y
const c: C = { x: 1, y: 2 };
|是"或者",&是"并且"。
6. 泛型(Generics)——"类型的函数"
这是最让人头大的一节,但也是 TypeScript 最强大的地方。先别背定义,看问题。
你想写一个"返回数组第一个元素"的函数。问题是:数组里装什么,取决于调用时传什么。你不想为 number[]、string[] 各写一个函数。
泛型就是"把类型当参数传进去"——就像函数把值当参数,泛型把类型当参数。
function first<T>(arr: T[]): T {
return arr[0];
}
const a = first([1, 2, 3]); // T 被推断为 number,a: number
const b = first(["x", "y"]); // T 被推断为 string,b: string
const c = first([true, false]); // T 被推断为 boolean,c: boolean
<T> 里的 T 是"类型变量"的惯例命名(Type 的首字母)。你可以叫 <Item>、<X>,但社区都用 T。
6.1 泛型 + 约束(extends)
有时候你想让泛型"至少是某种形状":
interface HasLength {
length: number;
}
// T 必须拥有 length 属性
function logLength<T extends HasLength>(item: T): T {
console.log(item.length);
return item;
}
logLength("hello"); // ✅ string 有 length
logLength([1, 2, 3]); // ✅ 数组有 length
logLength(123); // ❌ number 没有 length
6.2 真实项目里泛型长什么样?
React 里最常见的 useState:
// 简化版实现
function useState<T>(initial: T): [T, (next: T) => void] {
let value = initial;
const setter = (next: T) => { value = next; };
return [value, setter];
}
const [count, setCount] = useState(0); // T = number
const [name, setName] = useState(""); // T = string
泛型不是炫技。它就是"这个函数/类型能处理多种数据,但调用时一锤定音,全程类型安全"。你写一次,编译器替你守住所有调用点。
7. 类型收窄(Narrowing)与类型守卫
编译器再聪明,遇到 string | number 也会"分不清此刻到底是哪种"。收窄就是写一段代码,让编译器在某一块作用域里"锁定"具体类型。
7.1 typeof 守卫
function double(input: number | string) {
if (typeof input === "number") {
return input * 2; // ✅ 这里 input 是 number
}
return input.repeat(2); // ✅ 这里 input 是 string
}
7.2 in 守卫(判断对象有没有某个属性)
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // ✅ 收窄为 Fish
} else {
animal.fly(); // ✅ 收窄为 Bird
}
}
7.3 自定义类型守卫(is 关键字)
这是最帅的用法——你写一个返回 value is Type 的函数,编译器会信你:
function isString(value: unknown): value is string {
return typeof value === "string";
}
function format(input: unknown) {
if (isString(input)) {
input.toUpperCase(); // ✅ input 被收窄为 string
}
}
8. 类(Class)与访问修饰符
TypeScript 给 JavaScript 的类加了三道门:public(默认,谁都能进)、private(只有自己能进)、protected(自己和子类能进)。
class BankAccount {
public owner: string; // 公开
private balance: number; // 私有,外部拿不到
protected id: number; // 受保护,子类可继承
constructor(owner: string, balance: number) {
this.owner = owner;
this.balance = balance;
this.id = Math.random();
}
public deposit(amount: number): void {
this.balance += amount; // 内部可以操作 private
}
public getBalance(): number {
return this.balance; // 通过方法暴露,而不是暴露字段
}
}
const acc = new BankAccount("前端王睿", 100);
acc.deposit(50);
console.log(acc.getBalance()); // 150
console.log(acc.balance); // ❌ 报错:balance 是 private
为什么要有
private?因为"钱"的逻辑(存取、校验)应该锁在类内部。外部只能走你允许的门。这和现实里银行不让你直接进金库是一个道理。
9. 实用工具类型(Utility Types)——"类型层面的乐高"
TypeScript 内置了一批"对类型做加工"的工具。你不用自己从头拼,直接用。
interface User {
id: number;
name: string;
email: string;
age: number;
}
// Partial:所有属性变可选(常用于"更新"接口)
type UserUpdate = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number }
// Pick:只挑几个属性
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string }
// Omit:排除几个属性
type NewUser = Omit<User, "id">;
// { name: string; email: string; age: number }
// Readonly:全部只读
type FrozenUser = Readonly<User>;
// Record:构造"键→值"映射类型
type RoleMap = Record<string, string>;
const roles: RoleMap = { admin: "管理员", user: "普通用户" };
最常用的还有 Required(全部必填,与 Partial 相反)。这些不是魔法,它们是用第10节的条件类型实现的。
10. 条件类型与 infer(进阶)
条件类型像"三元表达式",但是给类型用的:
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
infer 更妙——它能在条件类型里"抽取"出一部分类型。比如"取出数组元素的类型":
type ElementType<T> = T extends (infer E)[] ? E : T;
type X = ElementType<number[]>; // number
type Y = ElementType<string>; // string(不是数组,原样返回)
但实际上你不需要自己写这个——它已经被内置成 ReturnType、Parameters 等工具:
function getUser(id: number): { name: string; age: number } {
return { name: "x", age: 1 };
}
type UserInfo = ReturnType<typeof getUser>;
// { name: string; age: number } —— 自动从函数推导出返回值类型
这一节进阶内容,第一遍看不懂很正常。困惑是理解的起点,不是你笨。先会用第9节的工具类型,等哪天你真的需要"动态算出一个类型",再回来啃
infer。
11. 一个完整小例子:把前面全串起来
一个"给任务列表加标签"的迷你系统:
// 1. 用 interface 定义数据结构
interface Task {
id: number;
title: string;
done: boolean;
tags: string[];
}
// 2. 用泛型函数创建任务
function createTask<T extends Task>(data: T): T {
console.log(`创建任务:${data.title}`);
return data;
}
// 3. 用 Utility Type 做"更新"——Partial 让更新时只传要改的字段
function updateTask(task: Task, patch: Partial<Task>): Task {
return { ...task, ...patch };
}
// 4. 类型守卫:判断是不是已完成
function isDone(task: Task): task is Task & { done: true } {
return task.done === true;
}
// 运行
const t1 = createTask({ id: 1, title: "学 TypeScript", done: false, tags: ["学习"] });
const t2 = updateTask(t1, { done: true }); // 只改 done
if (isDone(t2)) {
console.log(`${t2.title} 已完成 ✅`);
}
每一行都能对应到前面某一节。如果你能看着这个例子说出"这里用了第几节的什么",你就真懂了——不是知道名字,是理解它。
12. 你真懂没懂,做个自检
对着下面每条,能用自己的话 + 一个例子解释,才算过:
- 为什么 TypeScript 编译后类型就没了? —— 类型只为"写代码时"服务,运行时是纯 JS。
-
any和unknown差在哪? ——any关检查,unknown强制你先证明类型。 -
联合
|和交叉&的区别? —— 或 / 且。 - 泛型解决了什么? —— 一份逻辑处理多种类型,且全程类型安全。
-
收窄为什么必要? —— 联合类型下编译器分不清,用
typeof/in/is帮它锁定。 -
Partial/Pick/Omit用来干嘛? —— 在已有接口上做增删改,避免重复定义。
把每个例子亲手敲一遍,改一改、故意写错看报错。报错信息就是你最好的老师。别只看文章,去造它。
写这篇不是让你背下来,是让你下次写 TypeScript 时,能想起"这个类型到底在帮我省什么麻烦"。想清楚这个,剩下的查文档就行。