让你成为更好的 TypeScript 程序员的 11 个技巧
学习 Typescript 往往是一段重新发现的旅程。你最初的印象可能很具迷惑性:它不就是一个注释 JavaScript 的方法,让编译器帮我找到潜在的 bug 吗?
尽管这种说法通常是正确的,但随着你的不断学习,你会发现该语言最不可思议的力量在于组合、推断和操作类型。
本文将总结一些帮助您充分利用该语言的技巧。
#1 思考{Set}
类型对于程序员来说是一个日常概念,但要简洁地定义它却出奇地困难。我发现使用Set作为概念模型会更有帮助。
例如,新学习者会发现 Typescript 的组合类型方式违反直觉。举一个非常简单的例子:
type Measure = { radius: number };
type Style = { color: string };
// typed { radius: number; color: string }
type Circle = Measure & Style;
&
如果您以逻辑“与”的方式解释该运算符,您可能会认为Circle
它是一个虚拟类型,因为它是两个类型(没有任何重叠字段)的结合。这并非 TypeScript 的工作方式。相反,以Set的方式思考更容易推断出正确的行为:
- 每种类型都是一组值。
- 有些集合是无限的:字符串、对象;有些集合是有限的:布尔值、未定义……
unknown
是全集(包含所有值),而never
是空集(不包含任何值)。- 类型
Measure
是包含名为 的数字字段的所有对象的集合radius
。与 相同Style
。 - 该
&
运算符创建一个交集:表示包含和字段Measure & Style
的对象集合,它实际上是一个较小的集合,但具有更多常用的字段。radius
color
- 类似地,
|
运算符创建一个Union:一个更大的 Set ,但可能具有更少的常用字段(如果由两种对象类型组成)。
Set还有助于理解可赋值性:仅当值的类型是目标类型的子集时才允许赋值:
type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';
// disallowed because string is not subset of ShapeKind
shape = foo;
// allowed because ShapeKind is subset of string
foo = shape;
下面的文章对 Set 中的思维进行了非常详细的介绍。
#2 理解声明类型和窄类型
TypeScript 的一个非常强大的功能是基于控制流的自动类型收缩。这意味着一个变量在任何特定的代码位置都有两种关联类型:声明类型和收缩类型。
function foo(x: string | number) {
if (typeof x === 'string') {
// x's type is narrowed to string, so .length is valid
console.log(x.length);
// assignment respects declaration type, not narrowed type
x = 1;
console.log(x.length); // disallowed because x is now number
} else {
...
}
}
#3 使用可区分联合代替可选字段
当定义一组像 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!;
}
由于 和 其他字段之间没有建立关联,因此需要非空断言(访问 、 和 字段时radius
)width
。相比之下,可区分联合是一个更好的解决方案:height
kind
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;
}
类型缩小消除了强制转换的需要。
#4 使用类型谓词来避免类型断言
如果你以正确的方式使用 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();
// error because typescript doesn't know the filtering
// narrows typing
const circles: Circle[] = myShapes.filter(isCircle);
// you may be inclined to add an assertion:
// const circles = myShapes.filter(isCircle) as Circle[];
更优雅的解决方案是改变isCircle
并isRect
返回类型谓词,这样它们可以帮助 Typescript 在调用后进一步缩小类型范围filter
:
function isCircle(shape: Shape): shape is Circle {
return shape.kind === 'circle';
}
function isRect(shape: Shape): shape is Rect {
return shape.kind === 'rect';
}
...
// now you get Circle[] type inferred correctly
const circles = myShapes.filter(isCircle);
#5 控制联合类型的分布方式
类型推断是 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)[]
。
#6 使用详尽的检查来在编译时捕获未处理的情况
当对枚举进行 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:
// you'll get a type-checking error below
// if any shape.kind is not handled above
const _exhaustiveCheck: never = shape;
throw new Error('Unknown shape kind');
}
}
getArea
这样,在添加新的形状类型时就不可能忘记更新功能。
该技术背后的原理是,never
除了 之外,类型不能被赋值never
。如果shape.kind
case 语句穷尽了所有 的候选类型,那么唯一可能的类型default
就是 never;然而,如果任何候选类型未被覆盖,它就会泄漏到分支中default
,导致赋值无效。
#7type
更喜欢interface
在 TypeScript 中,type
和interface
在用于类型化对象时结构非常相似。尽管可能存在争议,但我的建议是type
在大多数情况下始终如一地使用,并且仅interface
在以下任一情况成立时使用:
-
您想利用的“合并”功能
interface
。 -
您有涉及类/接口层次结构的 OO 风格代码。
否则,始终使用更通用的type
构造会产生更一致的代码。
#8 适当时优先使用元组而不是数组
对象类型是结构化数据的常用类型,但有时你可能想要更简洁的表示,可以使用简单的数组。例如,我们的类型Circle
可以定义如下:
type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0]; // [kind, radius]
但是这种类型不必要地宽松,并且创建类似的东西很容易出错['circle', '1.0']
。我们可以通过使用 Tuple 来使其更严格:
type Circle = [string, number];
// you'll get an error below
const circle: Circle = ['circle', '1.0'];
Tuple 用法的一个很好的例子是 React 的useState
。
const [name, setName] = useState('');
它既紧凑又类型安全。
#9 控制推断类型的通用性或特殊性
Typescript 在进行类型推断时会使用合理的默认行为,旨在简化常见情况的代码编写(因此类型无需显式注释)。您可以通过几种方式调整其行为。
- 用于
const
缩小到最具体的类型
let foo = { name: 'foo' }; // typed: { name: string }
let Bar = { name: 'bar' } as const; // typed: { name: 'bar' }
let a = [1, 2]; // typed: number[]
let b = [1, 2] as const; // typed: [1, 2]
// typed { kind: 'circle; radius: number }
let circle = { kind: 'circle' as const, radius: 1.0 };
// the following won't work if circle wasn't initialized
// with the const keyword
let shape: { kind: 'circle' | 'rect' } = circle;
- 用于
satisfies
检查类型而不影响推断的类型
请考虑以下示例:
type NamedCircle = {
radius: number;
name?: string;
};
const circle: NamedCircle = { radius: 1.0, name: 'yeah' };
// error because circle.name can be undefined
console.log(circle.name.length);
我们遇到了一个错误,因为根据circle
的声明类型NamedCircle
,name
即使变量初始化器提供的是字符串值,字段也确实可以为 undefined。当然,我们可以删除: NamedCircle
类型注解,但这会削弱对象有效性的类型检查circle
。真是进退两难。
幸运的是,Typescript 4.9 引入了一个新satisfies
关键字,它允许您检查类型而不改变推断的类型:
type NamedCircle = {
radius: number;
name?: string;
};
// error because radius violates NamedCircle
const wrongCircle = { radius: '1.0', name: 'ha' }
satisfies NamedCircle;
const circle = { radius: 1.0, name: 'yeah' }
satisfies NamedCircle;
// circle.name can't be undefined now
console.log(circle.name.length);
修改后的版本同时享有两个好处:对象文字保证符合NamedCircle
类型,并且推断类型具有非空name
字段。
#10 用于infer
创建额外的泛型类型参数
在设计实用函数和类型时,你经常会觉得需要使用从给定类型参数中提取的类型。infer
在这种情况下,关键字 就派上用场了。它可以帮助你在运行时推断出新的类型参数。以下是两个简单的例子:
// gets the unwrapped type out of a Promise;
// idempotent if T is not Promise
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string
// gets the flattened type of array T;
// idempotent if T is not array
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
。
#11通过创造性地进行类型操作来保持DRY
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());
- 而不是并行同步两种类型的形状(此处为 typeof config 和 Factory):
type ContentTypes = 'news' | 'blog' | 'video';
// config for indicating what content types are enabled
const config = { news: true, blog: true, video: false }
satisfies Record<ContentTypes, boolean>;
// factory for creating contents
type Factory = {
createNews: () => Content;
createBlog: () => Content;
};
,使用映射类型和模板文字类型根据配置的形状自动推断正确的工厂类型:
type ContentTypes = 'news' | 'blog' | 'video';
// generic factory type with a inferred list of methods
// based on the shape of the given Config
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
[k in string & keyof Config as Config[k] extends true
? `create${Capitalize<k>}`
: never]: () => Content;
};
// config for indicating what content types are enabled
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。了解这些技巧可以帮助你更好地理解这些工具是如何在底层发挥其魔力的。
我是不是漏掉了什么重要信息?欢迎在下方留言,我们一起聊聊!
附言:我们正在构建ZenStack——一个使用 Next.js + Typescript 构建安全 CRUD 应用的工具包。我们的目标是让您节省编写样板代码的时间,专注于构建真正重要的内容——用户体验。
文章来源:https://dev.to/zenstack/11-tips-that-help-you-become-a-better-typescript-programmer-4ca1