具有类型缩小功能的类型安全 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。为了简单起见,代码片段没有展示它们的实现。
inputin的类型calcAge可以是三种不同的类型。因此,return 语句将编译失败。
2.平等范围缩小
一个显而易见的开始是检查 是否input不具有值null和undefined。如果是的话,那么它只能是Date。
如果我们添加这个条件,TypeScript 就会理解它,并将-blockinput内部视为,然后我们就可以安全地调用。ifDatediffInYears
TypeScript 的这种“隐式理解”已经是我们的第一个类型保护,它被称为“平等缩小”。
function calcAge(input: Date | null | undefined): number {
  if (input !== null && input !== undefined) {
    return diffInYears(input);
  }
}
请注意,该条件并非直接检查或 的if类型。它针对的是值。同样,我们也无法针对 编写类型检查:是 的类型,而不是它的值。undefinednullDatevalue === DateDateinput
那么为什么我们可以使用undefinedand nullthen 呢?答案很明显。该类型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、''、和。nullNaNundefined
我们可以利用这一点。如果我们只把 放入条件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属性。birthdayDate
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类型。不过好消息是,它们实际上都是类实例,我们可以使用。DatePersoninstanceof
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 中。这就是为什么它们可以工作的原因。PersonCarinstanceof
好的,那么我们能做什么呢?
我们很幸运。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该属性,则其类型只能是。birthdayPerson
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 后端开发教程 - Java、Spring Boot 实战 - msg200.com
            后端开发教程 - Java、Spring Boot 实战 - msg200.com