🧭 编程学习站

TypeScript 入门 + 进阶训练

公网版 · 手机/电脑自适应

🐣 纯新手先看这一框(30 秒秒懂)

1

TypeScript 适合做什么?Web 前端(Angular / React / Vue)、Node.js 后端、桌面(Electron)、甚至 Deno/Bun 运行时。一句话:凡是 JavaScript 能跑的地方,TypeScript 都能上,还多了“编译期类型检查”这层保护网。

2

为什么要学它?JS 是动态类型,写大了容易出“运行时才发现的低级错”。TS 给 JS 加了静态类型,能在写代码时(甚至保存时)就把大量 bug 挡在编译阶段,大项目维护成本直线下降。现代前端岗位基本默认要求 TS。

3

怎么跑起来?本站「动手实验室」选 TypeScript 点运行即可,不用装环境;想深入再装 Node + npm i -g typescript,用 tsc 文件.ts 编译成 JS 再 node 文件.js 运行,或直接用 deno run 文件.ts / bun run 文件.ts

4

新手最常踩的坑?① 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 的类型能力:类型注解、接口、泛型、联合/交叉、类型收窄、装饰器、工具类型、以及面试高频的“结构化类型”“协变逆变”“条件类型”等进阶主题。我们会反复强调“类型是用来约束你自己的,不是约束运行时的”。

环境与版本 建议 Node.js 18+,全局装 typescript。编译命令 npx tsc 文件.ts(会生成同名 .js);想只检查类型不生成文件用 tsc --noEmit 文件.ts。本站运行器使用 TypeScript 5.6。生产项目用 tsconfig.json 统一管理编译选项。
学习路径建议 JS 基础 → 类型注解/接口/函数类型 → 类与泛型 → 联合/交叉/收窄 → 工具类型与条件类型 → 装饰器/声明合并。别一上来死磕“条件类型 + infer”这种神仙语法,先把基础类型写顺手,否则会被类型报错劝退。

0.1 TypeScript 适合做什么(场景一览)

场景为什么适合 TS
Web 前端框架React / Vue / Angular 首选 TS,组件 props、状态都有类型,改一处全链报错
Node.js 后端NestJS、Express+TS 让接口契约清晰,入参出参自动校验思路
前端库 / 工具类型即文档,调用方不用看实现就有补全,体验拉满
跨端应用Electron / React Native 可与 Web 共享同一套类型定义
大型协作项目重构安全、减少“改了 A 忘了 B”的沟通成本
不适合一次性小脚本、对启动体积/编译速度极度敏感且无需维护的场景
学习前提 学 TS 不需要先精通 JS 的所有高级特性,但必须有 JS 基础:变量、函数、对象、数组、Promise。不懂 JS 直接学 TS,会被“类型”和“语法”两层东西同时卡住。建议先花一两周把 JS 基础写熟。
TS 不是银弹 TS 能在编译期挡掉一大类 bug(类型错、拼写错、字段错),但挡不掉逻辑错(算法写错、边界没考虑)。它也增加了一点编译步骤和类型书写成本。小玩具用 JS 更快,长期维护的项目用 TS 更稳——按项目寿命选。

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
新手最大的误区 TS 的红线错误默认不会阻止生成 JS(除非开了 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
  }
}
新手先开 strict 很多“TS 好像没用”的吐槽,其实是因为没开 strict,类型被悄悄放宽成 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 特有概念(anyunknownnever、元组、枚举)。类型注解写在变量/参数/返回值后面,用冒号分隔。

类型含义示例
string字符串let s: string = "hi";
number所有数字(含整/浮,无 int/float 之分)let n: number = 3.14;
booleantrue / falselet 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 是毒药 any 等于对编译器说“这块别管了”,于是类型检查彻底失效、自动补全也没了。能不用就不用;如果确实不知道类型,优先用 unknown(见第 9 条干货)。把整个文件写成 any 就等于退回纯 JS,那还学 TS 干啥。
元组 vs 数组 数组 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 的坑 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"42true 是值,它们还可以是类型。字面量类型在“限定取值集合”时极其好用,是联合类型与判别联合的基础。

type Direction = "上" | "下" | "左" | "右";
let d: Direction = "上";     // ✅
// d = "前";                  // ❌ 不在集合里

type StatusCode = 200 | 404 | 500;
let code: StatusCode = 200;  // ✅
const 推断的坑 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;
}
推断不是万能 函数参数没有上下文推断,必须显式标注(除非用默认参数值能推断)。所以参数类型是“你一定要写”的地方,别偷懒。返回类型可省,但公共 API 建议写上。

3.2 字面量拓宽(widening)与控制

let 赋给一个期望“字面量类型”的位置时,TS 会“拓宽”成基础类型;用 as constreadonly 可阻止拓宽。这是联合类型、判别联合能工作的底层原因之一。

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 不行(重复声明会报错)。

场景interfacetype
描述对象形状✅ 首选
同名声明合并✅ 支持❌ 不支持
联合/交叉组合❌ 不便✅ 灵活
映射类型 / 工具类型✅ 必须 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 做联合与交叉的灵活玩法

当类型不是单纯“对象形状”而是“多种可能”时,typeinterface 顺手得多。

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 区分。新手常把实现签名也当成对外接口,结果调用时被类型限制——记住实现签名不暴露。
默认参数顺序 带默认值的参数应该放在后面(和 C++ 一样),否则调用时得手动传 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""falseNaN)都兜底,常误伤;?? 只在 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 给方法第一个参数写 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 只是编译期约束 TS 的 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
为什么需要泛型 若用 anyfirst 会返回 any,丢失了“返回类型和数组元素类型相同”的信息。泛型让 TS 记住这个关联:传 number[] 就返回 number,传 string[] 就返回 string——既灵活又安全。
命名约定 习惯用 T(Type)、UK(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 的键
类型安全的“按 key 取属性” 手写 JS 的 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——没有任何值能同时满足。别和联合 | 搞混。
收窄的几种武器 typeofininstanceof=== 字面量判断、以及自定义类型守卫(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 对原始类型无效 in 用来判断“对象有没有某个属性”,对 null/undefinedin 会抛运行时错误,用之前先判非空。区分类实例优先用 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)不兼容。
别乱序赋值数字枚举 数字枚举默认从 0 自增;若你手动给中间某项赋值,后面项会接着那个值涨。顺序不直观时极易出 bug。需要稳定、可读、可序列化时,优先用字符串枚举或纯 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 的兼容坑 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
路径与扩展名 TS 里 import 通常不写 .ts 扩展名(写 ./math 不是 ./math.ts),编译后由模块解析规则决定。但 Deno/Bun 要求写全 .ts 扩展名——按你用的运行时约定来。混用会报“找不到模块”。
默认导出 vs 命名导出 命名导出(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 即可;但若你把它当类型用,优先 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 对该表达式的检查。如果运行时真是 nulls!.length 立刻抛错。能用 if (s) 收窄就别用 !;只在你 100% 确定非空的场景(如刚赋过值)才用。
as 不能乱转 两个毫无关系的类型直接 as 会报“转换可能不合理”,此时要反思是不是类型设计本身有问题。确需绕过时只能“先 as unknownas 目标”(双重断言),但这等于手动关检查,慎用。

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(只要不缺)
“多”可赋给“少”,反之不行 源比目标“字段更多”能赋值(目标不要的字段被忽略);目标比源“字段更多”不能赋(源缺字段)。这是结构化类型的核心直觉。函数参数同理:传的对象“至少”有参数要的字段即可。
比较对象字面量更严 TS 对“直接写的对象字面量”会做多余属性检查(excess property check)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 条件类型常配合 infer 从复杂类型里“提取”子类型,例如 type Ele<T> = T extends Array<infer E> ? E : T; 能拿数组元素类型。这是写高级工具类型的标配(见第 15 节)。
约束让泛型更稳 不加约束的 T 上只能调用所有类型共有操作(基本啥也干不了)。一旦 extends 约束,就能安全访问约束里的属性/方法,既灵活又不会乱用。写库时多用约束。

11.1 条件类型 + infer 实战

infer 让你在条件类型里“捕获”子类型。下面自己实现内置的 ReturnTypeArrayElement

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] }));
UI 状态首选 React/Vue 里常把“请求状态”写成这种判别联合,渲染时 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
装饰器还没完全稳定 TS 的“实验性装饰器”语法与 TC39 正在标准化的“Stage 3 装饰器”在写法/语义上不兼容。新项目若用框架(如 Angular/NestJS)就按其要求开 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
装饰器参数顺序 类装饰器收构造函数;方法装饰器收 (target, key, descriptor);属性装饰器收 (target, key)。记混参数会拿到 undefined。TS 只在 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 不能合并 如果你写两个 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 类型声明写在 .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]; };
优先用内置工具类型 90% 场景用内置 Pick/Omit/Partial/Record 就够了,别手工重写一个结构略不同的接口——一旦原接口改字段,你手写的就脱节了。工具类型永远跟着源类型走。
Omit 的嵌套坑 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;,等于逼编译器替你盯梢:“所有情况都处理了吗?没处理就报错”。大型重构时这能救你一命——忘加分支会立刻红,而不是线上才爆。
never 的另一用途 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"
何时用这些 递归条件类型常用于“深度工具类型”(深度 Partial/Readonly/Mutable);模板字面量类型常用于“按命名约定生成类型”。业务代码大部分用不到,但写通用库(如表单校验、路由类型)时是神器。
别过度设计 “类型体操”写爽了容易上头,但过于复杂的类型会让编译变慢、报错信息像天书、同事看不懂。黄金法则:类型是为了减少 bug 和提升体验,不是为了炫技。三行注释胜过二十行条件类型。

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 很香但要懂 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): stringformat(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>,含 pushpoppeekisEmpty,分别用 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)

每个框都能用下拉切换语言、「↺ 重置」回到初始代码。想写完整程序或体验其他语言,去顶部「练习场」(支持 Python/Java/Go/JS/TS/Rust/C#/Ruby 等)。注意:本站运行器文件名固定为 main.ts

实战干货(12 条工业级技巧)面试与上线都绕不开

干货 1 · 结构化类型(鸭子类型) TS 用结构化类型系统:两个类型“兼容”不看名字,只看结构是否匹配。只要 A 的字段 B 都有且类型对得上,A 就能赋值给 B——所谓“走路像鸭子、叫像鸭子,就是鸭子”。这跟 Java/C# 的名义类型(nominal)(类名必须相同/显式继承)完全不同。interface A { x: number }interface B { x: number; y: string },B 可以赋给 A(多出的字段无所谓),但 A 不能赋给 B(缺 y)。这带来了灵活,也带来了“形状巧合导致误兼容”的隐患——所以关键接口用 as const / 字面量类型收窄更稳。
干货 2 · 协变与逆变 TS 默认数组是协变的:Dog[] 可赋给 Animal[](直观,但运行时可能越界写错——这是结构化类型的历史妥协)。而函数参数默认是逆变(a: Animal) => void 可赋给 (d: Dog) => void(参数能接受更宽的类型才安全)。开 strictFunctionTypes 后,函数参数逆变会被严格检查(方法参数仍保持双变以兼容老代码)。理解它,你才看得懂某些“明明能赋值却报错/报错却能赋值”的诡异现象。
干货 3 · 泛型约束 + 条件类型 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
这是写 ReturnTypeParameters 等内置工具类型的底层手法。库作者必备,业务代码适度即可。
干货 4 · 类型守卫 typeof / in / instanceof + 自定义 is 收窄联合最稳的三板斧:typeof x === "string""field" in xx instanceof Foo。当它们不够用时,写自定义守卫 function isFish(x: unknown): x is Fish { return ... }——返回 x is T 后,if (isFish(x)) 块内 x 自动收窄为 Fish。切记守卫实现要对,因为那是你向编译器作的“担保”。
干货 5 · 模板字面量类型 TS 4.1+ 支持“模板字符串作为类型”,能在类型层面做字符串拼接与校验,常用于路由、CSS 单位、国际化 key:
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";        // ❌
这能把“拼写错误”在编译期挡掉,比运行时校验早得多。
干货 6 · satisfies 操作符 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 再担心形状”的笨拙写法,是配置对象的最佳实践。
干货 7 · keyof / 映射类型 / Pick/Omit/Partial/Record/Required keyof T 取所有键的联合;{ [P in keyof T]: T[P] } 叫映射类型,是批量改造字段的引擎。日常直接用内置工具类型:Partial(全可选,适合更新表单)、Required(全必填)、Pick(挑字段)、Omit(去字段)、Record<K,V>(构造字典类型)。它们永远跟随源类型演化,比手写重复接口 DRY 得多。
干货 8 · 声明合并 interface 同名自动合并,type 不能重复声明。利用这点可“给第三方全局对象偷偷补类型”(如扩展 Window)。但也要注意:意外的同名 interface 会被合并,可能导致你以为独立的类型被悄悄“污染”。团队里对全局类型扩展要集中管理,别散落各处。
干货 9 · 装饰器元数据(实验性) 装饰器语法糖在 Angular/NestJS/TypeORM 里遍地都是,但要拿运行时元数据必须:① tsconfigexperimentalDecoratorsemitDecoratorMetadata;② 安装并 import "reflect-metadata"。且 TS 实验装饰器与标准 Stage 3 装饰器语法不兼容,新原生项目建议等标准落地。滥用装饰器会让控制流变隐式,调试时“不知道这逻辑哪来的”。
干货 10 · never 穷尽检查 + 判别联合 在判别联合的 switch 末尾写 const _x: never = s;,一旦以后给联合加新成员却漏处理,编译立即报错——这是大型重构的“保险丝”。配合 kind 字面量标签字段,是 TS 表达“有限状态机 / 多种消息”的最稳范式。业务里把“消息类型”“错误类型”写成判别联合,能大幅减少 switch 漏写。
干货 11 · readonly 与 as const 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";          // ❌
写常量配置、Redux action type、路由表时 as const 能避免被意外修改,并保留精确字面量类型。
干货 12 · unknown vs any any 关掉所有检查(自动补全也没了),是“类型系统的逃生舱”,滥用等于退回 JS;unknown 表示“我也不知道是啥,但你必须先收窄才能用”——它安全:不能直接调用方法、不能当具体类型用,必须先 typeof/守卫判断。经验法则:凡是“来源不可信/类型未知”的地方(API 响应、JSON.parse、用户输入)一律用 unknown 而非 anyJSON.parse 的返回类型本应是 unknown(TS 历史原因仍标 any,可手动断言成 unknown)。
干货 13 · unknown 的“桥接”运行时校验(zod 思路) 前端拿到 API JSON,光靠 as 断言成类型并不安全——数据可能不符合。工业界做法是“运行时校验库”(如 zod):它既在运行时验证数据形状,又能反推出 TS 类型(z.infer<typeof Schema>),做到“一份定义,类型与校验两用”。这是 unknown → 可信类型 最稳的桥。
干货 14 · 渐进式迁移 JS → TS 老 JS 项目别一次性全改 TS。分阶段:① 开 allowJs.js.ts 共存;② 在 .js 顶部写 // @ts-check 让 TS 对 JS 也做部分检查;③ 逐个文件改后缀为 .ts,先把不确定处标 any,再逐步收紧。这样风险最低、团队最容易接受。
干货 15 · 类型体操的边界:何时该停手 遇到极复杂的类型需求,先想“有没有现成库/工具类型能解决”。手写 30 行递归条件类型,往往不如用一个 type-fest 里的现成类型好维护。类型存在的意义是降低心智负担,不是增加。当类型报错信息开始你看不懂时,就是该简化的信号。
干货 16 · 类型只在开发期,运行时没有 再强调一次:TS 编译后类型被完全擦除,运行的就是纯 JS。所以“类型保护”只在你写代码/编译时有效,运行时该做的参数校验(如后端接口验参)还得做,别以为有了 TS 就不用防非法输入。TS 防的是“开发者的笔误”,不是“用户的恶意/异常数据”。
干货 17 · 三方库没类型怎么办 遇到没类型的 npm 包:① 先看有没有社区维护的 @types/xxxnpm i -D @types/xxx);② 没有就自己写一份 .d.ts 声明文件;③ 临时应急可 declare module "xxx"; 让它变成 any(不推荐长期)。现代主流库基本都自带类型,缺类型的多半是老库或小众库。
干货 18 · 让类型“流动”而非“写死” 高手的 TS 代码里类型往往是“算”出来的,不是“写”出来的:用 typeof 从值取类型、用 ReturnType 从函数取返回、用映射类型从已有接口派生。这样上游一改,下游自动跟着变,几乎零维护。新手常把类型写死成具体形状,结果上游一变就到处红。多练“类型推导”,少写“字面量硬标注”。
给纯新手的最后一句话TS 入门不难,难在“坚持写”。每天写 30 行、改 30 行,一个月后你会惊讶于自己的进步。本站「练习场」和上面的「动手实验室」随时等你。类型报错别怕,它是在替你挡 bug。

附录 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没开 experimentalDecoratorstsconfig 开启该开关
Excess property 'x' ...对象字面量多出字段先存变量再传,或放宽目标类型
'X' refers to a type but used as value把接口当类 new接口不能 new,改用 class
Type instantiation is excessively deep递归类型太深限制递归深度,或拆成更简单的类型

附录 B:TypeScript vs JavaScript一张表看懂区别

维度JavaScriptTypeScript
类型检查时机运行时才暴露错误编译期(写代码时)暴露
怎么运行直接 node / 浏览器tsc 编译成 JS 再运行
类型注解有(可选、可推断)
大型项目维护易出低级 bug重构安全、自动补全强
学习曲线中等(要学类型系统)
生态自身同一生态(TS 是 JS 超集)
招聘需求基础岗现代前端/Node 岗默认要求
一句话TS = JS + 编译期类型安全。会 JS 就能上手 TS,多花的“类型学费”会在维护期十倍赚回。

附录 C:tsconfig 常用字段照着抄,再按项目调

字段作用建议
target编译到哪版 JSES2020+
module模块系统ESNext
moduleResolution模块解析策略Bundler(Vite)或 node
strict开启全部严格检查true
noImplicitAny禁止隐式 anytrue
strictNullChecksnull/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 写一个小型全栈/前端项目,读类型库源码
三条记住 TS ① 类型只是开发期帮手,运行时没有类型;② 报错是编译器替你挡 bug,别烦它、改它;③ 能写 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)这些坑几乎人人都踩过

Q:类型报错了还能运行吗? 默认能。tsc 报类型错仍会输出 JS(除非 noEmitOnError)。所以“红了但页面照常跑”是正常现象——类型错是提醒,不是阻断。但也别因此无视红,积累多了迟早出运行时 bug。
Q:写了 TS 还要写测试吗? 要。TS 防笔误、防类型错,但防不了业务逻辑错。单测、集成测该写还得写。TS 和测试是互补,不是替代。
Q:any 用一点点行不行? 应急可以,但要有意识。把 any 当“临时止血”,尽快换成具体类型或 unknown。团队里可开 noExplicitAny 禁止显式 any,逼大家写真实类型。
Q:TS 文件浏览器直接打不开? 对。浏览器只认 JS,.ts 必须经 tsc/打包器编译。本地想直接跑,用 deno/bun(原生支持 TS);线上必须编译。本站运行器已替你编译好。
Q:类型写得太细,维护成本高? 是的,过细的类型是负担。原则:对外接口(函数参数/返回值、模块导出)写清楚;内部局部变量靠推断、能省则省。类型和注释一样,服务于“人和未来的自己”。

推荐练习项目边做边学,比看十遍有用

  • 类型版待办清单:用 interface 定义 Task,泛型函数过滤/排序,判别联合表达“进行中/已完成”。
  • 表单校验器:用工具类型 + 条件类型生成“字段→错误”的映射类型,练习 Partial/Record
  • 简易路由:用模板字面量类型约束路径(如 /user/:id),练习高级字面量类型。
  • API 客户端:用泛型把“请求类型 → 响应类型”串起来,练习 ReturnType/infer
  • 状态机:用判别联合建模“加载中/成功/失败”,渲染时 switch 全覆盖。
做项目的诀窍每个项目都先写类型(interface/type)再写实现。类型定好了,实现就像“填空”,编译器还会一路提示你。这比“先写 JS 再补类型”顺得多。

附录 F:常用生态工具这些命令/库早晚会用到

工具干嘛用
tsc官方编译器,做类型检查/编译
ts-node / tsx直接运行 .ts(开发期方便,不必先编译)
deno / bun原生支持 TS 的运行时,deno run x.ts 即可
@types/nodeNode 内置 API 的类型,写后端必装
type-fest社区工具类型大合集(很多高级类型现成可用)
zod / io-ts运行时校验 + 反推类型,桥接 unknown 与可信类型
vite / esbuild极快打包/转译(不做类型检查,配 tsc 把关)
ts-pattern强大的模式匹配库,配合判别联合写出清晰的 switch 替代
新手装这几个就够typescript(编译器)、@types/node(若写后端)、一个运行时(tsxdeno)。其余等真遇到需求再装,别一上来堆一堆用不上的依赖。

类型错误调试四步红了一片时别慌

第一步:看第一条报错 TS 报错经常“连锁反应”——一个根因引发十几条红。永远先看第一条,修好它,后面一半可能自动消失。别从最后一条往上硬改。
第二步:把鼠标悬停看推断类型 IDE(VS Code)里把光标放到变量上,看编辑器显示的“推断类型”和你以为的是不是一样。类型不符的根源,90% 在这里现形。
第三步:用收窄/断言精确定位 若是联合类型没收窄,补 if (typeof x === ...) 或类型守卫;若是第三方类型太宽,用 as 收窄(确认无误后)。临时想看某个表达式的类型,可写 type T = typeof x; 再悬停 T。
第四步:最小复现 实在看不懂的复杂类型报错,把相关代码提取成一个最小 .ts 文件单独编译(tsc --noEmit),去掉无关部分,往往立刻看清问题。复杂类型报错信息冗长,简化后更容易读。

推荐资源学完本课后接着看

  • TypeScript Handbook(官方手册)——权威、系统。
  • TS Playground——在线试类型,报错即时显示,最适合练手。
  • type-challenges——类型体操题库,从易到地狱,进阶必刷。
  • type-fest——读它的源码,学工业级工具类型怎么写。
  • 本站「练习场」与「动手实验室」——把本课例子都敲一遍、改一遍。
最后一句话TS 不是用来“装高级”的,是用来“少加班”的。类型写得越准,半年后改代码的你越感谢现在的你。

自测题(点击展开答案)

  • Q1:TS 的类型检查报错,会阻止生成最终的 JS 吗?
    查看答案默认不会tsc 仍会输出 .js(类型只是开发期帮手)。想“有错就不生成”,在 tsconfig.json"noEmitOnError": true运行时没有类型,TS 最终被擦除成纯 JS。
  • Q2:anyunknown 主要区别?
    查看答案any 关闭所有类型检查、补全也失效;unknown 表示未知但安全,必须先用 typeof/类型守卫收窄后才能当具体类型使用。不可信来源(API、JSON.parse、用户输入)应优先 unknown
  • Q3:interfacetype 在“声明合并”上有什么不同?
    查看答案同名 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:interfacetype 都能描述对象,何时必须用 type
    查看答案做联合 A | B、交叉 A & B、条件类型、工具类型/映射类型时必须typeinterface 不支持这些。描述普通对象形状两者皆可,但 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:为什么推荐用 unknownJSON.parse 的结果?
    查看答案因为解析出的真实结构编译期未知,标 any 会让后续随意访问而不报错(运行时易崩)。标 unknown 后必须先收窄/校验才能用,把“结构假设”显式化,更安全。
本 TypeScript 课程遵循 TS 5.x 常用子集,手机与电脑端自适应,配套本站运行器(TypeScript 5.6)练习效果最佳。 公网参考:typescriptlang.org 官方文档 · TS Playground · microsoft/TypeScript。
🛠 在线练习