AIGC标识 TypeScript 知识点

TypeScript 知识点

概述:面向 C# 开发者的渐进式学习笔记——以案例为主线:每个知识点先用最小可运行的代码演示"是什么",再用 C# 类比说明"怎么理解",最后用陷阱清单指出"哪里会踩坑"。
环境:面向 TypeScript 5.x(正文引用 TS 4.9 的 satisfies、TS 5.7 的 ES2024 target)。
符号约定:❌ 标记的代码是错误写法,用于演示编译错误;✅ 是正确写法;⚠️ 是需要注意的差异。
阅读方式:第一次学习按模块 0 → 8 顺序读;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 是纸老虎——类型系统有大量暗门,nullany 可以到处流窜。

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 只决定语法降级级别,越低输出越保守、越兼容。
常见错误是 modulemoduleResolution 不匹配: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 常见陷阱

  1. 前缀 I 不是 TS 惯例interface IUser 是 C# 习惯——TS 社区直接用 interface User。声明合并时 I 前缀反而是噪音。
  2. interface 编译后消失,不能 newinterface User { name: string } 编译为 JS 后是 0 行代码。它是纯编译时契约,运行时不存在。
  3. const 声明 ≠ 不可变对象const arr = [1,2,3] 不能 arr = [],但可以 arr.push(4)(见 1.5)。编译期只读用 as const(见模块 2.5)——它只改类型,运行时对象依然可变;运行时真正冻结只有 Object.freeze()

模块 2:类型系统核心操作——联合类型、字面量与类型安全

📌 什么时候会踩到:你在 C# 里写过几个只有名字不同的 enumRequestStatusResponseStatusOrderStatus……本质都是整数别名。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.Active0 "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 陷阱

  1. 数字枚举的反向映射是运行时对象Status[0] 能查是因为编译产物里有一个双向对象——会增大 bundle。不需要反向查询时用字符串枚举或联合类型。
  2. const enum 与现代构建链不兼容:跨文件内联只有 tsc 能做;Babel / esbuild / Vite 等转译器按单文件工作(等价 isolatedModules 语义),const enum 会失去内联甚至行为不一致。现代代码库通常避免 const enum
  3. 字符串枚举没有反向映射,也不接受字面量赋值Color["#f00"]const c: Color = "#f00" 都报错——和数字枚举行为不对称。
  4. 混合枚举(数字+字符串成员):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 常见陷阱

  1. typeof 只能区分 JS 基本类型typeof [] === "object"(不是 "array")。类型守卫中 typeof 的 8 种结果都有意义:
    "string" / "number" / "boolean" / "symbol" / "undefined" / "function" / "bigint" / "object"
    注意 typeof null === "object"——收窄时 null 要单独处理。细化判断用 Array.isArrayinstanceof
  2. instanceofinterface 无效:interface 编译后消失——运行时不存在这个类型。instanceof 只能用于 class
  3. 字面量联合类型不是 enum 的完全替代:需要运行时遍历所有值(生成下拉选项等)时,仍需 enum 或常量数组 + as const(见模块 1.6;enum 的完整讨论见模块 2.7)。

→ 本模块的 Result<T> discriminated union 模式,在模块 8ApiResult<T> 中实战再现。


模块 3:Interface vs Type

📌 什么时候会踩到:TS 有两个几乎等价的东西来描述对象——interfacetype。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 常见陷阱

  1. interface 不加 I 前缀interface IUser 是 C# 习惯。TS 社区直接用 interface User——关键字和上下文已明确表达意图,I 是噪音(同模块 1 陷阱 1)。
  2. type 不能声明合并:多文件逐步扩展类型时,interface 的同名合并是一等公民;type 做不到。需要扩展能力时用 interface。
  3. 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 常见陷阱

  1. 泛型推断可能过于宽松first([])T = never,空数组没有类型信息。给默认值或调用时显式标注 first<number>([])
  2. 不是每个函数都需要泛型:类型参数只出现一次且返回类型与之无关时,泛型多此一举。function log<T>(x: T): void 不如 function log(x: unknown): void
  3. 泛型默认值容易忘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 常见陷阱

  1. Pick<T, K> 的 K 不存在→报错;Omit<T, K> 的 K 不存在→不报错。"去掉一个本来就没有的属性"不算错误,设计如此(同模块 3 陷阱 3)。
  2. Partial<T> 是浅层的Partial<{ a: { b: number } }>a 变可选,但 a.b 仍是必填。深层 partial 需要递归 mapped type。
  3. 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 常见陷阱

  1. 重载实现签名不对外暴露:实现签名内部必须自己做判断——TS 不会帮你收窄参数类型。调用方看到的只有重载签名。
  2. 箭头函数不支持重载语法const greet = (name: string): string => ... 不能声明多个重载。替代:用联合类型收窄,或改 function 声明。
  3. this 作为参数是伪参数:独立函数中 this 仅用于声明 this 的类型,不影响实际调用参数列表。

模块 7:async/await & Promise

📌 什么时候会踩到Task<T>Promise<T>,语法一样,坑不一样。catch 变量是 unknown、没有 CancellationTokenawait 可以接同步值——三个差异足以让 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.WhenAnyPromise.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# 的三个实际差异

  1. 没有 ConfigureAwait(false) — JS 是单线程事件循环,不存在线程切换问题
  2. 没有 CancellationToken — 替代方案是 AbortController + signal
  3. 没有 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 常见陷阱

  1. Promise.all 一个失败全部失败Promise.all([a, b, c]) 中任意一个 reject,整个就 reject——不等其他两个完成。需部分容错时用 Promise.allSettled,它会等所有 promise 敲定(无论成功/失败),结果中有 status: "fulfilled" | "rejected"
  2. 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 常见陷阱

  1. response.json() 不验证await response.json() as T 只是告诉 TS "信我,它是 T"——运行时可以是任何形状。生产环境加运行时校验(zod、io-ts 等)。
  2. 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 的类型系统是图灵完备的——这意味着你可以写出"永不终止"的类型计算。以下提示帮你在类型安全与编辑器响应速度之间取得平衡:

  1. 递归条件类型谨慎使用:深度递归(如 DeepPartial<T> 递归到嵌套对象的每一层)在深层对象上会导致类型计算时间暴增。必要时限制递归深度,或用 interface 显式声明层级。

  2. 大型联合类型 × mapped type = 💥type X = { [K in "a" | "b" | ... | "z"]: SomeHeavyType }——当联合类型成员过多且每个映射值类型很重时,IDE 的 IntelliSense 会明显卡顿。分拆为多个小类型。

  3. 优先用简单的 interface,而非炫技的类型体操interface User { name: string } 的类型计算成本远低于 type User = { [K in keyof SomeMappedType]: ... }。类型体操有它的场景,但不要为了"看起来很厉害"而牺牲可维护性和性能。

原则:明确的类型 > 推导的类型,简单的类型 > 复杂的类型。六个月后维护它的人(包括你自己)会感谢你。

文章声明

内容准确性: 我会尽力确保所分享信息的准确性和可靠性,但由于个人知识有限,难免会有疏漏或错误。如果您在阅读过程中发现任何问题,请不吝赐教,我将及时更正。
AI: 文章部分内容参考了大语言模型生成的内容。

posted on 2026-07-30 18:05  wubing7755  阅读(28)  评论(0)    收藏  举报