🐣 纯新手先看这一框(30 秒秒懂)
TypeScript 适合做什么?Web 前端(Angular / React / Vue)、Node.js 后端、桌面(Electron)、甚至 Deno/Bun 运行时。一句话:凡是 JavaScript 能跑的地方,TypeScript 都能上,还多了“编译期类型检查”这层保护网。
为什么要学它?JS 是动态类型,写大了容易出“运行时才发现的低级错”。TS 给 JS 加了静态类型,能在写代码时(甚至保存时)就把大量 bug 挡在编译阶段,大项目维护成本直线下降。现代前端岗位基本默认要求 TS。
怎么跑起来?本站「动手实验室」选 TypeScript 点运行即可,不用装环境;想深入再装 Node + npm i -g typescript,用 tsc 文件.ts 编译成 JS 再 node 文件.js 运行,或直接用 deno run 文件.ts / bun run 文件.ts。
新手最常踩的坑?① TS 报错但 JS 能跑——因为 TS 只是“类型层”报错,底层还是 JS;② any 会关闭检查,滥用等于白学;③ 类型报错≠不能编译,可用 // @ts-ignore 强制跳过(但不推荐);④ 浏览器不能直接跑 .ts,必须先编译成 .js。
课程说明给会一点 JS 的人,系统吃透 TypeScript 类型系统
TypeScript 由微软在 2012 年发布,是 JavaScript 的超集(superset):任何合法的 JS 代码都是合法的 TS 代码,TS 只是在其之上叠加了类型注解与一套强大的编译期类型系统。最终 TS 会被编译器(tsc)擦除类型、转译成纯 JS 交给运行时执行——所以“运行时没有类型”,类型完全是开发期的帮手。
本课程假设你已经懂基础 JavaScript(变量、函数、对象、Promise)。重点放在TS 区别于 JS 的类型能力:类型注解、接口、泛型、联合/交叉、类型收窄、装饰器、工具类型、以及面试高频的“结构化类型”“协变逆变”“条件类型”等进阶主题。我们会反复强调“类型是用来约束你自己的,不是约束运行时的”。
typescript。编译命令 npx tsc 文件.ts(会生成同名 .js);想只检查类型不生成文件用 tsc --noEmit 文件.ts。本站运行器使用 TypeScript 5.6。生产项目用 tsconfig.json 统一管理编译选项。
0.1 TypeScript 适合做什么(场景一览)
| 场景 | 为什么适合 TS |
|---|---|
| Web 前端框架 | React / Vue / Angular 首选 TS,组件 props、状态都有类型,改一处全链报错 |
| Node.js 后端 | NestJS、Express+TS 让接口契约清晰,入参出参自动校验思路 |
| 前端库 / 工具 | 类型即文档,调用方不用看实现就有补全,体验拉满 |
| 跨端应用 | Electron / React Native 可与 Web 共享同一套类型定义 |
| 大型协作项目 | 重构安全、减少“改了 A 忘了 B”的沟通成本 |
| 不适合 | 一次性小脚本、对启动体积/编译速度极度敏感且无需维护的场景 |
1. Hello World 与编译运行tsc、deno、bun,以及“类型即文档”
TS 写法和 JS 几乎一样,区别是可以在变量/参数后面加 : 类型 注解。下面这段:name: string 告诉编译器“name 必须是字符串”,传错类型会在保存/编译时报红。
function greet(name: string): string {
return "你好," + name;
}
const msg: string = greet("小明");
console.log(msg); // 你好,小明
npx tsc hello.ts # 生成 hello.js(擦除类型后的纯 JS)
node hello.js # 运行
# 或者一步到位(需要 deno / bun)
deno run hello.ts
bun run hello.ts
noEmitOnError)。也就是说你看到一堆类型报错,但 tsc 仍可能吐出一份能跑的 .js。类型报错是“提醒你可能有 bug”,不是“阻止你运行”。想严格点,在 tsconfig.json 里开 "noEmitOnError": true。
function add(a: number, b: number): number,不用看实现就知道要传俩数字、返回数字。类型注解本身就是最准确的文档,比注释可靠——注释会过期,类型不会。
1.1 严格模式 strict 才是真 TS
默认 tsc 比较宽松。真正发挥 TS 威力要开 strict: true(其实是一组开关的集合),它会强制:变量必须初始化、null/undefined 不能随便赋给普通类型、不允许隐式 any 等。
// tsconfig.json 关键项
{
"compilerOptions": {
"strict": true, // 开启全部严格检查
"noImplicitAny": true, // 不允许隐式 any
"strictNullChecks": true, // null/undefined 不能随便赋值
"noEmitOnError": true // 有类型错误就不生成 JS
}
}
any。新项目一律 "strict": true。老项目突然全开会冒一堆红,可逐个子开关渐进开启(先 strictNullChecks,再 noImplicitAny……)。
1.2 只检查不编译:--noEmit
日常开发常让 tsc 只做类型检查、不改变文件,配合打包器(Vite/Webpack)由它们负责转译。这能最快发现类型错误。
npx tsc --noEmit # 只检查类型,不生成 .js
npx tsc --noEmit --watch # 监听文件,保存即检查
2. 类型注解与基础类型string/number/boolean/数组/元组/枚举/any/unknown/never
TS 的内建基础类型基本对应 JS 的类型,外加几个 TS 特有概念(any、unknown、never、元组、枚举)。类型注解写在变量/参数/返回值后面,用冒号分隔。
| 类型 | 含义 | 示例 |
|---|---|---|
string | 字符串 | let s: string = "hi"; |
number | 所有数字(含整/浮,无 int/float 之分) | let n: number = 3.14; |
boolean | true / false | let ok: boolean = true; |
Array<T> / T[] | 数组 | let a: number[] = [1,2,3]; |
[T, U] | 元组(固定长度+固定类型) | let p: [string, number] = ["x", 1]; |
any | 关掉检查(不推荐) | let x: any = 1; |
unknown | 未知但安全(需收窄后用) | let u: unknown = getData(); |
void | 函数无返回值 | function f(): void {} |
never | 永远不会发生(见第 16 节) | function fail(): never { throw new Error(); } |
let title: string = "课程";
let count: number = 10;
let flag: boolean = false;
let nums: number[] = [1, 2, 3];
let pair: [string, number] = ["age", 18]; // 元组:第一项是字符串,第二项是数字
console.log(title, count, flag, nums, pair);
console.log(pair[0].toUpperCase()); // AGE
any 等于对编译器说“这块别管了”,于是类型检查彻底失效、自动补全也没了。能不用就不用;如果确实不知道类型,优先用 unknown(见第 9 条干货)。把整个文件写成 any 就等于退回纯 JS,那还学 TS 干啥。
string[] 表示"任意多个字符串";元组 [string, number] 表示"正好两个元素,分别是字符串和数字",常用于返回多值(如 [错误, 数据])。越界访问元组会报类型错。
2.1 any 与 unknown 的实战对比
两者都表示“类型不确定”,但安全性天差地别。下面代码直观对比:any 让你乱来编译器装瞎,unknown 逼你先判断再动手。
let a: any = getData();
a.foo.bar.baz(); // 编译器不拦,运行时可能炸
let u: unknown = getData();
// u.trim(); // ❌ 不能直接调方法
if (typeof u === "string") {
u.trim(); // ✅ 收窄成 string 后才能用
}
JSON.parse 返回类型被 TS 历史性地标成 any(本应是 unknown)。这意味着你 parse 完直接 obj.user.name 编译器不报错,但数据结构一旦对不上就运行时崩溃。务必手动断言:const obj = JSON.parse(s) as unknown as User; 或先用类型守卫校验。
2.2 枚举、字面量、联合的取舍
表示“有限取值集合”有三种姿势,各有适用场景:
| 写法 | 运行时开销 | 适用 |
|---|---|---|
enum | 有(生成对象) | 需反向映射、老代码 |
type X = "a" | "b" | 无 | 纯字面量集合(首选) |
const enum | 无(内联) | 要枚举语义但不想有运行时对象 |
// 现代首选:字面量联合 + as const 常量对象
const ROLE = { ADMIN: "admin", USER: "user" } as const;
type Role = typeof ROLE[keyof typeof ROLE]; // "admin" | "user"
let r: Role = ROLE.ADMIN;
3. 类型推断与字面量类型能不写类型就别写,但字面量类型是另一回事
TS 很聪明:大多数时候你不写注解,它也能从“初始值”推断出类型。这叫类型推断(type inference)。所以日常写代码能省则省,只在推断不清或想放宽类型时才显式标注。
let x = 10; // 推断为 number
x = "hi"; // ❌ 类型报错:number 不能赋给 string
const y = 10; // 推断为字面量类型 10(不是 number!)
// y = 11; // ❌ 字面量类型 10 不能变成 11
let z: number = 10; // 显式标注为 number,才能再赋别的数
z = 11; // ✅ OK
3.1 字面量类型(literal types)
TS 里不仅 "hello"、42、true 是值,它们还可以是类型。字面量类型在“限定取值集合”时极其好用,是联合类型与判别联合的基础。
type Direction = "上" | "下" | "左" | "右";
let d: Direction = "上"; // ✅
// d = "前"; // ❌ 不在集合里
type StatusCode = 200 | 404 | 500;
let code: StatusCode = 200; // ✅
let 推断成“基础类型”(如 number),const 推断成“字面量类型”(如 10)。这差别在配合联合类型、函数重载、as const 时很关键(见第 11 条干货)。想让 let 也变成字面量,可写 let x = "a" as const;。
3.1 函数返回类型也靠推断
函数若直接 return 一个值,返回类型会被推断出来。但跨模块导出、或逻辑复杂的函数,显式写返回类型能更早暴露“返回了不该返回的东西”。
function double(n: number) {
return n * 2; // 推断返回 number
}
// 显式标注:若以后误返回 string,编译立即报错
function doubleSafe(n: number): number {
return n * 2;
}
3.2 字面量拓宽(widening)与控制
把 let 赋给一个期望“字面量类型”的位置时,TS 会“拓宽”成基础类型;用 as const 或 readonly 可阻止拓宽。这是联合类型、判别联合能工作的底层原因之一。
let mode = "dev"; // 推断 string(拓宽)
const opts = { mode: "dev" } as const; // mode 锁成字面量 "dev"
type M = typeof opts.mode; // "dev"(没被拓宽)
4. 接口与类型别名interface vs type,描述“对象长什么样”
对象和函数是最常用的结构。用 interface(接口)或 type(类型别名)给“某种形状”起个名字,之后就能像基础类型一样使用。这是 TS 描述数据结构的基石。
interface User {
id: number;
name: string;
email?: string; // ? 表示可选属性
readonly role: string; // 只读:赋值后不能再改
}
const u: User = {
id: 1,
name: "小明",
role: "admin"
};
// u.role = "user"; // ❌ 只读属性不能改
console.log(u.name);
4.1 interface 与 type 的区别
描述对象/函数时两者几乎可互换;但有几个关键差异(详见第 8 条干货“声明合并”):interface 可以同名自动合并,type 不行(重复声明会报错)。
| 场景 | interface | type |
|---|---|---|
| 描述对象形状 | ✅ 首选 | ✅ |
| 同名声明合并 | ✅ 支持 | ❌ 不支持 |
| 联合/交叉组合 | ❌ 不便 | ✅ 灵活 |
| 映射类型 / 工具类型 | ❌ | ✅ 必须 type |
email?: 表示“可能有也可能没有”。访问它时它的类型是 string | undefined,直接当 string 用会报错。要么先用 if (u.email) 收窄,要么用可选链 u.email?.toUpperCase()。
interface(语义清晰、可合并);做联合、交叉、条件类型、工具类型时用 type。团队里定一个统一约定比纠结语法更重要。
4.1 索引签名:描述“任意键”的对象
当你不知道对象有哪些键、但知道值的类型时,用索引签名 [key: string]: T。常见场景:字典、配置表、动态字段。
interface StringMap {
[key: string]: string; // 任意字符串键,值都是 string
}
const m: StringMap = { a: "1", b: "2" };
console.log(m.a, m.b);
[key: string]: string 却写 count: number 会报错。需要混合时,用 Record<string, unknown> 或把具体字段抽成另一种类型。
4.2 type 做联合与交叉的灵活玩法
当类型不是单纯“对象形状”而是“多种可能”时,type 比 interface 顺手得多。
type Id = number | string;
type Point = { x: number; y: number };
type Named = { name: string };
type NamedPoint = Point & Named; // 交叉:同时有 x/y/name
const p: NamedPoint = { x: 1, y: 2, name: "原点" };
console.log(p);
5. 函数参数类型、返回值、可选/默认/剩余、函数类型、重载
函数里每个参数和返回值都能加类型。TS 还支持可选参数(?)、默认值、剩余参数(...),以及“函数类型”本身作为一种类型。
function add(a: number, b: number = 0): number {
return a + b;
}
function join(...parts: string[]): string { // 剩余参数
return parts.join("-");
}
console.log(add(3)); // 3(b 用默认 0)
console.log(add(3, 4)); // 7
console.log(join("a", "b", "c")); // a-b-c
// 函数类型:把“长这样”的函数存进变量
const fn: (x: number) => number = (x) => x * 2;
console.log(fn(5)); // 10
5.1 函数重载(overload)
当函数“根据输入返回不同类型”时,可用重载声明多个签名,再写一个统一实现。注意:实现签名本身对外不可见。
function reverse(x: string): string;
function reverse(x: number): number;
function reverse(x: string | number): string | number {
if (typeof x === "string") return x.split("").reverse().join("");
return Number(x.toString().split("").reverse().join(""));
}
console.log(reverse("abc")); // cba
console.log(reverse(123)); // 321
if/typeof 区分。新手常把实现签名也当成对外接口,结果调用时被类型限制——记住实现签名不暴露。
undefined 占位,很丑。可选参数 ? 本质也是一种“可省略 + 类型含 undefined”的参数。
5.1 可选链与空值合并(运行时也有)
TS 支持 JS 的可选链 ?. 和空值合并 ??,既省判空又安全。注意这是运行时语法(不只是类型)。
interface User { address?: { city?: string } }
const u: User = {};
console.log(u.address?.city); // undefined(不会报错)
console.log(u.address?.city ?? "未知"); // "未知"
const n = 0;
console.log(n ?? 10); // 0(?? 只在 null/undefined 时兜底,不像 || 把 0 也兜底)
console.log(n || 10); // 10(|| 把 0 当假值,常是坑)
|| 对所有“假值”(0、""、false、NaN)都兜底,常误伤;?? 只在 null/undefined 时兜底。处理“数字 0 / 空字符串也是合法值”的场景,务必用 ??。两者不能直接连写(a || b ?? c 语法错),要加括号。
5.2 this 的类型标注
interface Calculator {
value: number;
add(this: Calculator, n: number): void;
}
const calc: Calculator = {
value: 0,
add(n) { this.value += n; }
};
calc.add(5);
console.log(calc.value); // 5
this: SomeType,能让 TS 检查“这个方法被调用时 this 是否正确”。尤其把方法单独抽出来当回调传出去时,this 很容易丢,标注后能提前发现。
6. 类 class属性、访问修饰符、readonly、参数属性、继承、抽象类
TS 的 class 在 JS class 之上加了访问修饰符(public/private/protected)、readonly、参数属性等,让封装更严格。注意:这些修饰符只存在于编译期,运行时(JS)里 private 并不能真挡住访问。
class Animal {
protected legs: number = 4; // 子类可见,外部不可见
constructor(public name: string) {} // 参数属性:自动建字段并赋值
move(): void {
console.log(this.name + " 用 " + this.legs + " 条腿走");
}
}
class Dog extends Animal {
bark(): void {
console.log("汪汪,legs=" + this.legs);
}
}
const d = new Dog("旺财");
d.move(); // 旺财 用 4 条腿走
d.bark(); // 汪汪,legs=4
| 修饰符 | 可见范围 |
|---|---|
public | 哪都能访问(默认) |
private | 仅本类内部 |
protected | 本类 + 子类 |
readonly | 只读(不可写,可单独或配合上面用) |
private 仅防止你在“类型层面”误访问;编译成 JS 后它就是普通属性,别人照样能读。真要运行时私有,用 JS 原生的 #field(私有字段)语法。别以为加了 private 就安全了。
constructor(public name: string) 一句等于“声明字段 + 构造函数里赋值”两件事,强烈推荐。抽象类用 abstract 关键字(见进阶第 14 节判别联合旁的相关概念)。
6.1 抽象类 abstract
abstract 类不能直接 new,只能被继承;abstract 方法只有签名、没有实现,强制子类去填。适合“定好骨架、留坑给子类实现”的模板方法模式。
abstract class Shape {
abstract area(): number; // 子类必须实现
describe(): void { // 已实现的方法
console.log("面积=" + this.area());
}
}
class Square extends Shape {
constructor(private side: number) { super(); }
area(): number { return this.side * this.side; }
}
new Square(3).describe(); // 面积=9
private 的状态;接口只描述“形状”、没有实现。需要共享代码用抽象类,只需约定形状用接口。而且 TS 是单继承(一个类只能 extends 一个父类),但可以实现多个接口。
6.2 实现多个接口
interface Loggable { log(): void; }
interface Serializable { toJSON(): string; }
class Model implements Loggable, Serializable {
log() { console.log("log"); }
toJSON() { return "{}"; }
}
7. 泛型入门让函数/类“参数化类型”,写一次适配多种类型
泛型(Generics)是“类型的参数”。当你不知道或不愿固定某个类型,但又要保持类型关联时,用 <T> 占位,调用时再填具体类型。它是 TS 复用与类型安全的核心。
function identity<T>(arg: T): T {
return arg;
}
const a = identity<string>("hi"); // a: string
const b = identity(123); // 推断 b: number,可省略显式标注
// 泛型约束“容器里的第一个元素”
function first<T>(arr: T[]): T {
return arr[0];
}
console.log(first([1, 2, 3])); // 1
console.log(first(["x", "y"])); // x
any,first 会返回 any,丢失了“返回类型和数组元素类型相同”的信息。泛型让 TS 记住这个关联:传 number[] 就返回 number,传 string[] 就返回 string——既灵活又安全。
T(Type)、U、K(Key)、V(Value)、E(Element),但多参数时最好起有意义的名(如 fetchData<Req, Res>)。泛型不是越短越好,可读性优先。
7.1 泛型类与默认类型参数
泛型不仅能用在函数,也能用在类、接口,甚至是“默认类型参数”——调用时省略就用默认值。
class Box<T = string> { // 默认 T = string
constructor(public value: T) {}
get(): T { return this.value; }
}
const a = new Box("hi"); // T 推断为 string
const b = new Box<number>(10); // 显式指定 number
console.log(a.get(), b.get());
// 泛型接口
interface Pair<K, V> { key: K; val: V; }
const p: Pair<string, number> = { key: "age", val: 18 };
<T, U = unknown> 这类“主类型 + 可选辅类型”的写法很常见,比如 Map<K, V>。
7.2 泛型约束 + keyof(按 key 取值)
这是一个极其实用的套路:约束 key 必须是对象的真实键,且返回值类型精确跟随 key。
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // 返回值类型精确对应 key
}
const u = { id: 1, name: "x" };
const n: number = getProp(u, "id"); // 返回 number
const s: string = getProp(u, "name"); // 返回 string
// getProp(u, "age"); // ❌ "age" 不是 u 的键
obj[key] 往往返回 any,用这个泛型函数则全程类型正确。这是 TS 泛型最常用、最能体现代价比的写法之一,建议背下来。
8. 联合/交叉与类型收窄| 与 &,把“或/且”写进类型
联合类型 A | B 表示“可能是 A 也可能是 B”;交叉类型 A & B 表示“同时具备 A 和 B”。配合“类型收窄(narrowing)”,你能安全地从联合里取出想要的那一支。
type Id = number | string; // 联合
function printId(id: Id): void {
if (typeof id === "string") {
console.log("字符串ID: " + id.toUpperCase()); // 这里 id 被收窄为 string
} else {
console.log("数字ID: " + id.toFixed(0)); // 这里 id 是 number
}
}
printId(123);
printId("abc");
// 交叉:同时有 A 和 B 的字段
type A = { x: number };
type B = { y: number };
type C = A & B; // { x: number; y: number }
const c: C = { x: 1, y: 2 };
A & B 要求“同时满足”,不是“二选一”。如果两个类型有冲突字段(比如 x: string 交叉 x: number),结果会变成 never——没有任何值能同时满足。别和联合 | 搞混。
typeof、in、instanceof、=== 字面量判断、以及自定义类型守卫(x is T,见第 4 条干货)都能触发收窄。写代码时多用这些判断,TS 会自动把“宽类型”收成“窄类型”,补全和检查都更准。
8.1 in 与 instanceof 收窄
当联合的成员都是对象、typeof 区分不开时,用 in 判断“有没有某个属性”,用 instanceof 判断“是不是某个类的实例”。
interface A { type: "a"; a: number; }
interface B { type: "b"; b: string; }
type X = A | B;
function f(x: X) {
if ("a" in x) {
console.log(x.a); // 收窄到 A
} else {
console.log(x.b); // 收窄到 B
}
}
class Cat { meow() {} }
class Dog { bark() {} }
function sound(p: Cat | Dog) {
if (p instanceof Cat) p.meow();
else p.bark();
}
in 用来判断“对象有没有某个属性”,对 null/undefined 用 in 会抛运行时错误,用之前先判非空。区分类实例优先用 instanceof,它同时做运行时检查,比单纯类型断言安全。
8.2 类型谓词 vs 直接返回布尔
普通返回 boolean 的函数不会触发收窄;只有返回 x is T 的“类型谓词”函数才能在 if 里收窄。别写成返回 boolean 却指望 TS 懂。
// ❌ 返回 boolean,if 里不会收窄
function isFishWrong(x: unknown): boolean { return ... }
// ✅ 类型谓词,if 里 x 收窄为 Fish
function isFish(x: unknown): x is { name: string } {
return typeof x === "object" && x !== null && "name" in x;
}
9. 枚举 enum给一组相关常量起名字,外加 const enum 的性能取舍
枚举把一组具名常量组织起来,比裸数字/字符串可读、可维护。TS 有数字枚举和字符串枚举,还有 const enum(编译期内联,更省)。
enum Color { Red, Green, Blue } // 数字枚举:0,1,2
let c: Color = Color.Green;
console.log(c); // 1
enum Dir { Up = "UP", Down = "DOWN" } // 字符串枚举,调试更友好
let d: Dir = Dir.Up;
console.log(d); // UP
Color[1] 能反查出 "Green"),这会带来一点点运行时开销和体积。字符串枚举没有反向映射。若只想要“编译期内联、零运行时对象”,用 const enum,但注意它和某些打包/隔离编译模式(如 isolatedModules)不兼容。
const 对象 + as const(见第 11 条干货)。
9.1 字符串枚举 vs 字面量联合
字符串枚举和字面量联合都能表达“有限取值”,但前者有运行时对象、后者零成本。新代码多数场景字面量联合更轻。
// 字符串枚举(有运行时对象)
enum Status { Ok = "OK", Err = "ERR" }
let s: Status = Status.Ok;
// 字面量联合(零成本,推荐)
type Status2 = "OK" | "ERR";
let s2: Status2 = "OK";
console.log(s, s2);
const enum 编译期直接把 Color.Red 内联成 0,不生成对象,省体积;但开启 isolatedModules(Vite/esbuild/Babel 默认)时它不被支持,会报错。这类项目用字面量联合或普通 const 对象替代。
9.2 枚举与反向映射的调试陷阱
数字枚举的反向映射(Color[1] → "Green")在调试时看似方便,但一旦你重构枚举顺序,旧日志里的数字就指向了错误的名字,反而误导。新项目建议直接字符串枚举或字面量联合,调试时看到 "OK" 比看到 0 直观得多。
10. 模块与命名空间import/export 才是现代写法,namespace 基本退场
现代 TS 项目用 ES Module(import/export)组织代码,和浏览器/Node 的 ESM 一致。老式的 namespace(以前叫 module)基本已被取代,新项目别用了。
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export const PI = 3.14159;
// main.ts
import { add, PI } from "./math";
console.log(add(1, 2)); // 3
console.log(PI); // 3.14159
// 也可以整体导入
import * as M from "./math";
console.log(M.add(3, 4)); // 7
import 通常不写 .ts 扩展名(写 ./math 不是 ./math.ts),编译后由模块解析规则决定。但 Deno/Bun 要求写全 .ts 扩展名——按你用的运行时约定来。混用会报“找不到模块”。
export function)可多个、导入时按名取,重构友好;默认导出(export default)一个模块一个。库内部建议多用命名导出,方便 tree-shaking 和按需引入。
10.1 import type:只导入类型
用 import type 只引入“类型”,编译后会被完全擦除、不产生任何运行时 import。在 isolatedModules 模式下,这能避免“把一个只用于类型的 import 留到运行时”的隐患。
import type { User } from "./models"; // 仅类型,编译后消失
import { add } from "./math"; // 值,会保留
const u: User = { id: 1, name: "x" };
console.log(add(1, 2));
import 即可;但若你只把它当类型用,优先 import type,让打包器放心删掉这条 import,减小产物体积。
10.2 动态导入与代码分割
async function loadEditor() {
const mod = await import("./editor"); // 运行时才加载该模块
mod.open();
}
import()(动态导入)返回 Promise,配合打包器能实现“按需加载/代码分割”,首屏更快。类型上动态导入返回 Promise<typeof import("./editor")>,模块里的导出类型依然有补全。
10.3 重导出与类型重命名
export { add } from "./math"; // 重导出值
export type { User } from "./models"; // 重导出类型
import { add as sum } from "./math"; // 导入时改名,避免冲突
11. 类型断言与非空断言as 与 !,告诉编译器“我比你知道得更清楚”
当 TS 推断的类型比你实际知道的“更宽”时,可用类型断言 as 把它改成更具体的类型。但断言是“你向编译器打包票”,编译器不再验证——用错就是运行时炸。
const el = document.getElementById("app") as HTMLElement; // 断言非空 DOM
const val = ("123" as unknown) as number; // 双重断言:unknown 中转
// 非空断言 ! :告诉编译器“这里绝不可能是 null/undefined”
function len(s: string | null): number {
return s!.length; // 若实际是 null 会运行时报错
}
console.log(len("hi"));
! 非空断言直接关掉 strictNullChecks 对该表达式的检查。如果运行时真是 null,s!.length 立刻抛错。能用 if (s) 收窄就别用 !;只在你 100% 确定非空的场景(如刚赋过值)才用。
as 会报“转换可能不合理”,此时要反思是不是类型设计本身有问题。确需绕过时只能“先 as unknown 再 as 目标”(双重断言),但这等于手动关检查,慎用。
11.1 断言 vs 转换
as 只是“编译期视图”,不改运行时值;Number("123") 才是真正转换值。别把 "123" as number 当成转换——它编译后还是字符串 "123",只是类型被标成 number,后续数字运算会出怪结果。
const a = "123" as number; // ❌ 类型说是 number,值仍是字符串
const b = Number("123"); // ✅ 真正转成数字 123
console.log(typeof a, typeof b); // string number
12. 类型兼容与赋值结构化类型的直观规则:形状对得上就能赋值
TS 的赋值是“结构化”的:目标类型需要的字段,源都有且类型匹配即可,不要求名字相同。这比 Java/C# 的“必须同类/继承”宽松,也更容易出现“意外的兼容”。
interface Point { x: number; y: number; }
interface Point3D { x: number; y: number; z: number; }
let p: Point;
const p3: Point3D = { x: 1, y: 2, z: 3 };
p = p3; // ✅ Point3D 有 Point 要的所有字段,可赋给 Point
// p3 = p; // ❌ 反过来不行:Point 缺 z
function draw(pt: Point) {}
draw({ x: 1, y: 2, z: 3 }); // ✅ 多余字段 ok(只要不缺)
draw({ x:1, y:2, z:3 }) 如果 draw 参数是 Point,字面量里的 z 会报错(即使结构化上能赋)。但先存进变量再传就不报——这是“字面量特例”,常让新手困惑。解决:把对象先赋给变量,或把参数类型放宽成 Point & Record<string, unknown>。
12.1 函数参数双向协变(旧坑)
早版 TS 对函数参数采用“双变(bivariance)”,导致不安全的赋值能通过;开启 strictFunctionTypes 后改为逆变(更安全)。这也是为什么有些老教程的例子在你机器上报错——开着 strict 才是正确行为(详见第 2 条干货)。
13. 泛型进阶约束 extends、条件类型、映射类型初步
基础泛型只是占位,进阶泛型才是 TS 的“超能力”:用 extends 给类型参数加约束,用 T extends U ? X : Y 做条件分支,用映射类型批量改造字段。
// 约束:T 必须有 length 属性
function len<T extends { length: number }>(arg: T): number {
return arg.length;
}
console.log(len("hello")); // 5
console.log(len([1, 2, 3])); // 3
// len(10); // ❌ number 没有 length
// 条件类型
type IsString<T> = T extends string ? "是字符串" : "不是字符串";
type A = IsString<string>; // "是字符串"
type B = IsString<number>; // "不是字符串"
infer 从复杂类型里“提取”子类型,例如 type Ele<T> = T extends Array<infer E> ? E : T; 能拿数组元素类型。这是写高级工具类型的标配(见第 15 节)。
T 上只能调用所有类型共有操作(基本啥也干不了)。一旦 extends 约束,就能安全访问约束里的属性/方法,既灵活又不会乱用。写库时多用约束。
11.1 条件类型 + infer 实战
infer 让你在条件类型里“捕获”子类型。下面自己实现内置的 ReturnType 与 ArrayElement。
type MyReturn<T> = T extends (...args: any[]) => infer R ? R : never;
function f(): number { return 1; }
type R = MyReturn<typeof f>; // number
type Ele<T> = T extends (infer E)[] ? E : T;
type E = Ele<string[]>; // string
T extends U 且 T 是联合)时,会逐成员分发:Exclude<"a"|"b", "a"> 实际是 Exclude<"a","a"> | Exclude<"b","a">。这常是期望行为,但偶尔会让你意外得到联合。想关掉分发,把 T 包进元组:[T] extends [U]。
11.2 映射类型改修饰符
映射类型里可用 +/- 增删 readonly 与 ? 修饰符。
type Mutable<T> = { -readonly [P in keyof T]: T[P] }; // 去只读
type Required2<T> = { [P in keyof T]-?: T[P] }; // 去可选(= Required)
interface RO { readonly id: number; name?: string; }
type M = Mutable<RO>; // { id: number; name?: string }
14. 类型守卫与判别联合自定义 is 谓词 + 用“标签字段”安全分流
当类型是联合时,光靠 typeof 有时不够(比如两个都是对象)。这时用自定义类型守卫(返回 arg is T)或判别联合(discriminated union)——给每个分支加一个字面量“标签字段”,用 switch 分流最稳。
interface Circle { kind: "circle"; r: number; }
interface Rect { kind: "rect"; w: number; h: number; }
type Shape = Circle | Rect; // 判别联合:都有 kind 标签
function area(s: Shape): number {
switch (s.kind) { // 用标签字段分流,TS 自动收窄
case "circle": return 3.14 * s.r * s.r; // 这里 s 是 Circle
case "rect": return s.w * s.h; // 这里 s 是 Rect
}
}
console.log(area({ kind: "circle", r: 2 })); // 12.56
console.log(area({ kind: "rect", w: 3, h: 4 })); // 12
12.1 自定义类型守卫
function isString(x: unknown): x is string {
return typeof x === "string";
}
function foo(x: unknown) {
if (isString(x)) {
console.log(x.toUpperCase()); // 收窄为 string
}
}
kind: "circle"),而不是 string。写成 kind: string 就失去判别能力,switch 后 TS 不会帮你收窄。标签字段名在所有分支要一致。
x is string 是你向编译器承诺“返回 true 时 x 一定是 string”。如果你实现写错(比如实际返回的是 number),编译器会信你,导致后续误用——守卫实现务必正确。
12.1 用判别联合建模“状态机”
判别联合非常适合表达“一个东西的多种状态”,且保证你处理全部分支。
type Async<T> =
| { status: "loading" }
| { status: "ok"; data: T }
| { status: "error"; msg: string };
function render<T>(s: Async<T>): string {
switch (s.status) {
case "loading": return "加载中...";
case "ok": return "数据:" + JSON.stringify(s.data);
case "error": return "出错:" + s.msg;
}
}
console.log(render({ status: "ok", data: [1, 2] }));
switch 保证 loading/ok/error 三种 UI 都覆盖,漏写编译就报错(配合 never 检查更稳)。
15. 装饰器@ 注解式增强,配合 emitDecoratorMetadata 才有反射元数据
装饰器(Decorator)是“给类/方法/属性加标注”的语法糖,常见于 Angular、NestJS、TypeORM。注意:TS 装饰器历史上处于实验阶段,需要 experimentalDecorators: true;想拿到参数类型等元数据还要开 emitDecoratorMetadata。
function Log(target: any, key: string, desc: PropertyDescriptor) {
const old = desc.value;
desc.value = function (...args: any[]) {
console.log("调用 " + key + ",参数:", args);
return old.apply(this, args);
};
}
class Calc {
@Log
add(a: number, b: number): number {
return a + b;
}
}
new Calc().add(1, 2); // 打印日志后返回 3
experimentalDecorators;若自己写原生代码,等 Stage 3 正式落地更稳。别混用两套。
emitDecoratorMetadata: true 且安装 reflect-metadata 并在入口 import "reflect-metadata"。否则 Reflect.getMetadata 拿到的是 undefined,这是框架党最常踩的坑。
13.1 类装饰器与属性装饰器
装饰器能贴在类、方法、访问器、属性、参数上,分别收到不同的参数。下面给类加一个“标注版本号”的装饰器。
function Version(v: string) {
return function (ctor: Function) {
ctor.prototype.version = v;
};
}
@Version("1.0")
class Service {}
console.log((new Service() as any).version); // 1.0
experimentalDecorators 开启时编译装饰器,否则报“装饰器实验性”错误。
16. 声明合并interface 自动合并,type 不行——利用它扩展第三方类型
TS 有个独门特性叫声明合并(declaration merging):多个同名的 interface 会被自动合并成一个;同名 namespace 也会合并。但 type 别名不能重复声明,否则报错。
interface User { id: number; }
interface User { name: string; } // 同名 interface 自动合并
// 合并后等价于 { id: number; name: string }
const u: User = { id: 1, name: "x" };
// 常见用途:给第三方库的类型“偷偷加字段”
interface Window {
myConfig: { theme: string };
}
window.myConfig = { theme: "dark" };
type User = ... 会直接报“重复标识符”。需要可扩展时用 interface;需要联合/交叉/工具类型时用 type。这也是第 4 节“对象形状优先 interface”的另一个理由。
window、第三方库)补类型”,而不用改原库源码。模块扩展(declare module)也是基于合并思想。
14.1 模块扩展 declare module
给第三方包“补类型”或“扩展它的接口”,用 declare module 做模块合并。比如给某个没类型的 npm 包补一个导出类型。
// my-augment.d.ts
import "some-lib";
declare module "some-lib" {
export interface Options {
timeout?: number;
}
}
.d.ts 文件里,编译后不产生 JS,只提供类型。当你引入一个“没类型定义的 JS 库”时,自己补一份 .d.ts 或用 @types/xxx 社区包,是工程化必备动作。
14.2 函数重载也算一种合并
多个同名函数签名会被合并成一个“重载集”(见第 5 节)。这是声明合并思想的另一种体现:把多种调用形态合并到一个名字下,调用时按实参匹配最合适的签名。
17. 工具类型Pick/Omit/Partial/Record/Required,以及 keyof + 映射类型
TS 内置了一批“类型层面的函数”叫工具类型(Utility Types),用来从已有类型派生新类型,避免重复书写。掌握它们是写出“DRY(不重复)类型”的关键。
| 工具类型 | 作用 |
|---|---|
Partial<T> | 所有属性变可选 |
Required<T> | 所有属性变必填 |
Readonly<T> | 所有属性变只读 |
Pick<T, K> | 挑出部分属性组成新类型 |
Omit<T, K> | 去掉部分属性组成新类型 |
Record<K, V> | 构造“键为 K、值为 V”的对象类型 |
interface User { id: number; name: string; email: string; }
type UserPreview = Pick<User, "id" | "name">; // { id; name }
type UserNoEmail = Omit<User, "email">; // { id; name }
type UserInput = Partial<User>; // 全部可选,适合“更新表单”
const map: Record<string, number> = { a: 1, b: 2 };
15.1 keyof 与映射类型
type T = { a: number; b: string };
type K = keyof T; // "a" | "b"
// 映射类型:把每个属性变成只读
type ReadonlyT = { readonly [P in keyof T]: T[P]; };
Pick/Omit/Partial/Record 就够了,别手工重写一个结构略不同的接口——一旦原接口改字段,你手写的就脱节了。工具类型永远跟着源类型走。
Omit 是“浅”的,只作用于第一层属性。想删除嵌套对象里的字段,需要自己写递归映射类型(OmitRecursive),别以为 Omit<T, "a.b"> 能删嵌套——它不会。
15.1 内置工具类型的实现原理
工具类型不是黑魔法,都是用映射类型/条件类型写的。看懂源码,你就能写自己的。
type MyPartial<T> = { [P in keyof T]?: T[P]; };
type MyReadonly<T> = { readonly [P in keyof T]: T[P]; };
type MyPick<T, K extends keyof T> = { [P in K]: T[P]; };
type MyRecord<K extends keyof any, V> = { [P in K]: V; };
interface U { id: number; name: string; }
const a: MyPartial<U> = {}; // 全部可选
const b: MyReadonly<U> = { id: 1, name: "x" }; // 只读
+/- 增删修饰符,如 { -readonly [P in keyof T]: T[P] } 去掉只读、{ [P in keyof T]-?: T[P] } 去掉可选(即 Required 的实现)。这是“批量改字段属性”的钥匙。
15.2 递归映射类型(深度处理)
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
interface Cfg { db: { host: string; port: number }; debug: boolean; }
const patch: DeepPartial<Cfg> = { db: { port: 5432 } }; // 嵌套也能局部填
T[P] extends object 判断到基本类型就停止递归,否则会无限展开。TS 对递归深度有上限,过深的嵌套类型会报“类型实例化过深”。
18. never 与穷尽检查用 never 让“漏处理分支”在编译期暴露
never 表示“永远不会出现的值/永远不会返回的函数的返回类型”。它最大的实战价值是穷尽性检查(exhaustiveness check):在 switch 里兜底一个接收 never 的分支,万一以后给联合类型加了新成员却忘了处理,编译立刻报错。
type Shape = { kind: "circle" } | { kind: "rect" };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return 1;
case "rect": return 2;
default:
// 若以后 Shape 加了 "triangle",这里 s 不再是 never,编译报错
const _exhaustive: never = s;
return _exhaustive;
}
}
function fail(msg: string): never {
throw new Error(msg); // 永不返回
}
switch 末尾写 const _x: never = s;,等于逼编译器替你盯梢:“所有情况都处理了吗?没处理就报错”。大型重构时这能救你一命——忘加分支会立刻红,而不是线上才爆。
throw / process.exit() / 死循环的函数返回类型是 never。条件类型里也常用来“排除不可能”的分支(如 Exclude<T, U> 的底层)。
16.1 用 never 实现 Exclude / Extract
内置工具类型 Exclude<T, U>(从 T 去掉能赋给 U 的部分)和 Extract 的底层都靠 never:分发条件类型,匹配到的变成 never 被剔除。
type MyExclude<T, U> = T extends U ? never : T;
type A = MyExclude<"a" | "b" | "c", "a">; // "b" | "c"
type MyExtract<T, U> = T extends U ? T : never;
type B = MyExtract<string | number, string>; // string
T extends U ? never : T 当 T 是联合时,会对每个成员分别求结果再合并。所以 "a"|"b"|"c" 里 "a" 命中 U 变 never 被丢掉,剩下 "b"|"c"。这是 TS “类型体操”的发动机。
19. 条件类型进阶:递归与模板结合类型层面的“字符串处理”与“深度展开”
条件类型配合 infer 能写出递归类型,配合模板字面量类型能做“类型层面的字符串变换”,这是高级类型体操的两大主题。
// 去掉数组的嵌套:DeepFlatten
type DeepFlatten<T extends any[]> =
T extends [infer Head, ...infer Tail]
? Head extends any[]
? [...DeepFlatten<Head>, ...DeepFlatten<Tail>]
: [Head, ...DeepFlatten<Tail>]
: [];
type R = DeepFlatten<[[1], [2, [3]]]>; // [1, 2, 3]
// 模板字面量 + 条件:把 "getUser" 变成 "GetUser"
type CapitalizeKeys<K extends string> =
K extends `get${infer Rest}` ? `Get${Capitalize<Rest>}` : K;
type A = CapitalizeKeys<"getUser">; // "GetUser"
20. 工程实战:tsconfig 与常见配置把 TS 接进真实项目该开哪些开关
真实项目靠 tsconfig.json 统一编译行为。下面是一份“现代前端/Node 项目”常用配置及解释。
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler", // Vite/webpack 用 Bundler
"strict": true,
"noUnusedLocals": true, // 未用变量报错
"noUnusedParameters": true, // 未用参数报错
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true, // 跳过 .d.ts 检查,提速
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
strict(全严格)、noUnusedLocals/Parameters(逼你清掉死代码)、noImplicitReturns(函数所有分支都要返回)、exactOptionalPropertyTypes(更严格的可选属性)。每开一个,类型网就更密一层。
skipLibCheck: true 让 TS 不去检查 node_modules 里的 .d.ts,能极大加快大项目编译,且通常安全(第三方类型问题不该由你背)。但这意味着第三方类型里的错误你发现不了——遇到诡异类型问题可临时关掉它排查。
tsc 通常只做“类型检查”(--noEmit),真正的转译/打包交给 Vite/esbuild/SWC(它们比 tsc 快得多,但不做类型检查)。所以 CI 里要单独跑一遍 tsc --noEmit 把关类型,别只靠打包器。
进阶训练场(含答案)
题 1:写一个带类型的求和函数
写函数 sum(nums: number[]): number,返回数组所有元素之和;并在 main 里调用打印。要求类型标注完整。
显示答案
function sum(nums: number[]): number {
let total = 0;
for (const n of nums) total += n;
return total;
}
console.log(sum([1, 2, 3, 4])); // 10
题 2:用 interface 描述“书”并实例化
定义 Book 接口(title: string, price: number, author?: string),写一个打印书信息的函数,并创建一本缺省 author 的书。
显示答案
interface Book {
title: string;
price: number;
author?: string;
}
function show(b: Book): void {
console.log(b.title + " / " + b.price + " / " + (b.author ?? "佚名"));
}
show({ title: "TS 入门", price: 39 });
题 3:泛型 identity + 约束
写泛型函数 longest<T extends { length: number }>(a: T, b: T): T,返回 length 较大的那个;分别用字符串和数组测试。
显示答案
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
console.log(longest("hello", "hi")); // hello
console.log(longest([1, 2], [1, 2, 3])); // [1,2,3]
题 4:判别联合计算面积
定义 Shape 判别联合(circle 带 r、square 带 side),写 area 用 switch 计算,并加 never 穷尽检查。
显示答案
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return 3.14 * s.r * s.r;
case "square": return s.side * s.side;
default:
const _exhaustive: never = s;
return _exhaustive;
}
}
console.log(area({ kind: "circle", r: 2 })); // 12.56
console.log(area({ kind: "square", side: 3 })); // 9
题 5:工具类型做“更新用户”
用 interface User { id; name; email },创建一个函数接收 Partial<User> 做局部更新并返回新对象(用展开运算符合并)。
显示答案
interface User { id: number; name: string; email: string; }
function update(u: User, patch: Partial<User>): User {
return { ...u, ...patch };
}
const u: User = { id: 1, name: "A", email: "a@x.com" };
console.log(update(u, { name: "B" })); // { id:1, name:"B", email:"a@x.com" }
题 6:泛型 + keyof 安全取值
实现 getProp<T, K extends keyof T>(obj: T, key: K): T[K],对 {id:number,name:string} 分别取 id 与 name,验证返回类型精确跟随 key。
显示答案
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const u = { id: 1, name: "小明" };
const n: number = getProp(u, "id"); // number
const s: string = getProp(u, "name"); // string
console.log(n, s);
题 7:函数重载
写重载:format(x: number): string 与 format(x: string): string,数字补前导零到 2 位,字符串原样返回。
显示答案
function format(x: number): string;
function format(x: string): string;
function format(x: number | string): string {
if (typeof x === "number") return x.toString().padStart(2, "0");
return x;
}
console.log(format(5)); // "05"
console.log(format("ok")); // "ok"
题 8:类型守卫过滤数组
给定 (number | string)[],用类型守卫 + filter 得到纯 number[]。
显示答案
function isNum(x: unknown): x is number {
return typeof x === "number";
}
const mixed: (number | string)[] = [1, "a", 2, "b"];
const nums: number[] = mixed.filter(isNum);
console.log(nums); // [1, 2]
题 9:判别联合 + never 穷尽
定义 Shape 含 circle(area=πr²) 与 square(side²),写 perimeter 求周长并加 never 检查。
显示答案
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function perimeter(s: Shape): number {
switch (s.kind) {
case "circle": return 2 * 3.14 * s.r;
case "square": return 4 * s.side;
default:
const _x: never = s;
return _x;
}
}
console.log(perimeter({ kind: "square", side: 3 })); // 12
题 10:Record 做词频统计
用 Record<string, number> 统计字符串数组里每个单词出现次数。
显示答案
function count(words: string[]): Record<string, number> {
const m: Record<string, number> = {};
for (const w of words) m[w] = (m[w] ?? 0) + 1;
return m;
}
console.log(count(["a", "b", "a", "c", "b", "a"]));
// { a: 3, b: 2, c: 1 }
题 11:抽象类模板方法
写抽象类 Report,含抽象 render(): string 与具体 print()(调用 render 并加边框),子类 TextReport 实现 render。
显示答案
abstract class Report {
abstract render(): string;
print(): void {
console.log("====\n" + this.render() + "\n====");
}
}
class TextReport extends Report {
render(): string { return "一份报告"; }
}
new TextReport().print();
题 12:satisfies 校验配置
定义 type Cfg = { port: number; env: "dev" | "prod" },用 satisfies 声明配置对象,验证形状正确且 env 保持字面量类型。
显示答案
type Cfg = { port: number; env: "dev" | "prod" };
const cfg = { port: 8080, env: "dev" } satisfies Cfg;
// cfg.env 仍是 "dev"(字面量),且形状被校验
console.log(cfg.port, cfg.env.toUpperCase());
题 13:可选链 + 空值合并处理嵌套对象
给定可能缺字段的用户对象 { profile?: { address?: { city?: string } } },安全取出 city,缺省返回 "未知"。
显示答案
interface User {
profile?: { address?: { city?: string } };
}
function getCity(u: User): string {
return u.profile?.address?.city ?? "未知";
}
console.log(getCity({})); // 未知
console.log(getCity({ profile: { address: { city: "北京" } } })); // 北京
题 14:泛型栈 Stack<T>
写泛型类 Stack<T>,含 push、pop、peek、isEmpty,分别用 number 和 string 测试。
显示答案
class Stack<T> {
private items: T[] = [];
push(v: T): void { this.items.push(v); }
pop(): T | undefined { return this.items.pop(); }
peek(): T | undefined { return this.items[this.items.length - 1]; }
isEmpty(): boolean { return this.items.length === 0; }
}
const s = new Stack<number>();
s.push(1); s.push(2);
console.log(s.pop(), s.peek(), s.isEmpty()); // 2 1 false
🧪 动手实验室
不用从零敲代码:下面每个程序都已写好,点「▶ 运行」直接看结果;想改就在框里改,再点运行。手机、电脑都一样用。打开本页时会自动跑一次,方便你先看效果。
① 基础类型与函数
② 接口与对象
③ 泛型 + 联合收窄
④ 判别联合与穷尽检查
⑤ 工具类型(Omit / Partial)
main.ts。实战干货(12 条工业级技巧)面试与上线都绕不开
interface A { x: number } 和 interface B { x: number; y: string },B 可以赋给 A(多出的字段无所谓),但 A 不能赋给 B(缺 y)。这带来了灵活,也带来了“形状巧合导致误兼容”的隐患——所以关键接口用 as const / 字面量类型收窄更稳。
Dog[] 可赋给 Animal[](直观,但运行时可能越界写错——这是结构化类型的历史妥协)。而函数参数默认是逆变:(a: Animal) => void 可赋给 (d: Dog) => void(参数能接受更宽的类型才安全)。开 strictFunctionTypes 后,函数参数逆变会被严格检查(方法参数仍保持双变以兼容老代码)。理解它,你才看得懂某些“明明能赋值却报错/报错却能赋值”的诡异现象。
T extends U 给泛型加约束;T extends U ? X : Y 做编译期分支。配合 infer 能从泛型里“抠”出子类型:
type Ele<T> = T extends Array<infer E> ? E : T;
type A = Ele<number[]>; // number
type B = Ele<string>; // string
ReturnType、Parameters 等内置工具类型的底层手法。库作者必备,业务代码适度即可。
typeof x === "string"、"field" in x、x instanceof Foo。当它们不够用时,写自定义守卫 function isFish(x: unknown): x is Fish { return ... }——返回 x is T 后,if (isFish(x)) 块内 x 自动收窄为 Fish。切记守卫实现要对,因为那是你向编译器作的“担保”。
type Unit = "px" | "em" | "rem";
type Size = `${number}${Unit}`; // "12px" | "2em" ...
type Color<N extends string> = `color-${N}`; // color-red / color-blue
const s: Size = "12px"; // ✅
// const bad: Size = "12pt"; // ❌
satisfies(TS 4.9+)让你“既校验对象符合某类型,又保留它原本的字面量类型”,不会像直接标注那样把类型“拓宽”掉:
const cfg = {
port: 8080,
env: "dev"
} satisfies { port: number; env: "dev" | "prod" };
// cfg.env 仍是字面量 "dev"(可参与联合判断),同时保证形状正确
console.log(cfg.env.toUpperCase());
as const 再担心形状”的笨拙写法,是配置对象的最佳实践。
keyof T 取所有键的联合;{ [P in keyof T]: T[P] } 叫映射类型,是批量改造字段的引擎。日常直接用内置工具类型:Partial(全可选,适合更新表单)、Required(全必填)、Pick(挑字段)、Omit(去字段)、Record<K,V>(构造字典类型)。它们永远跟随源类型演化,比手写重复接口 DRY 得多。
interface 同名自动合并,type 不能重复声明。利用这点可“给第三方全局对象偷偷补类型”(如扩展 Window)。但也要注意:意外的同名 interface 会被合并,可能导致你以为独立的类型被悄悄“污染”。团队里对全局类型扩展要集中管理,别散落各处。
tsconfig 开 experimentalDecorators 与 emitDecoratorMetadata;② 安装并 import "reflect-metadata"。且 TS 实验装饰器与标准 Stage 3 装饰器语法不兼容,新原生项目建议等标准落地。滥用装饰器会让控制流变隐式,调试时“不知道这逻辑哪来的”。
switch 末尾写 const _x: never = s;,一旦以后给联合加新成员却漏处理,编译立即报错——这是大型重构的“保险丝”。配合 kind 字面量标签字段,是 TS 表达“有限状态机 / 多种消息”的最稳范式。业务里把“消息类型”“错误类型”写成判别联合,能大幅减少 switch 漏写。
readonly 让属性不可再赋值(仅编译期);as const 把整个对象/数组“深度只读并锁成字面量类型”:
const arr = [1, 2] as const; // 类型变成 readonly [1, 2],不能 push
const obj = { role: "admin" } as const; // obj.role 是字面量 "admin"
// arr.push(3); // ❌ 只读
// obj.role = "user"; // ❌
as const 能避免被意外修改,并保留精确字面量类型。
any 关掉所有检查(自动补全也没了),是“类型系统的逃生舱”,滥用等于退回 JS;unknown 表示“我也不知道是啥,但你必须先收窄才能用”——它安全:不能直接调用方法、不能当具体类型用,必须先 typeof/守卫判断。经验法则:凡是“来源不可信/类型未知”的地方(API 响应、JSON.parse、用户输入)一律用 unknown 而非 any。JSON.parse 的返回类型本应是 unknown(TS 历史原因仍标 any,可手动断言成 unknown)。
as 断言成类型并不安全——数据可能不符合。工业界做法是“运行时校验库”(如 zod):它既在运行时验证数据形状,又能反推出 TS 类型(z.infer<typeof Schema>),做到“一份定义,类型与校验两用”。这是 unknown → 可信类型 最稳的桥。
allowJs 让 .js 和 .ts 共存;② 在 .js 顶部写 // @ts-check 让 TS 对 JS 也做部分检查;③ 逐个文件改后缀为 .ts,先把不确定处标 any,再逐步收紧。这样风险最低、团队最容易接受。
type-fest 里的现成类型好维护。类型存在的意义是降低心智负担,不是增加。当类型报错信息开始你看不懂时,就是该简化的信号。
@types/xxx(npm i -D @types/xxx);② 没有就自己写一份 .d.ts 声明文件;③ 临时应急可 declare module "xxx"; 让它变成 any(不推荐长期)。现代主流库基本都自带类型,缺类型的多半是老库或小众库。
typeof 从值取类型、用 ReturnType 从函数取返回、用映射类型从已有接口派生。这样上游一改,下游自动跟着变,几乎零维护。新手常把类型写死成具体形状,结果上游一变就到处红。多练“类型推导”,少写“字面量硬标注”。
附录 A:常见编译错误速查看到红先看这里
| 报错片段 | 常见原因 | 解决思路 |
|---|---|---|
Type 'X' is not assignable to type 'Y' | 类型不匹配(结构化不符/缺字段/字面量不符) | 检查形状、用收窄、必要时 as unknown 中转 |
Object is possibly 'undefined' | 开了 strictNullChecks 后未判空 | if (x) / ?. / ??,或确认后 ! |
Property 'x' does not exist on type 'Y' | 访问了不存在的字段,或对象缺索引签名 | 加可选 ?、补类型、用 Record |
Cannot find module './x' | 路径写错 / 缺类型声明 | 检查路径与扩展名、装 @types、补 .d.ts |
Binding element implicitly has any | 参数没标注且关了 noImplicitAny | 给参数加类型注解 |
Type alias 'X' not allowed as a value | 把 type(仅类型)当值用 | 改用 interface 或常量对象 |
Decorator syntax isn't supported | 没开 experimentalDecorators | tsconfig 开启该开关 |
Excess property 'x' ... | 对象字面量多出字段 | 先存变量再传,或放宽目标类型 |
'X' refers to a type but used as value | 把接口当类 new | 接口不能 new,改用 class |
Type instantiation is excessively deep | 递归类型太深 | 限制递归深度,或拆成更简单的类型 |
附录 B:TypeScript vs JavaScript一张表看懂区别
| 维度 | JavaScript | TypeScript |
|---|---|---|
| 类型检查时机 | 运行时才暴露错误 | 编译期(写代码时)暴露 |
| 怎么运行 | 直接 node / 浏览器 | 先 tsc 编译成 JS 再运行 |
| 类型注解 | 无 | 有(可选、可推断) |
| 大型项目维护 | 易出低级 bug | 重构安全、自动补全强 |
| 学习曲线 | 低 | 中等(要学类型系统) |
| 生态 | 自身 | 同一生态(TS 是 JS 超集) |
| 招聘需求 | 基础岗 | 现代前端/Node 岗默认要求 |
附录 C:tsconfig 常用字段照着抄,再按项目调
| 字段 | 作用 | 建议 |
|---|---|---|
target | 编译到哪版 JS | ES2020+ |
module | 模块系统 | ESNext |
moduleResolution | 模块解析策略 | Bundler(Vite)或 node |
strict | 开启全部严格检查 | true |
noImplicitAny | 禁止隐式 any | true |
strictNullChecks | null/undefined 检查 | true(strict 已含) |
noUnusedLocals | 未用变量报错 | true |
skipLibCheck | 跳过第三方 .d.ts 检查 | true(提速) |
esModuleInterop | 兼容 default 导入 | true |
forceConsistentCasingInFileNames | 文件名大小写一致 | true |
noEmitOnError | 有类型错误不生成 JS | 按需(教学可调 true) |
allowJs | 允许 JS 混入 | 迁移老项目时 |
isolatedModules | 单文件转译兼容 | 用 esbuild/vite 时 true |
附录 D:30 天学习路线照这个节奏,别跳
| 阶段 | 重点 |
|---|---|
| 第 1 周 | 类型注解、基础类型、函数类型、strict 模式 |
| 第 2 周 | interface / type、类与修饰符、泛型入门 |
| 第 3 周 | 联合/交叉、类型收窄、枚举、模块 |
| 第 4 周 | 泛型进阶(约束/条件类型)、工具类型 |
| 第 5 周 | 判别联合、never 穷尽、装饰器 |
| 第 6 周 | tsconfig、渐进迁移老项目、类型体操练习 |
| 第 7-8 周 | 实战:用 TS 写一个小型全栈/前端项目,读类型库源码 |
unknown 就别写 any。
附录 E:类型编程速查常用工具类型与关键字一页纸
| 语法 / 工具 | 作用 |
|---|---|
Partial<T> | 所有属性变可选 |
Required<T> | 所有属性变必填 |
Readonly<T> | 所有属性变只读 |
Pick<T, K> | 挑部分属性 |
Omit<T, K> | 去部分属性 |
Record<K, V> | 构造字典类型 |
Exclude<T, U> | 从 T 去掉可赋给 U 的部分 |
Extract<T, U> | 从 T 取出可赋给 U 的部分 |
NonNullable<T> | 去掉 null/undefined |
ReturnType<F> | 取函数返回类型 |
Parameters<F> | 取函数参数元组 |
keyof T | 取所有键的联合 |
T[K] | 取键 K 对应的属性类型 |
typeof x | 取变量 x 的类型 |
infer | 在条件类型里捕获子类型 |
as const | 深度只读 + 锁字面量 |
satisfies | 校验形状但保留字面量类型 |
新手最常问(FAQ)这些坑几乎人人都踩过
tsc 报类型错仍会输出 JS(除非 noEmitOnError)。所以“红了但页面照常跑”是正常现象——类型错是提醒,不是阻断。但也别因此无视红,积累多了迟早出运行时 bug。
any 当“临时止血”,尽快换成具体类型或 unknown。团队里可开 noExplicitAny 禁止显式 any,逼大家写真实类型。
.ts 必须经 tsc/打包器编译。本地想直接跑,用 deno/bun(原生支持 TS);线上必须编译。本站运行器已替你编译好。
推荐练习项目边做边学,比看十遍有用
- 类型版待办清单:用 interface 定义 Task,泛型函数过滤/排序,判别联合表达“进行中/已完成”。
- 表单校验器:用工具类型 + 条件类型生成“字段→错误”的映射类型,练习
Partial/Record。 - 简易路由:用模板字面量类型约束路径(如
/user/:id),练习高级字面量类型。 - API 客户端:用泛型把“请求类型 → 响应类型”串起来,练习
ReturnType/infer。 - 状态机:用判别联合建模“加载中/成功/失败”,渲染时 switch 全覆盖。
附录 F:常用生态工具这些命令/库早晚会用到
| 工具 | 干嘛用 |
|---|---|
tsc | 官方编译器,做类型检查/编译 |
ts-node / tsx | 直接运行 .ts(开发期方便,不必先编译) |
deno / bun | 原生支持 TS 的运行时,deno run x.ts 即可 |
@types/node | Node 内置 API 的类型,写后端必装 |
type-fest | 社区工具类型大合集(很多高级类型现成可用) |
zod / io-ts | 运行时校验 + 反推类型,桥接 unknown 与可信类型 |
vite / esbuild | 极快打包/转译(不做类型检查,配 tsc 把关) |
ts-pattern | 强大的模式匹配库,配合判别联合写出清晰的 switch 替代 |
typescript(编译器)、@types/node(若写后端)、一个运行时(tsx 或 deno)。其余等真遇到需求再装,别一上来堆一堆用不上的依赖。
类型错误调试四步红了一片时别慌
if (typeof x === ...) 或类型守卫;若是第三方类型太宽,用 as 收窄(确认无误后)。临时想看某个表达式的类型,可写 type T = typeof x; 再悬停 T。
tsc --noEmit),去掉无关部分,往往立刻看清问题。复杂类型报错信息冗长,简化后更容易读。
推荐资源学完本课后接着看
- TypeScript Handbook(官方手册)——权威、系统。
- TS Playground——在线试类型,报错即时显示,最适合练手。
- type-challenges——类型体操题库,从易到地狱,进阶必刷。
- type-fest——读它的源码,学工业级工具类型怎么写。
- 本站「练习场」与「动手实验室」——把本课例子都敲一遍、改一遍。
自测题(点击展开答案)
- Q1:TS 的类型检查报错,会阻止生成最终的 JS 吗?
查看答案
默认不会。tsc仍会输出 .js(类型只是开发期帮手)。想“有错就不生成”,在tsconfig.json开"noEmitOnError": true。运行时没有类型,TS 最终被擦除成纯 JS。 - Q2:
any和unknown主要区别?查看答案
any关闭所有类型检查、补全也失效;unknown表示未知但安全,必须先用typeof/类型守卫收窄后才能当具体类型使用。不可信来源(API、JSON.parse、用户输入)应优先unknown。 - Q3:
interface和type在“声明合并”上有什么不同?查看答案
同名interface会自动合并成一个;type别名不能重复声明(会报“重复标识符”)。需要可扩展(如给第三方类型补字段)用 interface;联合/交叉/工具类型用 type。 - Q4:判别联合(discriminated union)靠什么字段来安全分流?
查看答案
靠每个分支共有的字面量标签字段(如kind: "circle")。用switch (s.kind)分流后 TS 自动收窄到对应分支;标签字段必须是字面量类型(不能是普通string),且各分支字段名一致。 - Q5:在判别联合的
switch默认分支写const _x: never = s;有什么用?查看答案
做穷尽性检查。若以后给联合类型新增成员(如加"triangle")却忘了在 switch 处理,s就不再是never,此行编译报错,逼你补齐分支。是大型重构的保险丝。 - Q6:
Omit<T, K>能删除嵌套对象的字段吗(如Omit<User, "profile.age">)?查看答案
不能。Omit是“浅”的,只作用于第一层属性,键也不能用点路径。想删嵌套字段需自己写递归映射类型(OmitRecursive)。日常挑/去第一层字段用Pick/Omit即可。 - Q7:
as const对数组/对象做了什么?查看答案
把它“深度只读”并锁成字面量类型:[1,2] as const类型变成readonly [1, 2](不能 push),对象字段变成字面量(如"admin"而非string)。适合常量配置、Redux action、路由表。 - Q8:函数重载里“实现签名”能被外部直接调用吗?
查看答案
不能。外部只能看到声明的重载签名;实现签名(带具体参数的那个)对调用方不可见,且必须兼容所有声明签名。实现体内部通常用if/typeof区分。 - Q9:
interface和type都能描述对象,何时必须用type?查看答案
做联合A | B、交叉A & B、条件类型、工具类型/映射类型时必须用type;interface不支持这些。描述普通对象形状两者皆可,但 type 不能声明合并。 - Q10:
strictNullChecks开启后,let x: string = null会怎样?查看答案
编译报错。null/undefined不再能赋给普通类型;必须显式写成string | null或用可选?。这能在编译期挡掉大量“ Cannot read property of null ”运行时崩溃。 - Q11:判别联合的标签字段,写成
kind: string而非kind: "circle" | "rect"会怎样?查看答案
失去判别能力。switch (s.kind)后 TS 不会自动收窄到具体分支(因为string包含无穷可能),穷尽检查也失效。标签字段必须是字面量联合类型。 - Q12:为什么常说“运行器/打包器(Vite/esbuild)不做类型检查”?
查看答案
它们只负责把 TS 转译成 JS,追求速度,不做类型分析。所以类型错误不会阻止它们打包成功。真正把关类型要在 CI 单独跑tsc --noEmit。别因为“能打包”就以为“类型没问题”。 - Q13:
type A = B & C中若 B 和 C 有同名但不同类型字段,结果是什么?查看答案
结果是never(那个冲突字段的类型变成never),因为没有任何值能同时满足。交叉类型要求“所有字段都兼容”,冲突即失效。改设计避免字段冲突。 - Q14:
import type和普通import对产物体积有什么影响?查看答案
import type只引入类型,编译后整条 import 被擦除,不产生运行时依赖,利于打包器 tree-shaking;普通import若只用其类型,可能留下无用的运行时引用。仅当类型用时优先import type。 - Q15:
satisfies和直接类型注解(const x: T = {...})区别?查看答案
直接注解会把右侧对象“拓宽”成 T(丢失字面量细节);satisfies只校验符合 T,但保留原始字面量类型。需要“既校验又保留精确类型”(如配置对象)时用satisfies。 - Q16:一个函数返回
never意味着什么?查看答案
意味着它永不正常返回——要么抛异常、要么死循环、要么调用process.exit。TS 据此做控制流分析(比如知道throw new Error()之后的代码不可达)。 - Q17:为什么推荐用
unknown接JSON.parse的结果?查看答案
因为解析出的真实结构编译期未知,标any会让后续随意访问而不报错(运行时易崩)。标unknown后必须先收窄/校验才能用,把“结构假设”显式化,更安全。