TypeScript 类型体操完全指南:从入门到实战

2 阅读6分钟

TypeScript 类型体操完全指南:从入门到实战

掌握 TypeScript 高级类型技巧,写出更安全的代码


前言

在使用 TypeScript 开发时,你是否遇到过这些场景:

  • 想要提取对象的部分属性类型,却不知道如何用工具类型实现
  • 面对复杂的泛型约束,只能照搬官方示例,不理解背后的原理
  • 看到开源项目中的类型体操代码,感觉像在读天书

类型系统本是 TypeScript 的核心优势,但很多开发者只使用了最基础的类型注解,错过了类型编程带来的巨大收益。

这篇文章将从基础概念出发,带你逐步掌握 TypeScript 类型体操的核心技巧。学完后,你将能够理解条件类型、映射类型、推断等核心概念,手写常用工具类型,并在实际项目中运用类型体操提升代码质量。


一、条件类型:类型世界的三元运算符

1. 基础语法

条件类型是 TypeScript 类型体操的基石,它的语法与 JavaScript 的三元运算符类似:

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

这里的 extends 关键字用于类型判断,如果 T 可以赋值给 string,则返回 true,否则返回 false

2. 分布式条件类型

当条件类型的左侧是泛型参数时,TypeScript 会自动进行分布式判断:

type ToArray<T> = T extends any ? T[] : never;

type A = ToArray<string | number>;  // string[] | number[]
// 等价于 ToArray<string> | ToArray<number>

这个特性在处理联合类型时非常有用,但有时也会带来意外结果。如果需要避免分布式行为,可以用元组包裹泛型:

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type A = ToArrayNonDist<string | number>;  // (string | number)[]

3. 实战:Extract 和 Exclude

TypeScript 内置的 ExtractExclude 工具类型就是条件类型的经典应用:

// Extract:从联合类型中提取可赋值给 U 的类型
type MyExtract<T, U> = T extends U ? T : never;

type A = MyExtract<'a' | 'b' | 'c', 'a' | 'b'>;  // 'a' | 'b'

// Exclude:从联合类型中排除可赋值给 U 的类型
type MyExclude<T, U> = T extends U ? never : T;

type B = MyExclude<'a' | 'b' | 'c', 'a'>;  // 'b' | 'c'

二、映射类型:批量处理对象属性

1. 基础语法

映射类型允许你基于现有类型创建新类型,类似于数组的 map 方法:

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

interface User {
  name: string;
  age: number;
}

type ReadonlyUser = Readonly<User>;
// { readonly name: string; readonly age: number; }

2. 属性修饰符

TypeScript 4.1+ 支持在映射类型中添加属性修饰符:

// 可选属性
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// 必填属性
type Required<T> = {
  [P in keyof T]-?: T[P];
};

// 移除 readonly
type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

3. 键的重映射

TypeScript 4.1 引入了 as 语法,允许在映射类型中重命名键:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
  name: string;
  age: number;
}

type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number; }

三、类型推断:从模式中提取类型

1. infer 关键字

infer 允许你在条件类型中推断出未知类型:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

type A = ReturnType<() => string>;  // string
type B = ReturnType<(x: number) => boolean>;  // boolean

2. 多重推断

TypeScript 4.7+ 支持在单个条件类型中进行多次推断:

type FirstAndLast<T extends any[]> = T extends [infer First, ...infer _, infer Last]
  ? [First, Last]
  : never;

type A = FirstAndLast<[1, 2, 3, 4]>;  // [1, 4]

3. 实战:Promise 解包

type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;

type A = UnwrapPromise<Promise<Promise<string>>>;  // string
type B = UnwrapPromise<number>;  // number

四、常见问题与解决方案

问题 1:条件类型中的 never

当联合类型中的某些分支返回 never 时,这些分支会被自动移除:

type FilterStrings<T> = T extends string ? T : never;

type A = FilterStrings<string | number | boolean>;  // string
// number 和 boolean 分支返回 never,被自动过滤

这不是 bug,而是设计行为。利用这个特性可以实现类型过滤。

问题 2:泛型约束过严

// 不推荐的写法
type GetKey<T extends { key: string }> = T['key'];

// 推荐的写法:使用索引访问,更灵活
type GetKey<T, K extends keyof T> = T[K];

问题 3:递归类型导致循环引用

// 不推荐的写法:可能导致类型检查过慢
type DeepReadonly<T> = {
  readonly [K in keyof T]: DeepReadonly<T[K]>;
};

// 推荐的写法:添加终止条件
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

五、实战技巧

技巧 1:对象属性路径提取

type KeysOfUnion<T> = T extends T ? keyof T : never;

interface A { a: string; b: number; }
interface B { b: number; c: boolean; }

type K = KeysOfUnion<A | B>;  // "a" | "b" | "c"

技巧 2:函数参数类型提取

type FirstParameter<T extends (...args: any[]) => any> = 
  T extends (arg: infer P, ...args: any[]) => any ? P : never;

type A = FirstParameter<(x: string, y: number) => void>;  // string

技巧 3:数组元素类型提取

type ArrayElement<T> = T extends (infer U)[] ? U : never;

type A = ArrayElement<string[]>;  // string
type B = ArrayElement<number[]>;  // number

技巧 4:深度可选类型

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

interface User {
  name: string;
  address: {
    city: string;
    zip: number;
  };
}

type PartialUser = DeepPartial<User>;
// { name?: string; address?: { city?: string; zip?: number; }; }

六、工具与资源推荐

工具 1:TypeScript Playground

  • 官网:www.typescriptlang.org/play
  • 主要功能:在线编写和调试 TypeScript 代码
  • 适用场景:快速验证类型体操代码

工具 2:type-challenges

  • GitHub:github.com/type-challe…
  • 主要功能:TypeScript 类型体操练习题
  • 适用场景:系统性练习类型编程技巧

工具 3:Utility Types


七、最佳实践总结

  1. 优先使用内置工具类型 - PartialPickOmit 等已覆盖大部分场景

  2. 复杂类型添加注释 - 类型体操代码可读性差,关键处添加类型注释

  3. 避免过度抽象 - 类型编程应以提升开发体验为目的,不要为了炫技而写

  4. 善用类型测试 - 使用 expectType 等工具验证类型推导结果

  5. 关注 TypeScript 版本 - 新版本可能带来更简洁的类型写法

场景推荐方案注意事项
对象部分属性Pick<T, K> / Omit<T, K>K 必须是 keyof T
属性可选Partial<T>递归场景用 DeepPartial
属性必填Required<T>会移除可选修饰符
只读对象Readonly<T>浅层只读
函数返回值ReturnType<T>支持 Promise 解包
实例类型InstanceType<T>T 必须是构造函数

八、实战案例:API 响应类型生成

案例背景

假设你有一个后端 API,返回格式统一为:

interface ApiResponse<T> {
  code: number;
  data: T;
  message: string;
}

现在需要编写工具类型,从 API 响应中提取数据类型。

实现步骤

  1. 定义基础提取类型
  2. 处理嵌套 Promise 场景
  3. 支持错误类型提取

完整代码

// 基础 API 响应类型
interface ApiResponse<T> {
  code: number;
  data: T;
  message: string;
}

// 提取响应数据类型
type ExtractResponseData<T> = T extends ApiResponse<infer U> ? U : never;

// 处理 Promise 包装
type UnwrapApi<T> = T extends Promise<infer P>
  ? ExtractResponseData<P>
  : ExtractResponseData<T>;

// 使用示例
type User = { id: number; name: string };

async function getUser(): Promise<ApiResponse<User>> {
  // ...
}

type UserType = UnwrapApi<ReturnType<typeof getUser>>;  // User

总结

类型体操是 TypeScript 的高级特性,掌握它能让你写出更安全、更灵活的代码。但也要记住,类型系统是为开发服务的工具,不要本末倒置。

学习建议:

  • 从内置工具类型开始,理解其实现原理
  • 在 type-challenges 上刷题,循序渐进
  • 在实际项目中尝试应用,遇到问题再深入学习
  • 关注 TypeScript 官方发布,了解新特性

类型编程的世界很大,这篇文章只是入门。真正掌握需要在实践中不断摸索和总结。


参考资料

  1. TypeScript 官方文档 - 条件类型:www.typescriptlang.org/docs/handbo…
  2. TypeScript 官方文档 - 映射类型:www.typescriptlang.org/docs/handbo…
  3. type-challenges:github.com/type-challe…
  4. Utility Types 集合:github.com/andnp/Simpl…

觉得文章对你有帮助?欢迎点赞收藏,分享给更多需要的朋友!