TypeScript 进阶必学的 11 个技巧
深度讲解 TypeScript 常见误区和高级用法,帮助写出更安全高效的代码。社区认可度高,实战价值强。
深度讲解 TypeScript 常见误区和高级用法,帮助写出更安全高效的代码。社区认可度高,实战价值强。
学习 TypeScript 往往是一场重新发现的旅程。你最初的印象可能会很具欺骗性:它不就是一种为 JavaScript 添加注解的方式,让编译器帮我找出潜在的 bug 吗?
虽然这个说法大致上是对的,但当你继续深入时,你会发现这门语言最不可思议的威力在于组合、推断和操纵类型。
本文将总结一些帮你充分利用该语言的技巧。
虽然"类型"是编程中司空见惯的概念,但要准确定义它出人意料地困难。我发现用集合(Set)作为概念模型来理解类型会更容易。
例如,初学者觉得 TypeScript 组合类型的方式很违反直觉。看一个很简单的例子:
type Measure = { radius: number };
type Style = { color: string };
// typed { radius: number; color: string }
type Circle = Measure & Style;
如果你把操作符 & 理解为逻辑与,你可能会预期 Circle 是一个虚拟类型,因为它是两个没有重叠字段的类型的结合。但 TypeScript 的行为并非如此。用集合的角度来想就容易多了:
每一个类型都是一个值的集合。
有些集合是无限的:string、object;有些是有限的:boolean、undefined 等。
unknown 是全集(包含所有值),而 never 是空集(不包含任何值)。
Measure 类型是所有包含一个叫 radius 的 number 字段的对象的集合。Style 也是一样。
& 操作符创建交集:Measure & Style 表示包含 radius 和 color 两个字段的对象的集合,实际上是一个更小的集合,但有更多通用的字段。
类似地,| 操作符创建并集:一个更大的集合,但潜在的通用字段可能更少(如果两个对象类型组合的话)。
集合论也有助于理解可赋值性:只有当值的类型是目标类型的子集时,赋值才被允许:
type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';
// 不被允许,因为 string 不是 ShapeKind 的子集
shape = foo;
// 被允许,因为 ShapeKind 是 string 的子集
foo = shape;
以下文章提供了一个关于用集合论思考的精彩详细介绍。
TypeScript and Set Theory | Iván Ovejero
TypeScript 一个极其强大的特性是基于控制流的自动类型缩窄。这意味着一个变量在代码的特定位置有两种类型:声明类型和缩窄后的类型。
function foo(x: string | number) {
if (typeof x === 'string') {
// x 的类型缩窄到 string,所以 .length 是合法的
console.log(x.length);
// 赋值遵循声明类型,而不是缩窄后的类型
x = 1;
console.log(x.length); // 不被允许,因为 x 现在是 number
} else {
...
}
}
当定义一组多态类型(如 Shape)时,很容易开始用:
type Shape = {
kind: 'circle' | 'rect';
radius?: number;
width?: number;
height?: number;
}
function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius! ** 2
: shape.width! * shape.height!;
}
由于 kind 和其他字段之间没有建立关系,访问 radius、width 和 height 字段时需要非空断言。判别联合是一个更好的解决方案:
type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;
function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius ** 2
: shape.width * shape.height;
}
类型缩窄消除了强制转换的需要。
如果你正确地使用 TypeScript,应该很少发现自己需要显式的类型断言(如 value as SomeType);不过,有时你仍然会有这种冲动,比如:
type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;
function isCircle(shape: Shape) {
return shape.kind === 'circle';
}
function isRect(shape: Shape) {
return shape.kind === 'rect';
}
const myShapes: Shape[] = getShapes();
// 错误,因为 typescript 不知道过滤
// 会缩窄类型
const circles: Circle[] = myShapes.filter(isCircle);
// 你可能倾向于添加一个断言:
// const circles = myShapes.filter(isCircle) as Circle[];
一个更优雅的解决方案是将 isCircle 和 isRect 改为返回类型谓词,这样它们在 filter 调用后能帮助 TypeScript 进一步缩窄类型:
function isCircle(shape: Shape): shape is Circle {
return shape.kind === 'circle';
}
function isRect(shape: Shape): shape is Rect {
return shape.kind === 'rect';
}
...
// 现在你可以正确地推断出 Circle[] 类型
const circles = myShapes.filter(isCircle);
类型推断是 TypeScript 的本能;大多数时候,它都默默地为你工作。不过,在存在歧义的微妙情况下,你可能需要进行干预。分布条件类型就是这样的情况之一。
假设我们有一个 ToArray 辅助类型,如果输入类型还不是数组,就返回一个数组类型:
type ToArray<T> = T extends Array<unknown> ? T: T[];
你觉得下面这个类型应该被推断为什么?
type Foo = ToArray<string|number>;
答案是 string[] | number[]。但这很模糊。为什么不是 (string | number)[] 呢?
默认情况下,当 TypeScript 遇到联合类型(这里是 string | number)用于泛型参数(这里是 T)时,它会分布到每个组成部分,这就是你得到 string[] | number[] 的原因。这种行为可以通过使用特殊语法并用 [] 包裹 T 来改变,如:
type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;
现在 Foo 被推断为类型 (string | number)[]。
当对 enum 进行 switch 分支时,一个好的习惯是为那些不被期望的情况主动抛出错误,而不是像其他编程语言那样静默忽略它们:
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
throw new Error('Unknown shape kind');
}
}
有了 TypeScript,你可以利用 never 类型让静态类型检查更早地为你找到错误:
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
// 如果上面的 case 语句没有处理
// 任何 shape.kind,你会在下面得到一个类型检查错误
const _exhaustiveCheck: never = shape;
throw new Error('Unknown shape kind');
}
}
这样,在添加新的 shape kind 时,就不可能忘记更新 getArea 函数。
这个技巧背后的原理是 never 类型除了 never 本身不能被赋予任何东西。如果 shape.kind 的所有候选都被 case 语句穷举了,到达 default 的唯一可能的类型是 never;然而,如果有任何候选没有被覆盖,它会泄漏到 default 分支,导致一个无效的赋值。
在 TypeScript 中,type 和 interface 在用于对象类型时是非常相似的构造。虽然这可能有争议,但我的建议是在大多数情况下始终使用 type,只在以下情况下使用 interface:
你想利用 interface 的"合并"特性。
你想利用 interface 的"合并"特性。
你有涉及 class/interface 继承层次的面向对象风格的代码。
你有涉及 class/interface 继承层次的面向对象风格的代码。
否则,始终使用更通用的 type 构造会产生更一致的代码。
对象类型是输入结构化数据的常见方式,但有时你可能希望有一个更简洁的表示,改为使用简单数组。例如,我们的 Circle 可以定义为:
type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0]; // [kind, radius]
但这种输入不必要地宽松,你可以轻易地通过创建类似 ['circle', '1.0'] 这样的东西犯错。我们可以通过使用 Tuple 来使其更严格:
type Circle = [string, number];
// 下面你会得到一个错误
const circle: Circle = ['circle', '1.0'];
一个很好的 Tuple 使用例子是 React 的 useState。
const [name, setName] = useState('');
它既紧凑又类型安全。
TypeScript 在进行类型推断时使用合理的默认行为,其目的是让常见情况下编写代码变得容易(这样就不需要显式注解类型)。有几种方式可以调整其行为。
用 const 缩窄到最具体的类型
let foo = { name: 'foo' }; // 输入:{ name: string }
let Bar = { name: 'bar' } as const; // 输入:{ name: 'bar' }
let a = [1, 2]; // 输入:number[]
let b = [1, 2] as const; // 输入:[1, 2]
// 输入 { kind: 'circle; radius: number }
let circle = { kind: 'circle' as const, radius: 1.0 };
// 如果 circle 没有用 const 关键字初始化,
// 下面的赋值就不会工作
let shape: { kind: 'circle' | 'rect' } = circle;
用 satisfies 检查输入而不影响推断的类型
考虑下面的例子:
type NamedCircle = {
radius: number;
name?: string;
};
const circle: NamedCircle = { radius: 1.0, name: 'yeah' };
// 错误,因为 circle.name 可能是 undefined
console.log(circle.name.length);
我们得到一个错误,因为根据 circle 的声明类型 NamedCircle,name 字段确实可能是 undefined,即使变量初始化器提供了一个字符串值。当然,我们可以去掉 : NamedCircle 类型注解,但我们会失去对 circle 对象有效性的类型检查。这是个相当矛盾的局面。
幸运的是,TypeScript 4.9 引入了一个新的 satisfies 关键字,它允许你检查类型而不改变推断的类型:
type NamedCircle = {
radius: number;
name?: string;
};
// 错误,因为 radius 违反了 NamedCircle
const wrongCircle = { radius: '1.0', name: 'ha' }
satisfies NamedCircle;
const circle = { radius: 1.0, name: 'yeah' }
satisfies NamedCircle;
// 现在 circle.name 不能是 undefined
console.log(circle.name.length);
改进后的版本享受两个好处:对象字面量保证符合 NamedCircle 类型,并且推断的类型有一个非空的 name 字段。
在设计实用函数和类型时,你经常会感到需要一个从给定的类型参数中提取出来的类型。infer 关键字在这种情况下派上用场。它帮助你即时推断一个新的类型参数。这里有两个简单的例子:
// 从一个 Promise 中获得解包后的类型;
// 如果 T 不是 Promise 则等幂
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string
// 获得数组 T 的扁平化类型;
// 如果 T 不是数组则等幂
type Flatten<T> = T extends Array<infer E> ? Flatten<E> : T;
type e = Flatten<number[][]>; // e: number
infer 关键字在 T extends Promise<infer U> 中是如何工作的,可以理解为:假设 T 与某个实例化的泛型 Promise 类型兼容,临时创建一个类型参数 U 使其工作。所以,如果 T 被实例化为 Promise<string>,U 的解就是 string。
TypeScript 提供了强大的类型操纵语法和一套非常有用的工具来帮助你将代码重复降到最低。这里仅仅是一些临时的例子:
与其重复字段声明:
type User = {
age: number;
gender: string;
country: string;
city: string
};
type Demographic = { age: number: gender: string; };
type Geo = { country: string; city: string; };
不如用 Pick 工具提取新类型:
type User = {
age: number;
gender: string;
country: string;
city: string
};
type Demographic = Pick<User, 'age'|'gender'>;
type Geo = Pick<User, 'country'|'city'>;
与其重复函数的返回类型
function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: { kind: 'circle'; radius: number }) {
...
}
transformCircle(createCircle());
不如用 ReturnType<T> 提取它:
function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: ReturnType<typeof createCircle>) {
...
}
transformCircle(createCircle());
与其平行同步两个类型的形状(这里是 config 和 Factory):
type ContentTypes = 'news' | 'blog' | 'video';
// 用于指示启用了哪些内容类型的配置
const config = { news: true, blog: true, video: false }
satisfies Record<ContentTypes, boolean>;
// 用于创建内容的工厂
type Factory = {
createNews: () => Content;
createBlog: () => Content;
};
不如用映射类型和模板字面量类型根据 config 的形状自动推断适当的工厂类型:
type ContentTypes = 'news' | 'blog' | 'video';
// 泛型工厂类型,其方法列表
// 根据给定 Config 的形状被推断
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
[k in string & keyof Config as Config[k] extends true
? `create${Capitalize<k>}`
: never]: () => Content;
};
// 用于指示启用了哪些内容类型的配置
const config = { news: true, blog: true, video: false }
satisfies Record<ContentTypes, boolean>;
type Factory = ContentFactory<typeof config>;
// Factory: {
// createNews: () => Content;
// createBlog: () => Content;
// }
发挥你的想象力,你会发现有无穷的潜能可以探索。
本文涵盖了 TypeScript 语言中相对高级的一些主题。在实践中,你可能会发现直接应用它们并不常见;不过,这些技术被专门为 TypeScript 设计的库广泛使用:比如 Prisma 和 tRPC。了解这些技巧可以帮助你更好地理解这些工具如何在幕后施展魔法。
我遗漏了什么重要的东西吗?在下方留下评论,让我们聊天吧!
P.S. 我们正在构建 ZenStack——一个使用 Next.js + TypeScript 构建安全 CRUD 应用的工具包。我们的目标是让你省去写样板代码的时间,专注于构建重要的东西——用户体验。