说到 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)
为什么这很重要?
没有分发,你没法对联合类型的每一项做独立处理。有了分发,你就能写出 Extract、Exclude、NonNullable 这些内置工具类型的底层原理。
唯一的坑:"裸"是什么意思?
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,使得:
- 定义事件名和对应的参数类型
on("eventName", callback)时,TS 自动推断 callback 的参数类型- 传入不存在的事件名或错误的参数时,直接报错
这就是前端/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>