TypeScript 知识点
TypeScript 知识点
概述:面向 C# 开发者的渐进式学习笔记——以案例为主线:每个知识点先用最小可运行的代码演示"是什么",再用 C# 类比说明"怎么理解",最后用陷阱清单指出"哪里会踩坑"。
环境:面向 TypeScript 5.x(正文引用 TS 4.9 的satisfies、TS 5.7 的ES2024target)。
符号约定:❌ 标记的代码是错误写法,用于演示编译错误;✅ 是正确写法;⚠️ 是需要注意的差异。
阅读方式:第一次学习按模块 0 → 8 顺序读;TypeScript vs C# 速查表和各个陷阱块可以随时查阅。
目录
- 模块 0:环境准备——tsconfig 关键决策
- 模块 1:基础数据结构与类型入门
- 模块 2:类型系统核心操作——联合类型、字面量与类型安全
- 模块 3:Interface vs Type
- 模块 4:泛型
- 模块 5:内置工具类型
- 模块 6:函数 — 高级模式
- 模块 7:async/await & Promise
- 模块 8:模块系统 & 实战 HttpClient
- TypeScript vs C# 速查表
- 附录:类型体操性能提示
模块 0:环境准备——tsconfig 关键决策
TS 的类型安全不是默认开启的——取决于 tsconfig.json。以下是面向 Node.js 全栈项目必须理解的关键选项:
strict: true — 不开等于白写 TS
strict 不是一个开关,而是 8 个子开关的聚合(TS 4.4 起新增 useUnknownInCatchVariables):
| 子开关 | 作用 | 关闭的后果 |
|---|---|---|
strictNullChecks |
null / undefined 不能赋值给其他类型 |
const x: string = null 静默通过 |
noImplicitAny |
禁止自动推断为 any |
函数参数忘写类型 = 静默 any |
strictFunctionTypes |
函数参数逆变检查 | 回调参数类型不匹配偷偷通过 |
strictBindCallApply |
bind / call / apply 参数检查 |
动态调用绕开类型检查 |
strictPropertyInitialization |
类属性必须初始化 | 未初始化属性 = undefined 运行时炸弹 |
noImplicitThis |
this 必须明确声明类型 |
this 静默变 any |
alwaysStrict |
编译输出 "use strict" |
旧 JS 宽松模式下的静默错误 |
useUnknownInCatchVariables |
catch 变量类型为 unknown |
catch 变量静默变 any |
不开启 strict 的 TS 是纸老虎——类型系统有大量暗门,null 和 any 可以到处流窜。
target / module / moduleResolution 三者关系
| 选项 | 含义 | C# 类比 |
|---|---|---|
target |
输出 JS 版本(ES2022 / ESNext) | LangVersion |
module |
输出模块格式(NodeNext / ESNext / CommonJS) | 输出目标类型 |
moduleResolution |
TS 查找模块的策略(bundler / nodenext / node10) | 引用解析策略 |
注:
node10(旧名node)是旧版解析策略,新项目用bundler(前端)或nodenext(Node.js)。
Node.js 24 推荐最小配置(需要 TypeScript ≥ 5.7——ES2024 target 自该版本加入):
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
常见误区:target 低不会导致输出"不兼容"——target 只决定语法降级级别,越低输出越保守、越兼容。
常见错误是 module 与 moduleResolution 不匹配:module: "NodeNext" 隐含 moduleResolution: "NodeNext",显式配成别的值(如 node10)会直接报错;另外 Node ESM 项目 package.json 未声明 "type": "module" 时,NodeNext 会按 CommonJS 输出。
保持 target ≥ 你的实际运行环境,是为了能用上新语法,而不是兼容性问题。
模块 1:基础数据结构与类型入门
📌 什么时候会踩到:C# 里
List<T>/HashSet<T>/Dictionary<K,V>是标准库,
TS 对应Array/Set/Map——API 不同,解决的是同一类问题。先把这三个摸熟,后续模块都建立在这三个之上。
1.1 数组(Array)
TS 的 Array<T> 对应 C# 的 List<T>——动态数组。尾部操作(push/pop)O(1),头部操作(shift/unshift)O(n):
const arr = [1, 2, 3, 4];
const last = arr.pop(); // Stack.Pop → 4(弹出并返回末尾元素)
const first = arr.shift(); // Queue.Dequeue → 1(弹出并返回头部元素)
arr.unshift(0); // 头部插入 → [0,2,3]
1.2 Set — 唯一值集合
O(n) 去重、写法最简(对应 C# 的 HashSet<T>):
const unique = [...new Set([1, 2, 2, 3, 3, 3])]; // [1, 2, 3]
1.3 Map — 键值对
对应 C# 的 Dictionary<K,V>:
const cache = new Map<string, number>();
cache.set("apple", 3);
cache.get("apple"); // 3
注意:Set / Map 比较用引用相等,不是值相等:
const s = new Set<number[]>();
s.add([1, 2]);
s.has([1, 2]); // false!因为是不同的数组引用
1.4 类型注解与 interface(预览)
TS 的类型注解是"给变量/参数/返回值贴契约标签";interface 描述对象的形状。
(interface vs type 的完整对比见模块 3。)
let count: number = 0; // 变量注解
function add(a: number, b: number): number { // 参数 + 返回值注解
return a + b;
}
interface User { // 描述对象形状
id: number;
name: string;
}
const alice: User = { id: 1, name: "Alice" };
1.5 const 声明与"不可变"
const 锁的是绑定(不能重新赋值),不是内容(对象/数组内部仍可变):
const arr = [1, 2, 3];
arr = []; // ❌ 编译错误:不能给 const 重新赋值
arr.push(4); // ✅ 内容可以变——arr 现在是 [1, 2, 3, 4]
1.6 常量数组——需要"运行时能遍历"的枚举值
C# enum 自带运行时遍历(Enum.GetValues);TS 的字面量联合类型只在编译期存在。
需要运行时遍历(生成下拉选项、写配置校验等)时,用常量数组 + as const:
const STATUSES = ["idle", "loading", "success"] as const;
// 运行时遍历
STATUSES.forEach(s => console.log(s)); // idle / loading / success
// 同时反推联合类型——数组是 single source of truth
type Status = (typeof STATUSES)[number]; // "idle" | "loading" | "success"
as const 的完整机制见模块 2.5。
1.7 空值处理:null / undefined / 可选链 / 空值合并
📌 C# 只有一个空值
null;TS 有两个——null(显式"没有值")和undefined("还没赋值")。对 C# 开发者,这是第一道要过的坎。
// null vs undefined——语义分工
let a: string | null = null; // 显式声明"没有值"(≈ C# 的 null)
let b: string | undefined; // 声明但没赋值 → undefined(C# 没有这个概念)
let c: string; // ❌ strict 下报错:未初始化(strictPropertyInitialization)
// 函数没有 return → undefined(C# 编译错,TS 合法)
function noop(): void { /* 无 return */ }
const r = noop(); // undefined
可选链 ?.——C# 有,但 TS 覆盖更全(索引、调用都能链):
interface Profile { name: string; }
interface User { profile?: Profile; }
const user: User = {}; // 没有 profile
user.profile.name; // ❌ 运行时炸:Cannot read properties of undefined
user.profile?.name; // ✅ undefined——一路短路,不炸
// 假设 arr 是数组、obj 是有可选方法的对象
arr?.[0]; // ✅ 可选索引(C# 的 ?. 不行)
obj?.method?.(); // ✅ 可选调用
空值合并 ??——只兜 null / undefined,不兜 0 / "" / false:
// 假设 config 是 { port?: number; count?: number; label?: string }
const port = config.port ?? 8080; // port 是 null/undefined 才用 8080
const count = config.count ?? 0; // count = 0 时保留 0!
const label = config.label || "默认"; // ❌ 空字符串 "" 也被替换——|| 兜所有 falsy
和 C# 对比:C# 的 ?? 只兜 null,TS 的 ?? 兜 null + undefined——但两边都不兜 0 / "" / false,这一点与 C# 一致。真正要防的是 ||(TS 里常见但语义是 falsy 兜底)。
经典组合——嵌套配置读取:
// 假设 config 有嵌套结构 config.ui.theme(config 同上)
const theme = config?.ui?.theme ?? "light"; // 读不到就兜底,全程不炸
陷阱:?? 不能与 || / && 直接混用:
const x = a ?? b || c; // ❌ TS5076:?? 与 || 混用必须加括号
const y = (a ?? b) || c; // ✅
🪤 模块 1 常见陷阱
- 前缀
I不是 TS 惯例:interface IUser是 C# 习惯——TS 社区直接用interface User。声明合并时I前缀反而是噪音。- interface 编译后消失,不能
new:interface User { name: string }编译为 JS 后是 0 行代码。它是纯编译时契约,运行时不存在。const声明 ≠ 不可变对象:const arr = [1,2,3]不能arr = [],但可以arr.push(4)(见 1.5)。编译期只读用as const(见模块 2.5)——它只改类型,运行时对象依然可变;运行时真正冻结只有Object.freeze()。
模块 2:类型系统核心操作——联合类型、字面量与类型安全
📌 什么时候会踩到:你在 C# 里写过几个只有名字不同的
enum?RequestStatus、ResponseStatus、OrderStatus……本质都是整数别名。TS 用字面量联合类型替代——1 行搞定,零运行时开销,序列化直出字符串。代价是失去 C# enum 的运行时迭代——下文教你取舍。
2.1 联合类型(Union Types)
type Status = "idle" | "loading" | "success" | "error";
// 穷尽检查助手:配合 default 分支,新增枚举值时编译器在这里报错
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function handleStatus(s: Status): string {
switch (s) {
case "idle": return "等待中";
case "loading": return "加载中...";
case "success": return "✓ 成功";
case "error": return "✗ 错误";
default: return assertNever(s); // Status 新增成员时,这里编译报错
}
}
| C# enum | TS string literal union | |
|---|---|---|
| 本质 | 整数别名 | 字符串字面量 |
| 扩展 | 不能动态扩展 | 天然可组合 |
| 序列化 | Status.Active → 0 |
"active" → "active" |
| 携带数据 | ❌ 不能 | ✅ 可结合对象 |
2.2 Discriminated Union(Tagged Union)
C# 需要用 class hierarchy 表达的东西,TS 用 5 行完成:
type Result<T> =
| { kind: "ok"; value: T }
| { kind: "err"; message: string };
function unwrap<T>(r: Result<T>): T {
if (r.kind === "ok") {
return r.value; // TS 自动收窄类型
}
throw new Error(r.message);
}
C# 等价:
abstract record Result<T>;
record Ok<T>(T Value) : Result<T>;
record Err<T>(string Message) : Result<T>;
// + switch expression pattern matching
2.3 类型收窄(Type Narrowing)
TS 在条件分支中自动收窄类型:
function describe(x: string | number | string[]): void {
if (typeof x === "string") {
console.log("字符串,长度:", x.length);
} else if (Array.isArray(x)) {
console.log("数组,元素数:", x.length);
} else {
console.log("数字,没有 length 属性");
}
}
收窄手段一览:
| 守卫表达式 | 收窄目标 | C# 等价 |
|---|---|---|
typeof x === "string" |
string |
x is string |
typeof x === "number" |
number |
x is int |
Array.isArray(x) |
T[] |
x is T[] |
x instanceof MyClass |
MyClass |
x is MyClass |
"key" in x |
包含该 key 的类型 | reflection / pattern match |
x.kind === "ok" |
discriminated union 分支 | switch expression |
2.4 自定义类型守卫
interface Cat { kind: "cat"; meow(): void; }
interface Dog { kind: "dog"; bark(): void; }
// 返回类型 animal is Cat 是关键——告诉 TS "这个 boolean 携带类型信息"
function isCat(animal: Cat | Dog): animal is Cat {
return animal.kind === "cat"; // 判别字段(discriminant)
// ↑ 运行时:检查判别字段的值
}
function greet(animal: Cat | Dog): void {
if (isCat(animal)) {
animal.meow(); // TS 知道这里是 Cat
} else {
animal.bark(); // TS 知道这里是 Dog
}
}
两步分工:
- 运行时判断:
animal.kind === "cat"(判别字段比较——比"meow" in animal稳,不受原型链影响) - 编译时类型信息:
animal is Cat(告诉 TS true 意味着 Cat)
2.5 as const — 字面量类型的开关
TS 的推断默认"宽松"——数组推断为 string[],丢失字面量精度。as const 将推断锁定为最窄的字面量类型:
// 不加 as const → 推断为 string[](注释掉,避免与下面重复声明)
// const STATUSES = ["idle", "loading", "success"];
// ^? string[]
// 加 as const → 推断为 readonly ["idle", "loading", "success"]
const STATUSES = ["idle", "loading", "success"] as const;
// ^? readonly ["idle", "loading", "success"]
// 从数组反推联合类型——两步逻辑
type Status = (typeof STATUSES)[number];
// "idle" | "loading" | "success"
// ① typeof 取数组元组类型 ② [number] 索引取元素联合
这是"从值推导类型"的标准模式:常量数组作为 single source of truth,用 (typeof arr)[number] 反推联合类型。
// 对象同理——所有属性变 readonly + 字面量
const config = { port: 3000, host: "localhost" } as const;
// ^? { readonly port: 3000; readonly host: "localhost" }
对比 C#:C# 的 const 不能用于对象和数组;TS 的 as const 可以递归把整个对象图标记为 readonly + 字面量类型(注意:这只是编译期类型,不是运行时冻结,见模块 1 陷阱 3)。
2.6 satisfies — 检查但不收窄
TS 4.9 引入的操作符:检查类型兼容,但不改变推断结果——既享受类型检查,又保留具体类型信息:
type Config = { port: number; host: string };
// ❌ 类型注解 → 类型收窄为 Config,丢失具体信息
const configTyped: Config = { port: 3000, host: "localhost" };
configTyped.port; // number
// ✅ satisfies → 检查兼容,保留原始推断
const config = { port: 3000, host: "localhost" } satisfies Config;
// 值的拼写错误 TS 看不出:"localost" 仍是 string,兼容通过
// 但多余的属性会报错:{ port, host, debug: true } satisfies Config → ❌ TS2353
与 as const 组合——既锁定字面量又通过类型检查:
const config = { port: 3000, host: "localhost" } as const satisfies Config;
config.port; // 3000 —— 字面量保留!
典型实战:路由表既要类型检查,又要精确 key 推导:
const ROUTES = {
home: "/",
about: "/about",
api: "/api/v1",
} satisfies Record<string, string>;
type Route = keyof typeof ROUTES; // "home" | "about" | "api"
2.7 TS 原生 enum——什么时候该用它
📌 2.1 推荐用字面量联合类型替代 enum。但 TS 确实有原生
enum——C# 开发者会本能地写。这一节讲清它是什么、有什么坑、什么时候该用。
// 数字枚举——默认从 0 开始自增
enum Status { Idle, Loading, Success }
// Status.Idle = 0, Status.Loading = 1, Status.Success = 2
// 数字枚举是"双向映射":名字 ↔ 数字 都能查
Status.Success; // 2
Status[2]; // "Success"(反向查找——编译产物里有一个双向对象)
// 字符串枚举——只有单向映射
enum Color { Red = "#f00", Green = "#0f0" }
Color.Red; // "#f00"
// Color["#f00"]; // ❌ TS7053:字符串枚举没有反向映射
// const enum——编译期直接内联
const enum Direction { Up, Down }
const d = Direction.Up; // 编译后直接是 0,不留对象
TS 枚举是名义类型,不是结构类型(和 C# 一样,和 TS 其他类型相反)。但数字和字符串的严格度不同:
const s1: Status = 1; // ✅ 数字枚举:1 是 Loading 的成员值,字面量直接通过
// const s3: Status = 5; // ❌ 5 不是成员值
const c1: Color = "#f00"; // ❌ 字符串枚举:即使值匹配也不接受字面量
const c2: Color = Color.Red; // ✅ 只能通过成员名引用
let n: number = Status.Idle; // ✅ enum → number 可以
这个不对称是 TS 的坑:数字枚举接受合法成员值的字面量,字符串枚举完全不接受——
从 API 序列化回来的字符串不能直接赋给字符串枚举,必须自己映射。
什么时候用 enum,什么时候用联合类型:
| 需求 | 用 |
|---|---|
| 序列化要可读字符串(API / 日志) | 联合类型——"success" 直出,无需映射 |
| 需要数字值(数据库存 int、位运算 flags) | enum |
| 需要运行时名字↔值双向查询 | enum(数字枚举) |
| 状态机 / 分支逻辑 | 联合类型 + discriminated union(2.2) |
| 前端枚举值列表(下拉选项) | 常量数组 + as const(1.6) |
🪤 2.7 enum 陷阱
- 数字枚举的反向映射是运行时对象:
Status[0]能查是因为编译产物里有一个双向对象——会增大 bundle。不需要反向查询时用字符串枚举或联合类型。const enum与现代构建链不兼容:跨文件内联只有 tsc 能做;Babel / esbuild / Vite 等转译器按单文件工作(等价isolatedModules语义),const enum会失去内联甚至行为不一致。现代代码库通常避免const enum。- 字符串枚举没有反向映射,也不接受字面量赋值:
Color["#f00"]和const c: Color = "#f00"都报错——和数字枚举行为不对称。- 混合枚举(数字+字符串成员):TS 允许但极易出错,别写。
2.8 类型断言 as / 非空断言 !
📌 2.3 / 2.4 学了"安全收窄"(类型守卫)。
as是另一条路:跳过检查,直接断言。C# 开发者最大的误解是把as当成 C# 的 cast——C# 的 cast 有运行时检查,TS 的as没有。
// as —— 告诉编译器"我比你更懂",不做任何运行时检查
const input = document.getElementById("name") as HTMLInputElement; // 浏览器环境
// 运行时如果 getElementById 返回 null,这行照样"成功"——后面 input.value 才炸
// 和 C# 的差别:
// C#: var x = (string)obj; // 运行时检查,类型不对抛 InvalidCastException
// TS: const x = obj as string; // 零运行时检查——类型不对也是这个"类型"
// 合理用法:DOM 查询、JSON 解析(JSON 最好再加运行时校验,见模块 8 陷阱 1)
// 假设 response 是 fetch 的响应对象,Todo 类型见模块 8.2
const data = await response.json() as Todo;
// 非空断言 ! —— 告诉 TS "这里不会是 null/undefined"
// 假设 items 是 { id: number }[]
const first = items.find(x => x.id === 1)!; // find 返回 T | undefined
// 运行时如果真没有匹配项——undefined 照常传播,调用处才炸
为什么有时要 as unknown as 两步——TS 会检查两个类型是否"足够重叠":
const bad = 42 as { name: string }; // ❌ TS2352:number 和 {name} 不重叠,直接报错
const ok = 42 as unknown as { name: string }; // ✅ 两步绕过(但你在对编译器撒谎)
更好的选择:自带泛型的 API 不需要 as——某些 API 天生类型安全:
const input = document.querySelector<HTMLInputElement>("#name");
// ↑ 泛型版本:返回 HTMLInputElement | null,不需要 as
if (input) {
input.value; // ✅ 守卫处理空值,没有 as 的运行时风险
}
原则:守卫 vs 断言
| 类型守卫(2.3 / 2.4) | 断言 as / ! | |
|---|---|---|
| 验证方式 | 运行时真的检查 | 完全跳过 |
| 出错时 | 走 else 分支,类型安全 | 运行时才炸 |
| 该用在哪 | 一切能写守卫的地方 | DOM 查询、JSON、第三方无类型库 |
一句话:能用守卫就用守卫。as / ! 是欠债,要还的(运行时炸)。非空断言 ! 尤其要克制——它是 C# null-forgiving 的对应物:C# 的 ! 也只是编译期声明,运行时 null 照样炸,行为一致。
🪤 模块 2 常见陷阱
typeof只能区分 JS 基本类型:typeof [] === "object"(不是"array")。类型守卫中typeof的 8 种结果都有意义:
"string"/"number"/"boolean"/"symbol"/"undefined"/"function"/"bigint"/"object"。
注意typeof null === "object"——收窄时 null 要单独处理。细化判断用Array.isArray或instanceof。instanceof对interface无效:interface 编译后消失——运行时不存在这个类型。instanceof只能用于class。- 字面量联合类型不是 enum 的完全替代:需要运行时遍历所有值(生成下拉选项等)时,仍需
enum或常量数组 +as const(见模块 1.6;enum 的完整讨论见模块 2.7)。
→ 本模块的
Result<T>discriminated union 模式,在模块 8的ApiResult<T>中实战再现。
模块 3:Interface vs Type
📌 什么时候会踩到:TS 有两个几乎等价的东西来描述对象——
interface和type。C# 开发者会本能地选interface,但 TS 的type能做很多 interface 做不了的事。一句话规则:能用 interface 就用,interface 做不到时再用 type。
3.1 基础对照
// Interface — 描述对象契约
interface Person {
name: string;
age: number;
}
// Type — 同样可以描述对象
type Person = {
name: string;
age: number;
};
这两种在描述对象形状时完全等价。
3.2 Interface 独有能力
声明合并(Declaration Merging)——同名 interface 自动合并:
interface Person {
name: string;
age: number;
}
// 另一个地方再声明同名 interface——合并了
interface Person {
email?: string; // 可选属性
}
const alice: Person = { name: "Alice", age: 28 };
Type 做不到——同名 type 直接报错。
extends 更自然:
interface Animal { species: string; }
interface Bird extends Animal { wingspan: number; }
3.3 Type 独有能力
// 联合类型 — interface 做不了
type Status = "idle" | "loading" | "success";
// 元组 — interface 做不了
type Point2D = [number, number];
// 函数签名 — type 更简洁
type Callback = (data: string) => void;
// 交叉类型 — 合并多个类型
type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged; // { name: string; age: number }
💡
satisfies是 type 体系的另一个利器——检查类型但不收窄推断。详见模块 2.6。
3.4 Index Signature(索引签名)
interface Config {
[key: string]: string;
}
const env: Config = {
APP_NAME: "MyApp",
VERSION: "1.0",
};
3.5 选择指南
| 场景 | 用 |
|---|---|
| 描述对象/类的形状 | interface — 错误信息更清晰,可扩展 |
| 扩展第三方库类型 | interface — 声明合并 |
| 联合类型、元组 | type — 只有它能做 |
| 函数签名 | type 更简洁,interface 也行 |
| 条件类型 / 映射类型 | type — 只有它能做 |
原则:能用 interface 就用 interface,遇到 interface 做不了的事再用 type。
🪤 模块 3 常见陷阱
- interface 不加
I前缀:interface IUser是 C# 习惯。TS 社区直接用interface User——关键字和上下文已明确表达意图,I是噪音(同模块 1 陷阱 1)。- type 不能声明合并:多文件逐步扩展类型时,interface 的同名合并是一等公民;type 做不到。需要扩展能力时用 interface。
Pick<T, K>的 K 必须存在于 T:传不存在的键名会直接报错;Omit<T, K>却允许传入不存在的键(设计如此——安全侧不同)。
模块 4:泛型
📌 什么时候会踩到:写了个
pluck工具函数,返回类型是any[]——团队里的同事不敢用。TS 泛型的精髓不在于<T>语法本身,而在于keyof+K extends keyof T这个组合,让类型安全从"编译时知道"变成"编译时精确推断"。
4.1 基础泛型函数
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
first([1, 2, 3]); // T 推断为 number
first(["a", "b", "c"]); // T 推断为 string
4.2 泛型约束
interface HasLength { length: number; }
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
where T : IHasLength(C#)→ T extends HasLength(TS)
4.3 泛型类
class Stack<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
peek(): T | undefined { return this.items.at(-1); } // at() 负索引,等价 items[items.length - 1]
}
4.4 keyof — TS 独有的泛型机制
const user = { id: 1, name: "Bob", email: "bob@example.com" };
type UserKeys = keyof typeof user;
// "id" | "name" | "email" ← 联合类型,不是普通 string
4.5 K extends keyof T — 详解
三层递进:
keyof T → 提取 T 所有键名,形成字面量联合类型
K extends keyof T → 约束 K 必须是 T 的键之一
T[K] → 从 T 中取出键 K 对应的值类型
调用时 K 被推断为传入的具体字面量:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
getProperty(user, "name") → K = "name" → T[K] = string
getProperty(user, "id") → K = "id" → T[K] = number
实战——类型安全的 pluck:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(item => item[key]);
}
const users = [
{ id: 1, name: "Alice", active: true },
{ id: 2, name: "Bob", active: false },
];
const names = pluck(users, "name"); // string[]
const ids = pluck(users, "id"); // number[]
// pluck(users, "salary"); // ❌ "salary" 不是 user 的 key
🧠 自己读源码:
Pick<T, K>/Omit<T, K>/Exclude<T, U>等工具类型不是编译器魔法——它们的实现在 TypeScript 自带的lib.es5.d.ts中,每个只有几行。打开看看,你会理解"TS 的类型系统是图灵完备的"不是夸张。
🪤 模块 4 常见陷阱
- 泛型推断可能过于宽松:
first([])→T = never,空数组没有类型信息。给默认值或调用时显式标注first<number>([])。- 不是每个函数都需要泛型:类型参数只出现一次且返回类型与之无关时,泛型多此一举。
function log<T>(x: T): void不如function log(x: unknown): void。- 泛型默认值容易忘:
function create<T>(): T无法推断 T,调用时必须create<User>()。给function create<T = unknown>(): T避免遗忘。
模块 5:内置工具类型
📌 什么时候会踩到:你为"创建任务"和"更新任务"手写了两份几乎一样的接口,然后每次加字段要改三处——TS 内置工具类型正是为此设计的:从一个源类型出发,一行变换出所有变体。C# 里没有对应物,最接近的是手写映射类。
参考类型:
interface Task {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}
5.1 速查表
| 工具类型 | 输入 | 输出 | C# 类比 |
|---|---|---|---|
Partial<T> |
T |
所有属性变可选 | 无直接对应 |
Required<T> |
带可选属性的 T | 所有属性变必选 | 无直接对应 |
Pick<T, K> |
T, "a" | "b" |
拣选部分属性 | SELECT 子句 |
Omit<T, K> |
T, "a" | "b" |
排除部分属性 | 无直接对应 |
Readonly<T> |
T |
所有属性只读 | IReadOnlyList |
Record<K, V> |
"a" | "b", V |
{a:V, b:V} |
枚举→字典 |
ReturnType<T> |
函数类型 | 返回值类型 | 无直接对应 |
Awaited<T> |
Promise<T> |
T |
Task<T> → T |
Exclude<T, U> |
联合类型 | 差集 | LINQ Except |
Extract<T, U> |
联合类型 | 交集 | LINQ Intersect |
NonNullable<T> |
T | null |
T |
! null-forgiving |
📌 这些工具类型你每天都在用——只是没意识到。
Partial<Todo>就是 "UPDATE 的参数",Pick<User, "id" | "name">就是 "SELECT 子句"。
5.2 核心工具示例
// Partial — Update 场景
function updateTask(id: number, patch: Partial<Task>): void {
console.log(`update #${id}:`, patch); // 示意实现
}
updateTask(1, { completed: true }); // 只传需要改的字段
// Pick — 挑字段
type TaskSummary = Pick<Task, "id" | "title">;
// { id: number; title: string }
// Omit — 排除字段
type TaskInput = Omit<Task, "id" | "createdAt">;
// { title: string; completed: boolean } ← 新增时不要 id 和时间戳
// Readonly — 编译期只读
const frozen: Readonly<Task> = { id: 1, title: "a", completed: false, createdAt: new Date() };
// frozen.title = "Changed"; // ❌ 编译报错
// Record — 枚举到映射
type StatusCounts = Record<"todo" | "doing" | "done", number>;
// { todo: number; doing: number; done: number }
// ReturnType — 不提重复定义
function createUser() { return { id: 1, name: "Eve" }; }
type User = ReturnType<typeof createUser>;
// Awaited — 解 Promise
type JustTask = Awaited<Promise<Task>>; // Task, 递归解包
5.3 组合使用
// "Task 的更新接口,只能改 title 和 completed"
type TaskUpdate = Partial<Pick<Task, "title" | "completed">>;
// → { title?: string; completed?: boolean }
// "给前端暴露的只读列表项"
type TaskListItem = Readonly<Pick<Task, "id" | "title" | "completed">>;
// "创建 Task 的入参"
type CreateTaskInput = Omit<Task, "id" | "createdAt">;
核心思想:从一个源类型出发,用工具类型变换出所有变体。源类型是 single source of truth。
5.4 🧬 内幕:Pick/Omit vs Extract/Exclude — 为什么不能统一
这两组工具操作的数据结构根本不同,不可互相替代:
Pick<T, K>: 对象类型 ──→ 挑键名 ──→ 更小的对象类型
{ a: A; b: B } → Pick<T, "a"> → { a: A }
Extract<T, U>: 联合类型 ──→ 筛兼容成员 ──→ 更小的联合类型
A | B | C → Extract<T, A | C> → A | C
Pick<T, K> |
Extract<T, U> |
|
|---|---|---|
| 操作对象 | 对象类型(积类型) | 联合类型(和类型) |
| 筛选依据 | 键名 | 类型兼容性 |
| 类比 | SQL SELECT 选列 |
集合过滤取交集 |
底层是两种代数结构:积类型用 AND 组合("有 a 而且 b"),和类型用 OR 组合("是 A 或者 B")。不能统一,也不该统一。
🧠 自己读源码:
Pick/Exclude/ReturnType的实现都在lib.es5.d.ts中。type Pick<T, K extends keyof T> = { [P in K]: T[P] }——这就是全部。
🪤 模块 5 常见陷阱
Pick<T, K>的 K 不存在→报错;Omit<T, K>的 K 不存在→不报错。"去掉一个本来就没有的属性"不算错误,设计如此(同模块 3 陷阱 3)。Partial<T>是浅层的:Partial<{ a: { b: number } }>→a变可选,但a.b仍是必填。深层 partial 需要递归 mapped type。ReturnType取重载函数的返回类型时取最后一个签名。需要特定重载的返回类型,用条件类型推断。
模块 6:函数 — 高级模式
📌 什么时候会踩到:一个函数,不同参数返回不同类型——C# 写多个重载,TS 只要多个签名 + 一个实现。但实现签名不对外暴露——编译器只认重载签名,实现内部你需要自己判断参数。
6.1 函数重载(Overloads)
C# 是多个独立实现;TS 是多个签名 + 一个实现:
// 两个重载签名(对外可见)
function greet(name: string): string;
function greet(name: string, formal: boolean): string;
// 实现签名(对外不可见,必须兼容所有重载签名)
function greet(name: string, formal?: boolean): string {
const prefix = formal ? "Dear" : "Hi";
return `${prefix} ${name}`;
}
greet("Ada"); // "Hi Ada"
greet("Ada", true); // "Dear Ada"
💡 参数类型不同但处理逻辑相同时,用联合类型比重载更简洁。详见模块 2.1。
6.2 剩余参数与解构(Rest Parameters & Destructuring)
// 剩余参数 — 等价 C# params
function sum(...nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
// 参数解构 + 类型注解
function describe({ name, age }: { name: string; age: number }): string {
return `${name} is ${age}`;
}
6.3 泛型回调
function map<T, U>(arr: T[], fn: (item: T, index: number) => U): U[] {
const result: U[] = [];
for (let i = 0; i < arr.length; i++) {
result.push(fn(arr[i], i));
}
return result;
}
const squared = map([1, 2, 3], n => n * n); // [1, 4, 9]
const labels = map([1, 2, 3], n => `#${n}`); // ["#1", "#2", "#3"]
6.4 this 类型 — 链式调用
class Builder {
private _name = "";
setName(name: string): this { // ← this 而非 Builder
this._name = name;
return this;
}
build(): string {
return `Built: ${this._name}`;
}
}
new Builder().setName("Ada").build(); // "Built: Ada"
返回 this 而非 Builder 的好处:子类链式调用不断链。
6.5 可选参数与默认值
function log(msg: string, level?: string): void {
console.log(`[${level ?? "INFO"}] ${msg}`);
}
function connect(host: string, port = 8080): string {
return `${host}:${port}`; // port 有默认值,自动变可选
}
6.6 重载 vs 联合类型 — 选择
用联合类型:参数类型不同但处理逻辑基本相同
function format(value: string | number): string { return String(value); }
用函数重载:参数类型改变导致返回类型改变
const store = new Map<string, string>(); // 模拟配置存储
function getValue(key: string): string | undefined;
function getValue(key: string, defaultValue: string): string;
function getValue(key: string, defaultValue?: string): string | undefined {
const v = store.get(key); // string | undefined
return v ?? defaultValue; // 有值用值,没有用默认值
}
const a = getValue("theme"); // string | undefined
const b = getValue("theme", "dark"); // string —— TS 知道不会 undefined
🪤 模块 6 常见陷阱
- 重载实现签名不对外暴露:实现签名内部必须自己做判断——TS 不会帮你收窄参数类型。调用方看到的只有重载签名。
- 箭头函数不支持重载语法:
const greet = (name: string): string => ...不能声明多个重载。替代:用联合类型收窄,或改function声明。this作为参数是伪参数:独立函数中this仅用于声明this的类型,不影响实际调用参数列表。
模块 7:async/await & Promise
📌 什么时候会踩到:
Task<T>→Promise<T>,语法一样,坑不一样。catch 变量是unknown、没有CancellationToken、await可以接同步值——三个差异足以让 C# 开发者的第一个 TS 异步调用翻车。
7.1 Promise = Task
| C# | TypeScript |
|---|---|
Task<T> |
Promise<T> |
Task.WhenAll |
Promise.all |
Task.WhenAny |
Promise.race(不完全等价,见下) |
Task.FromResult(x) |
Promise.resolve(x) |
Task.FromException(e) |
Promise.reject(e) |
⚠️
Task.WhenAny→Promise.race不完全等价:WhenAny 永不抛异常(返回先完成的任务),
而race在第一个 settle 的 promise 是 rejection 时整个 reject。需要"取第一个成功"时用
Promise.any(ES2021,等价"WhenAny + 检查 IsFaulted")。
// 把 setTimeout 封装成 Promise——避免在业务函数里手写 Promise 构造器
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
async function fetchUser(id: number): Promise<{ id: number; name: string }> {
await delay(100);
return { id, name: `User-${id}` };
}
7.2 async/await — 语法相同
async function main(): Promise<void> {
const user = await fetchUser(1);
// Promise.all = Task.WhenAll
const [u1, u2] = await Promise.all([fetchUser(2), fetchUser(3)]);
}
7.3 错误处理
try {
await Promise.reject(new Error("网络超时"));
} catch (err) {
// err 是 unknown——用 instanceof 收窄后访问(见 2.3 / 2.8),比 as 断言更安全
if (err instanceof Error) {
console.log(err.message);
}
}
和 C# 的关键差异:catch 只有一个变量,类型永远是 unknown。因为 JS 可以 throw 任何值,没有类型保证。
⚡ 深入:any vs unknown
C# 中 catch 变量类型总是 Exception。TS 里是 unknown——为什么不是 any?这涉及 TS 类型系统的一个重要设计选择:
any |
unknown |
|
|---|---|---|
| 可以赋值给任何类型 | ✅ | ❌ — 必须先收窄 |
| 可以访问任意属性 | ✅ | ❌ — 编译报错 |
| 使用前提 | 放弃类型检查 | 类型守卫后可用 |
| 何时用 | JS→TS 迁移的临时态 | 不信任类型假设的场景 |
let val: unknown = getSomeValue();
// ❌ 不能直接用
// val.toUpperCase();
// ✅ 必须收窄
if (typeof val === "string") {
val.toUpperCase(); // OK — TS 收窄为 string
}
核心原则:unknown = "我承认不知道,所以不瞎猜"。any = "我放弃了,别再管我"。如果你不能确定类型,用 unknown 而不是 any——至少编译器会逼你在使用前验证。
7.4 await 同步值也合法
const x = await 42; // ✅ 等价于 Promise.resolve(42)
C# 里 await 42 会报错——只能 await Task。TS 更宽松。
7.5 并行执行
// ❌ 串行——没必要
const a = await fetchUser(1);
const b = await fetchUser(2);
// ✅ 并行
const [a, b] = await Promise.all([fetchUser(1), fetchUser(2)]);
7.6 和 C# 的三个实际差异
- 没有
ConfigureAwait(false)— JS 是单线程事件循环,不存在线程切换问题 - 没有
CancellationToken— 替代方案是AbortController+signal - 没有
async void— TS 里 async 函数始终返回Promise<void>
7.7 常见陷阱
// ❌ 忘记 await——user 是 Promise<User>,不是 User
const user = fetchUser(1);
console.log(user.name); // ❌ TS2339: Property 'name' does not exist on type 'Promise<...>'
// 访问 Promise 上不存在的属性,TS 直接编译报错——类型系统帮你拦住了
// ✅
const user = await fetchUser(1);
console.log(user.name); // "User-1"
// 真正抓不住的场景——floating promise:调用了但完全不用返回值
fetchUser(1); // 类型检查通过,错误被静默吞掉
TS 编译器默认不报 floating promise——需要 ESLint 规则 @typescript-eslint/no-floating-promises 检测。
🪤 模块 7 常见陷阱
Promise.all一个失败全部失败:Promise.all([a, b, c])中任意一个 reject,整个就 reject——不等其他两个完成。需部分容错时用Promise.allSettled,它会等所有 promise 敲定(无论成功/失败),结果中有status: "fulfilled" | "rejected"。- for 循环里 await 导致串行:
for (const id of ids) { await fetch(id); }是一次一个。要并行应await Promise.all(ids.map(fetch))——但有并发上限时需用信号量控制。
模块 8:模块系统 & 实战 HttpClient
📌 什么时候会踩到:C# 有
using+namespace,TS 是import+ 文件即模块。这个模块用 discriminated union 替代 try/catch,把前面 7 个模块的类型概念全部串成一个实战 HttpClient——看完你会理解 TS 的完整工具箱如何协同工作。
8.1 ES Module
// ═══ math.ts:导出 ═══
export function add(a: number, b: number): number { return a + b; }
export const PI = 3.14159;
export interface Config { port: number; host: string; }
export default class Calculator { /* ... */ } // 每个文件最多一个 default
// ═══ 使用方:导入 ═══
import { add, PI } from "./math"; // 命名导入
import Calc from "./math"; // 默认导入,名字随意
import * as mathUtils from "./math"; // 全部导入(避免遮蔽全局 Math)
import type { Config } from "./math"; // 只导入类型(编译后消失)
| C# | TypeScript |
|---|---|
using System; |
无全局命名空间 |
using MyLib; |
import { X } from "./myLib" |
namespace |
文件即模块 |
internal |
不 export 的就是 private to module |
8.2 实战:类型安全的 HttpClient
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
interface RequestOptions {
method: HttpMethod;
headers?: Record<string, string>;
body?: unknown;
}
interface ApiError {
status: number;
message: string;
}
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: ApiError };
async function request<T>(url: string, options: RequestOptions): Promise<ApiResult<T>> {
try {
const response = await fetch(url, {
method: options.method,
headers: options.body ? { "Content-Type": "application/json", ...options.headers } : options.headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
return { ok: false, error: { status: response.status, message: response.statusText } };
}
const data = await response.json() as T;
return { ok: true, data };
} catch (err) {
return { ok: false, error: { status: 0, message: String(err) } };
}
}
function httpGet<T>(url: string) {
return request<T>(url, { method: "GET" });
}
function httpPost<T>(url: string, body: unknown) {
return request<T>(url, { method: "POST", body });
}
// ═══ 使用 ═══
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
async function example(): Promise<void> {
const result = await httpGet<Todo>("https://jsonplaceholder.typicode.com/todos/1");
if (result.ok) {
console.log(`Todo #${result.data.id}: "${result.data.title}"`);
} else {
console.log("Failed:", result.error.message);
}
}
这个 HttpClient 用到的概念:泛型、联合类型、字面量类型、discriminated union、类型收窄、Record<K,V>、async/await、可选属性。
🔧 工程扩展:请求取消
模块 7 提到 TS 没有 CancellationToken——替代方案是 AbortController。给 HttpClient 加取消支持只需两处改动:
async function request<T>(
url: string,
options: RequestOptions,
signal?: AbortSignal
): Promise<ApiResult<T>> {
try {
const response = await fetch(url, {
method: options.method,
headers: options.body ? { "Content-Type": "application/json", ...options.headers } : options.headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal, // ← 传入 signal,fetch 内部处理取消
});
if (!response.ok) {
return { ok: false, error: { status: response.status, message: response.statusText } };
}
const data = await response.json() as T;
return { ok: true, data };
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return { ok: false, error: { status: 0, message: "请求已取消" } };
}
return { ok: false, error: { status: 0, message: String(err) } };
}
}
🔧 工程扩展:重试机制
async function httpGetWithRetry<T>(
url: string,
maxRetries = 3,
baseDelayMs = 500,
): Promise<ApiResult<T>> {
for (let attempt = 0; ; attempt++) {
const result = await httpGet<T>(url);
if (result.ok) return result;
// 4xx 是客户端错误,重试无意义——直接返回
if (result.error.status >= 400 && result.error.status < 500) return result;
if (attempt === maxRetries) return result;
// 只对网络错误(status 0)和 5xx 做 exponential backoff: 500ms → 1000ms → 2000ms
await new Promise(r => setTimeout(r, baseDelayMs * 2 ** attempt));
}
}
8.3 和 C# HttpClient 对比
// C# — 异常驱动
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var todo = await response.Content.ReadFromJsonAsync<Todo>();
// TS — discriminated union 驱动
const result = await httpGet<Todo>(url);
if (result.ok) {
const todo = result.data; // Todo
}
两种模式都可以在 TS 里实现,但 union 模式下调用方不需要 try/catch——request 内部已经把异常折叠进 ApiResult(见 8.2)。
💡 扩展阅读
- 为什么用 fetch 而不是 axios:Node.js 18+ 内置了
fetch(基于 undici)。目标环境 Node 24 时不需要额外依赖——fetch已是标准库。axios 的优势在拦截器体系和浏览器兼容,现代 Node 项目 fetch 通常够用。- 类型安全的 WebSocket 封装:类似
ApiResult<T>,可以用 discriminated union 封装onmessage:{ kind: "data"; payload: T } | { kind: "error"; reason: string } | { kind: "closed" }。
🪤 模块 8 常见陷阱
response.json()不验证:await response.json() as T只是告诉 TS "信我,它是 T"——运行时可以是任何形状。生产环境加运行时校验(zod、io-ts 等)。import type编译后消失:import type { Config } from "./types"→ JS 中 0 行代码。如果 Config 在运行时需要(如作为 class 父类或值传递),用普通import。
TypeScript vs C# 速查表
| 概念 | C# | TypeScript |
|---|---|---|
| 类型系统 | 名义类型 | 结构类型 |
| 类型时机 | 编译后保留在 IL | 编译后完全擦除 |
| 空值 | null |
null + undefined |
| 泛型 | <T> / where T : X |
<T> / T extends X |
| 接口 | interface IFoo |
interface Foo(可声明合并) |
| 类继承 | class A : B |
class A extends B |
| 属性 | { get; set; } |
直接声明字段 + get/set 可选 |
| 枚举 | 仅整数 | 数字 + 字符串 |
| 联合类型 | 无 | A | B |
| 重载 | 多个实现 | 多个签名 + 一个实现 |
| 异步 | Task<T> + async/await |
Promise<T> + async/await |
| 取消 | CancellationToken |
AbortController |
| 反射 | GetType() / 反射 API |
无(类型擦除) |
| 命名空间 | namespace |
文件即模块 |
| 访问修饰符 | public/private/protected/internal |
public/private/protected + #(JS 硬私有) |
| 元组 | ValueTuple / (int,string) |
[number, string] |
附录:类型体操性能提示
TS 的类型系统是图灵完备的——这意味着你可以写出"永不终止"的类型计算。以下提示帮你在类型安全与编辑器响应速度之间取得平衡:
-
递归条件类型谨慎使用:深度递归(如
DeepPartial<T>递归到嵌套对象的每一层)在深层对象上会导致类型计算时间暴增。必要时限制递归深度,或用interface显式声明层级。 -
大型联合类型 × mapped type = 💥:
type X = { [K in "a" | "b" | ... | "z"]: SomeHeavyType }——当联合类型成员过多且每个映射值类型很重时,IDE 的 IntelliSense 会明显卡顿。分拆为多个小类型。 -
优先用简单的
interface,而非炫技的类型体操:interface User { name: string }的类型计算成本远低于type User = { [K in keyof SomeMappedType]: ... }。类型体操有它的场景,但不要为了"看起来很厉害"而牺牲可维护性和性能。
原则:明确的类型 > 推导的类型,简单的类型 > 复杂的类型。六个月后维护它的人(包括你自己)会感谢你。
文章声明
内容准确性: 我会尽力确保所分享信息的准确性和可靠性,但由于个人知识有限,难免会有疏漏或错误。如果您在阅读过程中发现任何问题,请不吝赐教,我将及时更正。
AI: 文章部分内容参考了大语言模型生成的内容。
posted on 2026-07-30 18:05 wubing7755 阅读(28) 评论(0) 收藏 举报
浙公网安备 33010602011771号