TypeScript 高级用法全解析:从泛型到 infer,把类型系统真正用起来

27 阅读15分钟

!!文章很长最好根据自身能力分段看 !!

如果你已经学过 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 怎么还能写成这样?

其实这些所谓的“高级类型”,并没有想象中那么玄学。

它们的核心目的依然只有一个:

让类型根据已有信息自动推导出来,而不是让开发者重复手写。

本文会从最实用的几个高级能力开始讲:

  • 泛型
  • keyof
  • typeof
  • 索引访问类型 T[K]
  • 映射类型
  • 条件类型
  • infer
  • 联合类型分发
  • as const
  • satisfies
  • 可辨识联合
  • 模板字符串类型
  • 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 从“会用”走向“真正熟练”的关键一步。