TypeScript 高级类型实战笔记

说到 TS 这个东西,它和 JS 通俗来讲,就是"写了类型的 JS"。但你的类型真的写明白了吗?

我相信大多数人的类型定义都还停留在静态层面:需要什么类型就定义什么类型;如果考虑到复用性,顶多再用上联合类型和交叉类型。事实上,如果你和我一样只做到了这一步,那我们都还只是这门语言的初级使用者。

TS 的高级类型,才是 TypeScript 真正的魅力所在。 不掌握它,你很难说自己是 TS 高手。而高级类型也确实难,学起来大有一种重新学一门语言的感觉。

下面就由我带领大家来进行学习吧!

💡 说明:以下内容是我让 AI 教学后,自己整理总结的实战笔记,旨在把晦涩的概念转化为可落地的代码。


一、TS 泛型经典

typescript 复制代码
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    if (key in obj) {
      result[key] = obj[key];
    }
  }
  return result;
}

二、条件类型与 infer 关键字解包

核心目标 :理解 TS 如何像"拆快递"一样从复杂类型中提取出你想要的部分。infer 是 TS 高级类型中最强大的工具,没有之一。

练习题:实现 UnwrapPromise<T>

在异步编程中,我们经常需要获取一个 Promise 包裹的真实返回值类型。

typescript 复制代码
// 你的任务:补全 UnwrapPromise
type UnwrapPromise<T> = /* 在这里写你的代码 */;

// 🧪 测试用例(不要修改,用来验证你的答案)
type T1 = UnwrapPromise<Promise<string>>;         // 期望: string
type T2 = UnwrapPromise<Promise<{ id: number }>>; // 期望: { id: number }
type T3 = UnwrapPromise<number>;                  // 期望: number (非 Promise 时返回原类型)

// ✅ 答案
type UnwrapPromise<T> = T extends Promise<infer X> ? X : T;

封装请求函数

假设后端把 /api/user 的返回结构从 { name: string } 改成了 { nickname: string, age: number }

typescript 复制代码
// ========== 基础封装 ==========
interface User { nickname: string; age: number }
async function request<T>(url: string): Promise<T> { /* ... */ return {} as T }
const getUser = () => request<User>('/api/user');

// ========== 核心用法:反向提取类型 ==========
// 以前你要手写: type UserData = User;
// 现在你直接从函数里"抠"出来,函数变了,这里自动变
type UserData = UnwrapPromise<ReturnType<typeof getUser>>;

// ========== 实际业务中使用 ==========
function renderUserCard() {
  // ✅ 这里的 user 自动拥有完整的类型提示
  const user: UserData = { nickname: 'Tom', age: 25 };

  console.log(user.nickname); // ✅ 有智能提示
  console.log(user.name);     // ❌ TS 直接报错,因为接口已经改了

  // ❌ 如果你之前手写了 type UserData = { name: string }
  // 这里不会报错,但运行时就会出 bug!
}

核心价值UserData 永远和 getUser 的真实返回值绑定。你只需要维护函数,类型会自动跟着变。

写通用 Hook

这个场景是 infer 最值钱的地方。假设你要写一个通用的数据请求 Hook:

typescript 复制代码
// ========== 通用 Hook 实现 ==========
function useAsync<T extends (...args: any[]) => Promise<any>>(fn: T) {
  // 用 infer 自动提取 fn 返回 Promise 里的真实数据类型
  type DataType = UnwrapPromise<ReturnType<T>>;

  const [data, setData] = useState<DataType | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fn().then(res => {
      setData(res); // ✅ res 自动就是 DataType,不需要 any
      setLoading(false);
    });
  }, []);

  return { data, loading };
}

// ========== 实际业务中使用 ==========
const fetchOrders = () => request<{ id: number; title: string }[]>('/orders');

function OrderList() {
  // ✅ 调用时完全不需要传泛型,TS 自动推导
  const { data, loading } = useAsync(fetchOrders);

  if (loading) return <div>加载中...</div>;

  // ✅ data 自动推导为 { id: number; title: string }[] | null
  // 输入 . 就有完整的智能提示
  return data?.map(order => <div key={order.id}>{order.title}</div>);
}

如果没有 UnwrapPromise:你就必须让用户每次手动写 useAsync<{id:number,title:string}[]>(fetchOrders),或者干脆把 data 定义为 any,彻底失去类型安全。

组合多个接口结果

当你需要一个页面同时展示用户信息和订单列表,需要合并成一个新类型:

typescript 复制代码
// ========== 两个独立的接口函数 ==========
const fetchUser = () => request<User>('/user');
const fetchOrders = () => request<{ id: number; title: string }[]>('/orders');

// ========== 自动组合类型 ==========
type DashboardData = {
  user: UnwrapPromise<ReturnType<typeof fetchUser>>;
  orders: UnwrapPromise<ReturnType<typeof fetchOrders>>;
};

// ========== 实际业务中使用 ==========
function DashboardPage() {
  const [pageData, setPageData] = useState<DashboardData | null>(null);

  useEffect(() => {
    Promise.all([fetchUser(), fetchOrders()]).then(([user, orders]) => {
      // ✅ 赋值时 TS 会自动校验结构是否匹配
      setPageData({ user, orders });
    });
  }, []);

  // ✅ pageData.user 和 pageData.orders 都有完整类型提示
  return <div>{pageData?.user.nickname}</div>;
}

如果没有 UnwrapPromise:你必须手动去翻 User 和订单的类型定义,再手写一遍 DashboardData。一旦某个接口返回结构变了,你还得记得回来改 DashboardData,极易遗漏。

补充:TS 官方内置工具 Awaited<T>

因为我们这种"提取 async 函数真实返回值"的需求太常见了,TS 4.5+ 官方直接提供了一个内置关键词叫 Awaited<T>,它的作用和我们自己写的 UnwrapPromise 一模一样:

typescript 复制代码
// 以前我们自己造轮子(两步):
type Result1 = UnwrapPromise<ReturnType<typeof getUser>>;

// 现在用官方内置工具(更简洁):
type Result2 = Awaited<ReturnType<typeof getUser>>;

// Result1 和 Result2 完全等价,都是 User

三、映射类型 + 键重映射(as 子句)

假设后端返回的用户数据是下划线命名,但前端代码规范要求驼峰命名。请补全下面的工具类型,让它能自动完成转换:

typescript 复制代码
// 输入类型(后端返回)
interface UserRaw {
  user_name: string;
  is_active: boolean;
  login_count: number;
}

// 👇 请补全这个工具类型,使得 UserCamel 的结果为:
// { userName: string; isActive: boolean; loginCount: number }
type ToCamelCase<T> = {
  // ❓ 在这里填写你的答案
};

type UserCamel = ToCamelCase<UserRaw>;

映射类型长什么样?(纯复习)

如果你要把一个对象类型的每个 value 都变成 string,你会这么写:

typescript 复制代码
type AllString<T> = {
  [K in keyof T]: string;  // K 就是 user_name | is_active | login_count
};

✅ 这一步你应该很熟悉,[K in keyof T] 就是遍历对象所有 key 的固定语法。

as 子句是干嘛的?(本关新东西)

默认情况下,映射出来的新类型 key 和原来一模一样。但 TS 4.1 引入了 as 子句,允许你在遍历时把 key 换成别的名字:

typescript 复制代码
type AddPrefix<T> = {
  [K in keyof T as `prefix_${K}`]: T[K];
  //              ↑ 这里!key 从 user_name 变成了 prefix_user_name
  //                冒号右边的 T[K] 还是原来的 value 类型,没变
};

💡 关键记忆点as 只改 key 的名字,不改 value 的类型。value 类型永远由冒号右边决定。

字符串模板里的"大小写转换"(最后一块拼图)

TS 内置了 4 个字符串操作符,可以直接在模板字面量里用:

typescript 复制代码
type Test = `hello${Capitalize<'world'>}`;   // → "helloWorld"
type Test2 = `${Uppercase<'abc'>}`;          // → "ABC"
type Test3 = `${Lowercase<'ABC'>}`;          // → "abc"

最终答案

typescript 复制代码
// ✅ 最终正确版
type ToCamelCase<T> = {
  [K in keyof T as `${Capitalize<string & K>}`]: T[K];
};

// 结果: { UserName: string; IsActive: boolean; LoginCount: number } ✅

四、模板字面量类型(字符串的"正则表达式")

核心概念:infer 在字符串里的用法

在前面我们学过 infer 可以从泛型里提取类型。现在它还能从字符串模板里提取子串!

typescript 复制代码
// 语法:用 infer 占位,TS 会自动匹配并捕获内容
type ExtractRouteParam<T> =
  T extends `/api/users/${infer Id}/posts/${infer PostId}`
    ? { id: Id; postId: PostId }  // ✅ 匹配成功,Id="string", PostId="string"
    : never;                       // ❌ 不匹配

type Test = ExtractRouteParam<"/api/users/123/posts/456">;
// → { id: "123"; postId: "456" }  🎉 字符串被精确解析了!

⚠️ 关键区别 :这里的 "123" 是字符串字面量类型,不是普通的 string。TS 记住了确切的值。

答案解析

第一步:单个字符串转换(核心辅助类型)
typescript 复制代码
type CamelCase<S extends string> =
  // 1. 用 infer 把字符串从第一个 _ 处拆成两半
  S extends `${infer Head}_${infer Tail}`
    // 2. Head 保持原样 + Tail 首字母大写 + 递归处理剩余的下划线
    ? `${Head}${Capitalize<CamelCase<Tail>>}`
    // 3. 没有下划线了,直接返回原字符串(递归终止条件)
    : S;
第二步:塞进映射类型
typescript 复制代码
type ToCamelCase<T> = {
  [K in keyof T as K extends string
    ? K extends `${infer Head}_${infer Tail}`
      ? `${Head}${Capitalize<CamelCase<Tail>>}`
      : never
    : never]: T[K];
};

五、联合类型分发(Distributive Conditional Types)

一句话核心

当条件类型的左边是一个"裸的"联合类型时,TS 会自动把它拆开,逐个检查,最后再把结果合并回来。

typescript 复制代码
// A | B extends X ? Y : Z
// ↓ TS 自动拆成:
(A extends X ? Y : Z) | (B extends X ? Y : Z)
为什么这很重要?

没有分发,你没法对联合类型的每一项做独立处理。有了分发,你就能写出 ExtractExcludeNonNullable 这些内置工具类型的底层原理。

唯一的坑:"裸"是什么意思?
typescript 复制代码
// ✅ 裸的 → 会分发
type IsString<T> = T extends string ? true : false;
type Test1 = IsString<"a" | 1>;
// → ("a" extends string ? true : false) | (1 extends string ? true : false)
// → true | false

// ❌ 被包了一层元组 → 不会分发!
type IsString2<T> = [T] extends [string] ? true : false;
type Test2 = IsString2<"a" | 1>;
// → ["a" | 1] extends [string] ? true : false
// → false (整个联合类型作为一个整体去比较)

📌 记忆口诀 :想让联合类型逐项处理,就让它"裸奔";想阻止分发,就用 [] 包起来。

手写 MyExclude

TS 内置了 Exclude<T, U>(从 T 中剔除属于 U 的类型)。现在请你用刚学的分发机制自己实现:

typescript 复制代码
// 期望:MyExclude<"a" | "b" | "c", "a" | "c"> → "b"
type MyExclude<T, U> = /* ❓❓❓ */;

答案

typescript 复制代码
type MyExclude<T, U> = T extends U ? never : T

// 过程:
type Result = MyExclude<"a" | "b" | "c", "a" | "c">;

// Step 1: T 是裸联合类型,自动分发拆成三项:
("a" extends "a"|"c" ? never : "a")   // → never ("a" 在 U 里,踢掉)
| ("b" extends "a"|"c" ? never : "b") // → "b"   ("b" 不在 U 里,保留)
| ("c" extends "a"|"c" ? never : "c") // → never ("c" 在 U 里,踢掉)

// Step 2: 合并结果,never 自动消失:
never | "b" | never → "b" ✅

六、类型安全的事件总线 (EventEmitter)

本关目标

实现一个 TypedEventEmitter,使得:

  1. 定义事件名和对应的参数类型
  2. on("eventName", callback) 时,TS 自动推断 callback 的参数类型
  3. 传入不存在的事件名或错误的参数时,直接报错

这就是前端/Node.js 开发中"类型体操"最经典的落地场景。

🧩 第一步:定义事件映射表

typescript 复制代码
// 所有事件的类型定义(实际项目中可能来自后端接口或协议文档)
interface EventMap {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "data:update": { id: number; payload: unknown };
}

🧩 第二步:核心类型推导(综合运用前四关知识)

typescript 复制代码
// ✅ 综合运用了:映射类型 + keyof + 索引访问
type EventHandler<T extends keyof EventMap> =
  (payload: EventMap[T]) => void;

// 💡 理解这行:
// 当 T = "user:login" 时
// → EventMap["user:login"]
// → { userId: string; timestamp: number }
// → 最终得到: (payload: { userId: string; timestamp: number }) => void

🧩 第三步:完整的 EventEmitter 类

typescript 复制代码
class TypedEventEmitter {
  private handlers: Partial<{
    [K in keyof EventMap]: EventHandler<K>[];
  }> = {};

  // ✅ on 方法:事件名限定为 EventMap 的 key,callback 参数自动推断
  on<K extends keyof EventMap>(event: K, handler: EventHandler<K>): void {
    if (!this.handlers[event]) {
      this.handlers[event] = [];
    }
    this.handlers[event]!.push(handler);
  }

  // ✅ emit 方法:第二个参数的类型由第一个参数决定!
  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
    this.handlers[event]?.forEach(h => h(payload));
  }
}

✅ 见证奇迹的时刻

typescript 复制代码
const emitter = new TypedEventEmitter();

// 🟢 完美推断:handler 的参数自动知道是 { userId, timestamp }
emitter.on("user:login", (payload) => {
  console.log(payload.userId);    // ✅ string
  console.log(payload.timestamp); // ✅ number
});

// 🔴 错误1:事件名不存在
emitter.on("user:delete", () => {});
// ❌ Argument of type '"user:delete"' is not assignable...

// 🔴 错误2:参数类型不对
emitter.emit("user:logout", { userId: 123 });
// ❌ Type 'number' is not assignable to type 'string'

// 🔴 错误3:缺少必填字段
emitter.emit("user:login", { userId: "abc" });
// ❌ Property 'timestamp' is missing...

💡 这就是类型安全的终极意义:把运行时的 bug 提前到编码阶段消灭。你不需要记任何 API 文档,IDE 的自动补全就是活文档。


七、TS 内置工具类型全家桶

核心基础篇(每天都在用)

  • Partial<T> / Required<T> / Readonly<T>
  • Pick<T, K> / Omit<T, K> / Record<K, T>

联合与条件篇(刚刚学过的原生版)

  • Extract<T, U> / Exclude<T, U>
  • NonNullable<T> / Awaited<T>

函数与高级推导篇(框架源码常客)

  • Parameters<T> / ReturnType<T>
  • ConstructorParameters<T> / InstanceType<T>
  • ThisParameterType<T>