!!文章很长最好根据自身能力分段看 !!
如果你已经学过 TypeScript 的这些基础内容:
string
number
boolean
interface
type
enum
泛型
那你大概率会进入一个阶段:
“TypeScript 我好像会了,但为什么看一些成熟项目的类型代码还是看不懂?”
例如你突然看到:
type ValueOf<T> = T[keyof T]
或者:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never
再或者:
type Permission =
`${'user' | 'order'}:${'read' | 'write'}`
第一眼很容易怀疑人生:
TypeScript 怎么还能写成这样?
其实这些所谓的“高级类型”,并没有想象中那么玄学。
它们的核心目的依然只有一个:
让类型根据已有信息自动推导出来,而不是让开发者重复手写。
本文会从最实用的几个高级能力开始讲:
- 泛型
keyoftypeof- 索引访问类型
T[K] - 映射类型
- 条件类型
infer- 联合类型分发
as constsatisfies- 可辨识联合
- 模板字符串类型
- Utility Types
- 函数重载
- Brand Type
看完之后,你会发现 TypeScript 的高级类型,其实是在做一件非常像“编程”的事情:
只不过以前我们是在运行时编程,现在是在编译期对类型编程。
一、先建立一个重要认知:TypeScript 不只是“给变量加类型”
很多人刚学 TS 时,写法基本是:
const age: number = 18
const name: string = 'Tom'
然后写接口:
interface User {
id: number
name: string
age: number
}
这种写法当然没问题。
但这只是 TypeScript 最基础的一层:
给已有数据贴标签。
真正高级的 TypeScript,更像:
根据一个类型,动态计算出另一个类型。
例如:
type User = {
id: number
name: string
age: number
}
我们希望自动得到:
type UserKeys = 'id' | 'name' | 'age'
不想自己写:
type UserKeys =
| 'id'
| 'name'
| 'age'
于是:
type UserKeys = keyof User
这就是类型计算。
可以把 TypeScript 的高级类型想象成:
输入一个类型
↓
进行类型运算
↓
得到新的类型
有点像普通函数:
function add(a, b) {
return a + b
}
只不过 TypeScript 类型系统是在编译阶段运行。
二、泛型:让类型也变成“参数”
高级 TS 的第一道门槛就是泛型。
普通函数:
function getValue(value: string): string {
return value
}
这个函数只能处理字符串。
如果数字也要用:
function getNumber(value: number): number {
return value
}
那就开始重复了。
于是我们想:
能不能让“类型”也像函数参数一样传进去?
可以。
function getValue<T>(value: T): T {
return value
}
这里:
<T>
表示声明一个类型参数。
你可以把:
T
理解成:
暂时不知道是什么类型,等调用的时候再确定。
例如:
const a = getValue('hello')
TS 推断:
T = string
所以:
a
就是:
string
再比如:
const b = getValue(123)
此时:
T = number
所以:
b
就是:
number
这和 JavaScript 函数参数非常像:
function add(a, b) {}
只是泛型传递的是:
类型
三、泛型约束:你不能什么都往里传
假设我们写:
function getLength<T>(value: T) {
return value.length
}
TS 会报错。
为什么?
因为:
T
有可能是:
number
而:
123.length
显然不存在。
所以我们要告诉 TypeScript:
我虽然不知道 T 具体是什么,但它至少必须有 length。
于是:
function getLength<T extends { length: number }>(
value: T
) {
return value.length
}
这里:
extends
不是传统面向对象里的“继承”。
这里更应该理解为:
T 必须满足这个类型。
因此下面都可以:
getLength('hello')
getLength([1, 2, 3])
因为:
string 有 length
array 有 length
但:
getLength(123)
不行。
这就是:
泛型约束。
在项目中你会大量看到:
T extends SomeType
意思基本都是:
T 可以很灵活,但至少得满足这些条件。
四、keyof:把对象的 key 拿出来
这是 TS 高级类型最常用的关键字之一。
假设:
type User = {
id: number
name: string
age: number
}
执行:
type UserKey = keyof User
得到:
'id' | 'name' | 'age'
也就是说:
keyof T
等价于:
获取 T 所有属性名组成的联合类型。
你可以理解为 JavaScript 的:
Object.keys(obj)
但区别在于:
Object.keys
是在运行时拿 key
keyof
是在类型层面拿 key
五、keyof + 泛型:写一个真正安全的 getValue
我们经常需要写:
function getValue(obj, key) {
return obj[key]
}
JavaScript 很简单。
但是 TS 怎么保证:
key
一定存在?
可以这样:
function getValue<T, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key]
}
这段代码非常值得拆开。
首先:
T
代表对象类型。
例如:
const user = {
id: 1,
name: 'Tom'
}
那么:
T
就是:
{
id: number
name: string
}
然后:
keyof T
得到:
'id' | 'name'
所以:
K extends keyof T
意思是:
K 必须是 T 的 key。
因此:
getValue(user, 'name')
正确。
而:
getValue(user, 'password')
直接报错。
因为:
'password'
不属于:
'id' | 'name'
这就是类型系统帮助我们提前发现错误。
六、T[K]:对象类型也可以像对象一样取值
刚才代码里还有:
T[K]
这是:
索引访问类型。
比如:
type User = {
id: number
name: string
}
你可以:
type NameType = User['name']
得到:
string
同理:
type IdType = User['id']
得到:
number
是不是很像 JS?
JavaScript:
user['name']
拿到值。
TypeScript:
User['name']
拿到类型。
所以:
T[K]
意思就是:
获取 T 中 K 属性对应的类型。
七、typeof:别重复定义类型,让 TS 从值推出来
JavaScript 也有 typeof:
typeof 123
得到:
number
TypeScript 也有 typeof,但它可以在类型层面使用。
例如:
const user = {
id: 1,
name: 'Tom',
age: 18
}
如果你再写:
type User = {
id: number
name: string
age: number
}
其实有点重复。
可以直接:
type User = typeof user
于是 TS 自动得到:
type User = {
id: number
name: string
age: number
}
特别适合配置类数据。
例如:
const config = {
port: 3000,
env: 'development',
debug: true
}
直接:
type Config = typeof config
八、typeof + keyof:非常经典的组合
假设:
const roleMap = {
admin: '管理员',
user: '普通用户',
guest: '游客'
}
我们想得到:
'admin' | 'user' | 'guest'
可以:
type Role = keyof typeof roleMap
拆解一下。
首先:
typeof roleMap
得到:
{
admin: string
user: string
guest: string
}
再:
keyof
得到:
'admin' | 'user' | 'guest'
所以:
keyof typeof xxx
是一个非常常见的组合。
九、映射类型:批量修改对象类型
假设:
type User = {
id: number
name: string
age: number
}
现在有一个需求:
把 User 所有字段都变成可选。
最笨的方法:
type OptionalUser = {
id?: number
name?: string
age?: number
}
但字段多了以后非常麻烦。
于是 TypeScript 提供映射类型:
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
这个语法第一眼很怪。
你可以把它读成:
遍历 T 的所有 key
每个 key 叫 K
新类型的 key 也是 K
value 类型还是 T[K]
然后给 key 加一个 ?
假设:
T = User
那么:
keyof T
就是:
'id' | 'name' | 'age'
TypeScript 相当于循环:
K = id
K = name
K = age
最终得到:
{
id?: number
name?: string
age?: number
}
这其实和 JS 的:
array.map()
很像。
所以叫:
Mapped Type,映射类型。
十、自己实现 Readonly
再来一个。
把所有字段变成只读:
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
使用:
type User = {
id: number
name: string
}
type ReadonlyUser = MyReadonly<User>
得到:
{
readonly id: number
readonly name: string
}
你会发现:
Partial<T>
Readonly<T>
这种官方工具类型,本质就是映射类型。
十一、条件类型:类型系统里的 if
JavaScript 里:
const result =
age >= 18
? 'adult'
: 'child'
TypeScript 类型系统里也可以:
type IsString<T> =
T extends string
? true
: false
读法:
如果 T 可以赋值给 string
返回 true
否则
返回 false
例如:
type A = IsString<string>
得到:
true
而:
type B = IsString<number>
得到:
false
所以:
T extends X ? A : B
你可以直接把它理解成:
类型层面的三元运算符
十二、never:类型世界里的“什么都没有”
理解条件类型之前,还得知道:
never
never 表示:
永远不可能出现的值。
比如:
function throwError(): never {
throw new Error()
}
因为这个函数永远不会正常返回。
在高级类型里:
never
经常被用来:
把不需要的类型过滤掉。
例如:
type OnlyString<T> =
T extends string
? T
: never
那么:
type A = OnlyString<string>
得到:
string
而:
type B = OnlyString<number>
得到:
never
十三、条件类型遇到联合类型,会发生什么?
这里是一个很重要的知识点:
条件类型通常会对联合类型进行分发。
比如:
type OnlyString<T> =
T extends string
? T
: never
现在:
type Result =
OnlyString<string | number | boolean>
TypeScript 不会一次判断整个 union。
而是拆开:
string
number
boolean
分别执行:
OnlyString<string>
→ string
OnlyString<number>
→ never
OnlyString<boolean>
→ never
最后组合:
string | never | never
而:
never
会消失。
所以结果:
string
这就是:
Distributive Conditional Types
分布式条件类型。
十四、infer:让 TypeScript 帮你“挖”出一个类型
infer 是很多人认为最难的地方。
其实你可以把它理解成:
我知道这里有一个类型,但我不知道是什么,帮我提取出来。
例如:
function getUser() {
return {
id: 1,
name: 'Tom'
}
}
我们想获得:
getUser
的返回类型。
可以:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never
关键就在:
infer R
意思:
如果 T 是一个函数,那么把它的返回类型提取出来,并命名为 R。
例如:
type Result =
MyReturnType<typeof getUser>
最终:
Result
就是:
{
id: number
name: string
}
你可以把:
infer
想象成:
类型系统里的解构赋值
JavaScript:
const { name } = user
类型系统:
infer R
也是:
把我需要的那部分类型拿出来。
十五、自己实现 Parameters
不仅可以提取返回值。
函数:
function add(
a: number,
b: number
): number {
return a + b
}
想拿参数类型:
type MyParameters<T> =
T extends (...args: infer P) => any
? P
: never
于是:
type Params =
MyParameters<typeof add>
得到:
[number, number]
这也是官方:
Parameters<T>
的核心思想。
十六、从 Promise 里提取真正的数据类型
假设:
async function getUser() {
return {
id: 1,
name: 'Tom'
}
}
函数返回值其实是:
Promise<{
id: number
name: string
}>
我们想拿里面的:
{
id: number
name: string
}
可以:
type UnwrapPromise<T> =
T extends Promise<infer R>
? R
: T
使用:
type User =
UnwrapPromise<
ReturnType<typeof getUser>
>
先:
ReturnType<typeof getUser>
得到:
Promise<User>
再:
infer R
取出:
User
实际项目里官方已经提供:
Awaited<T>
所以通常可以:
type User =
Awaited<
ReturnType<typeof getUser>
>
这也是非常实用的组合。
十七、as const:让类型不要变宽
看代码:
const role = 'admin'
由于:
const
TS 通常知道:
role
是:
'admin'
但数组:
const roles = [
'admin',
'user',
'guest'
]
通常会被推断为:
string[]
因为数组未来可能变化。
如果我们希望:
roles
永远就是这三个值:
const roles = [
'admin',
'user',
'guest'
] as const
类型会变成:
readonly [
'admin',
'user',
'guest'
]
这时候可以:
type Role = typeof roles[number]
为什么 [number]?
因为数组的 key 本质上是:
0
1
2
...
通过:
typeof roles[number]
意思是:
获取这个数组所有元素可能的类型。
最终:
type Role =
| 'admin'
| 'user'
| 'guest'
这个技巧特别常用。
十八、as const + typeof:配置即类型
例如:
const routes = [
'home',
'user',
'setting'
] as const
然后:
type Route =
typeof routes[number]
最终:
type Route =
| 'home'
| 'user'
| 'setting'
以后增加:
'admin'
类型自动变化。
这就是:
单一数据源。
不要:
数据维护一份
类型再维护一份
否则早晚不同步。
十九、satisfies:我想校验类型,但不想丢失精确推断
这是现代 TS 非常实用的能力。
假设:
type Config = {
theme: 'light' | 'dark'
port: number
}
我们写:
const config: Config = {
theme: 'dark',
port: 3000
}
这是没问题的。
但我们把:
config
显式声明成了:
Config
有时候会损失一些更加具体的字面量推断。
可以使用:
const config = {
theme: 'dark',
port: 3000
} satisfies Config
它做两件事情:
1. 检查这个对象是否满足 Config
2. 尽可能保留对象本身更具体的推断
所以:
config.theme
可以更精确地知道:
'dark'
而不是单纯只知道:
'light' | 'dark'
二十、satisfies 特别适合配置对象
比如菜单:
type MenuItem = {
title: string
path: `/${string}`
}
const menu = {
home: {
title: '首页',
path: '/home'
},
user: {
title: '用户',
path: '/user'
}
} satisfies Record<string, MenuItem>
这样:
menu.home.path
仍然能够保留:
'/home'
这种精确类型。
因此:
satisfies
特别适合:
菜单
路由
权限
主题
插件配置
接口配置
二十一、可辨识联合:React 状态建模神器
假设请求状态:
type State = {
loading: boolean
data?: User
error?: string
}
看起来没问题。
但它允许:
const state = {
loading: true,
data: user,
error: '请求失败'
}
这就非常奇怪:
正在加载
有数据
还有错误
三个状态同时存在。
其实业务状态应该是互斥的:
加载中
成功
失败
所以我们可以:
type State =
| {
status: 'loading'
}
| {
status: 'success'
data: User
}
| {
status: 'error'
error: string
}
这里:
status
就是:
判别字段。
二十二、TypeScript 会自动缩窄类型
有了刚才的类型:
function render(state: State) {
if (state.status === 'success') {
console.log(state.data)
}
if (state.status === 'error') {
console.log(state.error)
}
}
当:
state.status === 'success'
时:
TypeScript 自动知道:
state
一定是:
{
status: 'success'
data: User
}
所以:
state.data
一定存在。
这就是:
Type Narrowing,类型收窄。
二十三、为什么可辨识联合特别适合前端?
因为前端充满状态机。
例如上传:
idle
uploading
success
error
WebSocket:
connecting
connected
disconnected
error
订单:
pending
paid
cancelled
refunded
如果用:
boolean
boolean
boolean
管理,很容易产生非法状态。
如果用:
status
统一建模,就清晰很多。
所以:
一个复杂状态如果本质上互斥,可以优先考虑可辨识联合,而不是堆多个 boolean。
二十四、模板字符串类型:类型也可以拼字符串
JavaScript:
const name = `user:${id}`
TS 类型也可以:
type Module =
| 'user'
| 'order'
type Action =
| 'read'
| 'write'
type Permission =
`${Module}:${Action}`
最终:
type Permission =
| 'user:read'
| 'user:write'
| 'order:read'
| 'order:write'
是不是很像笛卡尔积?
这特别适合:
权限系统
事件系统
路由系统
CSS 名称
状态名称
二十五、结合 Capitalize 做事件名
例如:
type Event =
'click'
| 'change'
可以:
type Handler =
`on${Capitalize<Event>}`
得到:
'onClick' | 'onChange'
TypeScript 内置很多字符串类型工具:
Uppercase<T>
Lowercase<T>
Capitalize<T>
Uncapitalize<T>
例如:
type A =
Uppercase<'hello'>
得到:
'HELLO'
二十六、Utility Types:官方给你的类型工具箱
TypeScript 自带很多工具类型。
这些其实就是:
官方已经帮你写好的类型函数。
最常用的是:
Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, V>
ReturnType<T>
Parameters<T>
Awaited<T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
二十七、Partial:全部变成可选
type User = {
id: number
name: string
}
使用:
type UpdateUser =
Partial<User>
得到:
{
id?: number
name?: string
}
特别适合 PATCH:
function updateUser(
id: number,
data: Partial<User>
) {}
二十八、Pick:只选几个字段
type User = {
id: number
name: string
password: string
email: string
}
如果页面只需要:
id
name
可以:
type UserInfo =
Pick<User, 'id' | 'name'>
得到:
{
id: number
name: string
}
二十九、Omit:排除字段
例如:
type SafeUser =
Omit<User, 'password'>
得到:
{
id: number
name: string
email: string
}
这个在 API 返回结构里特别常用。
三十、Record:动态 key 对应固定 value
比如:
type Role =
| 'admin'
| 'user'
| 'guest'
希望每个 Role 对应中文:
const roleText:
Record<Role, string> = {
admin: '管理员',
user: '用户',
guest: '游客'
}
Record<K, V> 可以理解成:
key 是 K
value 是 V
三十一、Exclude 和 Extract
假设:
type Role =
| 'admin'
| 'user'
| 'guest'
排除:
type NormalRole =
Exclude<Role, 'admin'>
得到:
'user' | 'guest'
反过来:
type SomeRole =
Extract<Role, 'admin' | 'xxx'>
得到:
'admin'
所以:
Exclude
= 排除
Extract
= 取交集
三十二、函数重载:参数不同,返回值也不同
假设:
function getValue(
type: 'user'
): User
function getValue(
type: 'admin'
): Admin
function getValue(
type: 'user' | 'admin'
) {
// 实现
}
调用:
const user =
getValue('user')
TS 知道:
user
是:
User
而:
const admin =
getValue('admin')
就是:
Admin
这种场景适合:
参数不同,返回值类型有明显对应关系。
三十三、函数重载不要滥用
如果只是:
number | string
能够简单解决,就不用强行重载。
重载更适合:
输入 A → 返回 X
输入 B → 返回 Y
而且这种关系非常明确
例如:
getElement('canvas')
应该返回:
HTMLCanvasElement
而:
getElement('input')
应该返回:
HTMLInputElement
三十四、Brand Type:解决 TS 的“结构类型”问题
TypeScript 是:
结构类型系统。
也就是说只要结构一样,就会认为类型兼容。
例如:
type UserId = string
type OrderId = string
那么:
function getUser(
id: UserId
) {}
下面居然也可以:
const orderId: OrderId = '123'
getUser(orderId)
因为:
UserId
和
OrderId
本质都是 string
但是业务上:
订单 ID
当然不能当用户 ID
三十五、Brand Type 的解决方式
可以:
type Brand<T, Name> =
T & {
__brand: Name
}
然后:
type UserId =
Brand<string, 'UserId'>
type OrderId =
Brand<string, 'OrderId'>
现在:
UserId
和:
OrderId
就不是一个类型了。
实际项目里:
UserId
OrderId
TenantId
WorkspaceId
ProductId
这种类型非常适合品牌化。
它主要解决:
类型结构一样,但业务语义完全不同。
三十六、递归类型:树形结构非常常见
比如菜单树:
type Menu = {
id: number
title: string
children?: Menu[]
}
这里:
Menu
里面又出现:
Menu[]
这就是递归类型。
典型应用:
树
菜单
评论
组织架构
AST
文件目录
例如:
const menu: Menu = {
id: 1,
title: '系统管理',
children: [
{
id: 2,
title: '用户管理'
}
]
}
三十七、一个非常实战的例子:类型安全的表格组件
假设写一个通用表格:
<Table
data={users}
columns={[
{
key: 'name',
title: '姓名'
}
]}
/>
问题是:
key
不能随便传。
如果用户写:
key: 'username'
而 User 根本没有:
username
最好编译阶段报错。
可以:
type Column<T> = {
key: keyof T
title: string
}
然后:
type TableProps<T> = {
data: T[]
columns: Column<T>[]
}
最终:
function Table<T>(
props: TableProps<T>
) {
// ...
}
如果:
type User = {
id: number
name: string
age: number
}
那 column key 只能是:
'id' | 'name' | 'age'
这就是:
泛型
+
keyof
在真实项目中的使用。
三十八、再进阶:让 render 的参数类型自动跟着 key
假设:
{
key: 'age',
render(value) {}
}
如果:
key = age
那:
value
应该自动是:
number
而如果:
key = name
value 应该是:
string
这时候就可以用:
T[K]
例如:
type Column<
T,
K extends keyof T
> = {
key: K
title: string
render?: (
value: T[K],
row: T
) => React.ReactNode
}
这就开始体现 TypeScript 类型系统真正厉害的地方了:
类型会随着你的配置变化自动联动。
三十九、高级 TS 的本质:减少重复信息
假设你已经有:
const roles = [
'admin',
'user',
'guest'
] as const
就不要再写:
type Role =
| 'admin'
| 'user'
| 'guest'
直接:
type Role =
typeof roles[number]
如果已经有:
function getUser() {}
就不用手写返回类型:
type User =
ReturnType<typeof getUser>
如果返回 Promise:
type User =
Awaited<
ReturnType<typeof getUser>
>
高级 TS 的一个核心思想就是:
能推导的类型,就不要重复声明。
因为重复信息最大的问题不是麻烦。
而是:
它们早晚会不一致。
四十、最后建立一套 TS 高级类型思维
以后看到一个类型问题,可以按这个顺序思考。
如果:
我需要对象所有 key
想:
keyof
如果:
我已经有一个变量,想得到它的类型
想:
typeof
如果:
我想拿对象某个 key 对应的 value 类型
想:
T[K]
如果:
我想遍历一个对象类型的所有字段
想:
[K in keyof T]
如果:
我想根据条件返回不同类型
想:
T extends X ? A : B
如果:
我想从某个复杂类型内部提取一部分
想:
infer
如果:
我想从常量生成联合类型
想:
as const
+
typeof
如果:
我想校验一个对象类型,但保留精确推断
想:
satisfies
如果:
我有多个互斥状态
想:
可辨识联合
如果:
我要组合字符串规则
想:
模板字符串类型
四十一、真正值得优先掌握的顺序
如果你是前端或者 AI 全栈开发,不需要一开始就钻特别变态的类型体操。
建议先掌握:
第一阶段
泛型
keyof
typeof
T[K]
然后:
第二阶段
映射类型
条件类型
Utility Types
接着:
第三阶段
infer
as const
satisfies
可辨识联合
最后:
第四阶段
模板字符串类型
递归类型
Brand Type
复杂类型体操
四十二、总结
TypeScript 的高级用法看起来复杂,本质其实一直围绕几件事情:
获取类型
转换类型
筛选类型
提取类型
组合类型
约束类型
几个最核心的关键字可以浓缩成:
keyof
typeof
extends
infer
in
as const
satisfies
而最重要的思想不是:
“我会写多复杂的类型体操。”
而是:
让 TypeScript 尽可能从已有代码中推导类型,让错误尽量在编译阶段暴露,而不是运行到线上以后才发现。
当你开始真正熟练使用:
keyof T
T[K]
typeof
infer
satisfies
以后,你会明显发现 TypeScript 不再只是:
“JavaScript + 类型注解”。
而更像是一套运行在编译阶段的:
类型编程语言。
这也是 TypeScript 从“会用”走向“真正熟练”的关键一步。