具有类型缩小功能的类型安全 TypeScript
本文介绍了一些常用模式,可以最大限度地发挥 TypeScript 编写类型安全代码的潜力。这些技术都属于同一类,我们称之为类型收缩。
源代码可以在以下位置找到:https://github.com/rainerhahnekamp/type-safe-typescript-with-type-narrowing
如果您更喜欢视频而不是文章,那么这适合您:
1. 简介
每当我们处理一个可以包含多种类型的变量(例如unknown
或 联合类型)时,我们都可以应用类型收缩,将其“缩小”为一种特定类型。我们与 TypeScript 编译器协同工作,因为它能够理解我们代码的上下文,并保证这种收缩以完全类型安全的方式进行。
假设我们有一个函数,其参数类型为Date | undefined
。每次函数执行时,变量的类型可以是Date
或undefined
,但不能同时是两种类型。
function print(value: Date | undefined): void {}
如果我们应用一个if
条件,检查该变量是否为undefined
,TypeScript 会理解其含义,并将条件中的值仅视为string
。这就是类型缩小。
function print(input: Date | undefined): void {
if (input !== undefined) {
input.getTime(); // 👍 value is only Date
}
input.getTime(); // 💣 fails because value can be Date or undefined
}
还有一种非常类似的技术,称为类型断言。乍一看,它似乎是更简单的选择。但就类型安全而言,它并非如此。我们需要手动设置类型,从而覆盖编译器。
如果编译器可以和我们对话,它会说这样的话:“好的,你知道你在做什么,但如果出了问题,不要怪我。”
因此,我们应该避免类型断言,并始终支持类型收缩。(总的来说:试图比编译器更聪明永远不是一个好主意。)
function print(input: Date | undefined): void {
(input as Date).getTime(); // type assertion - don't!
}
经过这个简短的介绍之后,让我们举一个例子来看一下主要类型缩小技术的实际应用。
这将是我们的“工作台”:
declare function diffInYears(input: Date): number;
declare function parse(input: string): Date;
function calcAge(input: Date | null | undefined): number {
return diffInYears(input); // will not work
}
calcAge
,应该返回 - 正如名称所示 - 年龄。
此外,我们使用了两个实用函数diffInYears
和parse
。为了简单起见,代码片段没有展示它们的实现。
input
in的类型calcAge
可以是三种不同的类型。因此,return 语句将编译失败。
2.平等范围缩小
一个显而易见的开始是检查 是否input
不具有值null
和undefined
。如果是的话,那么它只能是Date
。
如果我们添加这个条件,TypeScript 就会理解它,并将-blockinput
内部视为,然后我们就可以安全地调用。if
Date
diffInYears
TypeScript 的这种“隐式理解”已经是我们的第一个类型保护,它被称为“平等缩小”。
function calcAge(input: Date | null | undefined): number {
if (input !== null && input !== undefined) {
return diffInYears(input);
}
}
请注意,该条件并非直接检查或 的if
类型。它针对的是值。同样,我们也无法针对 编写类型检查:是 的类型,而不是它的值。undefined
null
Date
value === Date
Date
input
那么为什么我们可以使用undefined
and null
then 呢?答案很明显。该类型undefined
只有一个值,即 undefined。同样如此null
。该类型null
只能有一个值,即 null。
所以我们的条件实际上做的是排除所有可能出现的undefined
或的值null
。出于显而易见的原因,我们不想排除类型的所有可能值Date
:)
3.typeof
让我们尝试另一种类型保护。JavaScript 提供了typeof
这种类型保护,我们可以将其放在任何变量前面。顾名思义,它会返回变量类型的名称string
……其实不然。否则我们早就读到本文的结尾了 :)。
在 JavaScript 中,我们有七种原始类型,其余的只是 类型object
。原始类型包括boolean
、string
、number
、undefined
、null
、bigint
和symbol
。
typeof
返回除 之外的所有原始类型的名称null
。对于 ,null
它返回“object”。因此,可以假设对于null
和任何非原始类型,我们都会得到“object”。但还有第二个例外。typeof
如果变量实际上是function
,或者你传递了 的名称,也会返回“function” class
。严格来说, function 不是真正的类型,它是一个可调用对象。
有了这些知识,下面的代码可以工作吗?
function calcAge(input: Date | null | undefined): number {
if (typeof input === "object") {
return diffInYears(input);
}
}
不行。因为typeof
返回的null
也是“object”,所以编译会失败。所以目前我们用不着typeof
类型保护,不过我们还是记住它吧。以后可能会用到它。
4. 真实性缩小
JavaScript 发明了两个新的英文术语:Falsy 和 Truthy。如果我们将一个假值放入一个条件中,它会返回一个真值false
,并且true
返回一个假值。JavaScript 中有一个详尽的假值列表,分别是、false
、0
、0n
、''
、和。null
NaN
undefined
我们可以利用这一点。如果我们只把 放入条件input
中if
,那么结果就是true
如果值不是undefined
或null
。在我们的例子中,这与相等运算符非常相似,只是更简洁一些。
function calcAge(input: Date | null | undefined): number {
if (input) {
return diffInYears(input);
}
}
这里我们漏掉了一点。如果值是空字符串,它也会为 false,这样就不会进入if
-condition 了。不过,对于我们的用例来说,只要我们意识到这一点,这就可以了。
让我们的例子更有趣一些。我们添加了第四种可能的类型,即string
。现在是时候typeof
进入游戏了。
首先,我们通过真实性缩小来消除值为null
或的可能性undefined
,并返回值 0(我们稍后会改进这一点)。
那么它只能在Date
和之间string
。这里我们可以使用typeof
来检查 是否input
是string
。如果不是,那它只能是Date
。完美!
function calcAge(input: Date | null | undefined | string): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else {
return diffInYears(input);
}
}
5.instanceof
在处理类时,也有可能进行类型缩小。为此,我们添加一个 类,它具有类型的Person
属性。birthday
Date
class Person {
birthday = new Date();
}
function calcAge(input: Date | null | undefined | string | Person): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else {
return diffInYears(input); // failure: can be Date or Person
}
}
显然,该子句中的最后一个 returnelse
将失败,因为可以是或input
类型。不过好消息是,它们实际上都是类实例,我们可以使用。Date
Person
instanceof
instanceof
如果值是某个类的实例,则返回该true
值。因此,如果我们添加一个检查 class 的条件Date
,我们就能再次确保类型安全:
function calcAge(input: Date | null | undefined | string | Person): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else {
return diffInYears(input.birthday);
}
}
请注意,instanceof
返回的true
是整个类继承链。因此,如果Person
继承自类Entity
,instanceof Entity
也会返回 true。
6. 歧视联盟
我们经常使用类,但更多时候我们处理对象文字,或者用 TypeScript 术语来说,接口或类型。
查看稍微修改过的示例:
type Person = {
birthday: Date;
category: "person";
};
type Car = {
yearOfConstruction: Date;
category: "car";
};
function calcAge(
input: Date | null | undefined | string | Person | Car
): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else {
return diffInYears(input.birthday); // Person | Car
}
}
哎哟,又编译错误了。我们不能直接instanceof
在这里使用吗?不行。Person
和Car
都只是一个元素,它只存在于 TypeScript 中。当它被编译成 JavaScript 时, 和type
的定义就不存在了。另一方面,类也存在于 JavaScript 中。这就是为什么它们可以工作的原因。Person
Car
instanceof
好的,那么我们能做什么呢?
我们很幸运。Person
和Car
共享相同的属性category
,并且每个属性都有不同的值。通过验证 是否category
的值是“car”,TypeScript 足够智能,能够理解它只能是 类型Car
。因为 的Person
值显然是“person”。这种类型保护的名称是“discriminated union”。让我们再次修复代码:
function calcAge(
input: Date | null | undefined | string | Person | Car
): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else if (input.category === "Car") {
return diffInYears(input.yearOfConstruction);
} else {
return diffInYears(input.birthday);
}
}
我们不需要为 discriminator 指定属性的名称category
。我们可以选择任何我们想要的名字。
7.in
类型保护
考虑一下我们不太幸运地拥有可以用作鉴别器的属性的情况:
type Person = {
birthday: Date;
};
type Car = {
yearOfConstruction: Date;
};
function calcAge(
input: Date | null | undefined | string | Person | Car
): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else if (input.category === "Car") {
return diffInYears(input.yearOfConstruction);
} else {
return diffInYears(input.birthday);
}
}
在这种情况下,我们可以使用in
类型保护。通过in
它可以判断一个对象是否具有某个属性。因此,如果它介于Person
和之间,并且存在Car
该属性,则其类型只能是。birthday
Person
type Person = {
birthday: Date;
};
type Car = {
yearOfConstruction: Date;
};
function calcAge(
input: Date | null | undefined | string | Person | Car
): number {
if (!value) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else if ("birthday" in input) {
return diffInYears(input.birthday);
} else {
return diffInYears(input.yearOfConstruction);
}
}
8. 类型谓词
从这个意义上来说,我们的下一个类型保护并不是真正的类型保护,而是一种折衷。
首先,让我们将 类型 替换Car
为 类型PersonJson
。它也有一个 属性birthday
,但它的类型是string
。
我们可以通过 来解决这个问题。这是和可区分联合类型保护typeof value.birthday === 'string'
的组合:typeof
type Person = {
birthday: Date;
};
type PersonJson = {
birthday: string;
};
function calcAge(
input: Date | null | undefined | string | Person | PersonJson
): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else if (typeof input.birthday === "string") {
return diffInYears(parse(input.birthday));
} else {
return diffInYears(input.birthday);
}
}
这段代码编译通过了,看起来一切正常,但并不完美。如果我们input
在最后一条else if
语句中赋值给一个新变量,我们会发现 TypeScript 将该类型标识为PersonJson
而不是Person | PersonJson
。
正是在这个时候,我们达到了 TypeScript 的极限。幸运的是,这并不意味着游戏结束。
每当 TypeScript 用尽选项时,它就会让我们有可能提出一个包含特定类型的验证代码的函数。
某种程度上来说,这是一种妥协。我们可以在函数中写任何我们想写的东西,只要返回true
或 即可false
。TypeScript 会信任我们。
这个特殊的函数被称为“类型谓词”,Person
它看起来像这样:
function isPerson(value: Person | PersonJson): value is Person {
return value.birthday instanceof Date;
}
请注意我们通常具有返回类型的特殊符号。
我们像使用其他函数一样使用谓词并将其放入最后一个else if
条件中:
type Person = {
birthday: Date;
};
type PersonJson = {
birthday: string;
};
function isPerson(value: Person | PersonJson): value is Person {
return value.birthday instanceof Date;
}
function calcAge(
input: Date | null | undefined | string | Person | PersonJson
): number {
if (!input) {
return 0;
} else if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else if (isPerson(input)) {
return diffInYears(input.birthday);
} else {
return diffInYears(parse(input.birthday));
}
}
9. 类型缩小unknown
有了类型谓词,我们甚至可以处理 类型的变量unknown
。只需应用if
类型谓词的条件,问题就迎刃而解了。
但我们必须小心,不要欺骗自己。类型缩小的效果取决于我们的验证逻辑。
如果我们有一个 类型的值unknown
,并且想验证它的“形状”是否为 ,那么Person
我们必须想出一个比之前更好的代码。“形状”指的是任何具有 属性 的对象字面量或实例birthday: Date
。
9.1. 手动验证
类型安全的类型谓词如下所示:
function isPerson(value: unknown): value is Person {
return (
typeof value === "object" &&
value !== null &&
"birthday" in value &&
(value as { birthday: unknown }).birthday instanceof Date
);
}
多么庞大的代码量啊!不幸的是,如果我们想要完全类型安全,这是必须的。
该函数展示了 TypeScript 功能的另一个限制。尽管我们检查了birthday
是 的属性value
,但我们仍然必须在最后一个条件中应用类型断言来检查 是否birthday
属于 类型Date
。
我们可以预期,随着时间的推移,TypeScript 的局限性会变得越来越少,但目前它就是这样。
9.2. 自动验证:zod
我们坚持使用类型缩窄而不是unknown
类型本身。根据应用程序的类型,我们可能经常需要处理类型unknown
。显然,我们不想总是自己编写这些冗长的类型谓词。这会浪费大量宝贵的时间。
幸运的是,情况并非如此。有一些专门的库可以自动进行验证。
其中最受欢迎的一个是“zod”。
对于每种类型,我们必须首先创建一个模式。这意味着我们以编程方式定义类型并将其存储在变量中。因此,模式信息在运行时也存在。
然后在类型谓词中使用生成的模式来验证值是否属于该类型。
有了 zod,我们的isPerson
看起来会像这样:
const personSchema = z.object({
birthday: z.date(),
});
type Person = z.infer<typeof personSchema>;
function isPerson(value: unknown): value is Person {
return personSchema.safeParse(value).success;
}
首先,我们定义模式并将其存储在 下personSchema
。有了现有的模式,我们可以让 zod 自动为我们生成类型。这适用于z.infer
。这简化了操作,并确保我们在更改类型时不会重复工作Person
。
safeParse
最后,如果值不是 类型,我们使用不会抛出错误的方法Person
。它通过属性返回结果success
。
这比一直手动编写验证代码要好得多。
10.断言函数
最后一个功能不是类型保护,但非常方便,那就是断言功能。
unknown
当类型为、null
或空字符串时,我们在第一个条件中返回数字 0 。
返回数字 0 是一种方法。另一种方法是抛出错误。
如果我们抛出错误,TypeScript 的类型缩小将从函数的其余代码中排除undefined
或。null
断言函数是一个特殊的函数,它的作用正是如此。如果我们调用它,它会——类似于类型保护——缩小参数范围,但不会返回布尔值。它保证,如果值不是指定的类型,它会抛出一个错误。
为了处理那些不应该是undefined
或null
的类型,TypeScript 甚至提供了一个特殊的类型实用程序。它名为NonNullable<T>
。这个类型实用程序的意思是,无论类型T
是什么,它都不是undefined
或null
。让我们看看NonNullable<T>
断言函数是如何工作的:
function assertNonNullable<T>(value: T): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new Error("undefined or null are not allowed");
}
}
function calcAge(
input: Date | null | undefined | string | Person | PersonJson
): number {
assertNonNullable(input);
if (typeof input === "string") {
return diffInYears(parse(input));
} else if (input instanceof Date) {
return diffInYears(input);
} else if (isPerson(input)) {
return diffInYears(input.birthday);
} else {
return diffInYears(parse(input.birthday));
}
}
最后一个必须允许的问题是:当我们抛出错误时,为什么不一开始就直接将它们从参数的联合类型中移除呢?比如function calcAge(value: Date | string | Person | PersonJson): number {}
?
嗯,因为调用者可能会强迫我们包含它。例如,在 Angular 中,表单的输入值是undefined
禁用状态的。所以我们当然可以说,我们的程序逻辑不允许,undefined
因为我们没有提供禁用函数。然而,我们的表单库的类型包含它,所以它包含在参数类型中。
11.总结
本文介绍了处理联合类型的各种技巧,以及如何将其缩小到一种特定类型。TypeScript 能够验证这些模式,这意味着我们无需费力地超越编译器,而是能够生成尽可能类型安全的代码。
我们应该始终尝试使用类型收缩而不是类型断言。这意味着我们需要付出更多努力,但不必牺牲类型安全。
类型安全实际上是我们使用 TypeScript 而非 JavaScript 的主要原因。我们希望获得尽可能多的类型安全。从这个角度来看,正确使用类型收缩是应用程序开发人员最重要的 TypeScript 技能。
文章来源:https://dev.to/this-is-learning/type-safe-typescript-with-type-narrowing-5930