具有类型缩小功能的类型安全 TypeScript

2025-06-07

具有类型缩小功能的类型安全 TypeScript

本文介绍了一些常用模式,可以最大限度地发挥 TypeScript 编写类型安全代码的潜力。这些技术都属于同一类,我们称之为类型收缩。

源代码可以在以下位置找到:https://github.com/rainerhahnekamp/type-safe-typescript-with-type-narrowing

如果您更喜欢视频而不是文章,那么这适合您:

1. 简介

每当我们处理一个可以包含多种类型的变量(例如unknown或 联合类型)时,我们都可以应用类型收缩,将其“缩小”为一种特定类型。我们与 TypeScript 编译器协同工作,因为它能够理解我们代码的上下文,并保证这种收缩以完全类型安全的方式进行。

假设我们有一个函数,其参数类型为Date | undefined。每次函数执行时,变量的类型可以是Dateundefined,但不能同时是两种类型。

function print(value: Date | undefined): void {}
Enter fullscreen mode Exit fullscreen mode

如果我们应用一个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
}
Enter fullscreen mode Exit fullscreen mode

还有一种非常类似的技术,称为类型断言。乍一看,它似乎是更简单的选择。但就类型安全而言,它并非如此。我们需要手动设置类型,从而覆盖编译器。

如果编译器可以和我们对话,它会说这样的话:“好的,你知道你在做什么,但如果出了问题,不要怪我。”

因此,我们应该避免类型断言,并始终支持类型收缩。(总的来说:试图比编译器更聪明永远不是一个好主意。)

function print(input: Date | undefined): void {
  (input as Date).getTime(); // type assertion - don't!
}
Enter fullscreen mode Exit fullscreen mode

经过这个简短的介绍之后,让我们举一个例子来看一下主要类型缩小技术的实际应用。

这将是我们的“工作台”:

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
}
Enter fullscreen mode Exit fullscreen mode

calcAge,应该返回 - 正如名称所示 - 年龄。

此外,我们使用了两个实用函数diffInYearsparse。为了简单起见,代码片段没有展示它们的实现。

inputin的类型calcAge可以是三种不同的类型。因此,return 语句将编译失败。

2.平等范围缩小

一个显而易见的开始是检查 是否input不具有值nullundefined。如果是的话,那么它只能是Date

如果我们添加这个条件,TypeScript 就会理解它,并将-blockinput内部视为,然后我们就可以安全地调用ifDatediffInYears

TypeScript 的这种“隐式理解”已经是我们的第一个类型保护,它被称为“平等缩小”。

function calcAge(input: Date | null | undefined): number {
  if (input !== null && input !== undefined) {
    return diffInYears(input);
  }
}
Enter fullscreen mode Exit fullscreen mode

请注意,该条件并非直接检查或 的if类型。它针对的是值。同样,我们也无法针对 编写类型检查是 的类型而不是它的值。undefinednullDatevalue === DateDateinput

那么为什么我们可以使用undefinedand nullthen 呢?答案很明显。该类型undefined只有一个值,即 undefined。同样如此null。该类型null只能有一个值,即 null。

所以我们的条件实际上做的是排除所有可能出现的undefined或的值null。出于显而易见的原因,我们不想排除类型的所有可能值Date:)

3.typeof

让我们尝试另一种类型保护。JavaScript 提供了typeof这种类型保护,我们可以将其放在任何变量前面。顾名思义,它会返回变量类型的名称string……其实不然。否则我们早就读到本文的结尾了 :)。

在 JavaScript 中,我们有七种原始类型,其余的只是 类型object。原始类型包括booleanstringnumberundefinednullbigintsymbol

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

不行。因为typeof返回的null也是“object”,所以编译会失败。所以目前我们用不着typeof类型保护,不过我们还是记住它吧。以后可能会用到它。

4. 真实性缩小

JavaScript 发明了两个新的英文术语:Falsy 和 Truthy。如果我们将一个假值放入一个条件中,它会返回一个真值false,并且true返回一个假值。JavaScript 中有一个详尽的假值列表,分别是false00n''nullNaNundefined

我们可以利用这一点。如果我们只把 放入条件inputif,那么结果就是true如果值不是undefinednull。在我们的例子中,这与相等运算符非常相似,只是更简洁一些。

function calcAge(input: Date | null | undefined): number {
  if (input) {
    return diffInYears(input);
  }
}
Enter fullscreen mode Exit fullscreen mode

这里我们漏掉了一点。如果值是空字符串,它也会为 false,这样就不会进入if-condition 了。不过,对于我们的用例来说,只要我们意识到这一点,这就可以了。

让我们的例子更有趣一些。我们添加了第四种可能的类型,即string。现在是时候typeof进入游戏了。

首先,我们通过真实性缩小来消除值为null或的可能性undefined,并返回值 0(我们稍后会改进这一点)。

那么它只能在Date和之间string。这里我们可以使用typeof来检查 是否inputstring。如果不是,那它只能是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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

显然,该子句中的最后一个 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

请注意,instanceof返回的true是整个类继承链。因此,如果Person继承自类Entityinstanceof 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
  }
}
Enter fullscreen mode Exit fullscreen mode

哎哟,又编译错误了。我们不能直接instanceof在这里使用吗?不行。PersonCar都只是一个元素,它只存在于 TypeScript 中。当它被编译成 JavaScript 时, 和type的定义就不存在了。另一方面,类也存在于 JavaScript 中。这就是为什么它们可以工作的原因。PersonCarinstanceof

好的,那么我们能做什么呢?

我们很幸运。PersonCar共享相同的属性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);
  }
}
Enter fullscreen mode Exit fullscreen mode

我们不需要为 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

在这种情况下,我们可以使用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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

这段代码编译通过了,看起来一切正常,但并不完美。如果我们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;
}
Enter fullscreen mode Exit fullscreen mode

请注意我们通常具有返回类型的特殊符号。

我们像使用其他函数一样使用谓词并将其放入最后一个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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

多么庞大的代码量啊!不幸的是,如果我们想要完全类型安全,这是必须的。

该函数展示了 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;
}
Enter fullscreen mode Exit fullscreen mode

首先,我们定义模式并将其存储在 下personSchema。有了现有的模式,我们可以让 zod 自动为我们生成类型。这适用于z.infer。这简化了操作,并确保我们在更改类型时不会重复工作Person

safeParse最后,如果值不是 类型,我们使用不会抛出错误的方法Person。它通过属性返回结果success

这比一直手动编写验证代码要好得多。

10.断言函数

最后一个功能不是类型保护,但非常方便,那就是断言功能。

unknown当类型为、null或空字符串时,我们在第一个条件中返回数字 0 。

返回数字 0 是一种方法。另一种方法是抛出错误。

如果我们抛出错误,TypeScript 的类型缩小将从函数的其余代码中排除undefined或。null

断言函数是一个特殊的函数,它的作用正是如此。如果我们调用它,它会——类似于类型保护——缩小参数范围,但不会返回布尔值。它保证,如果值不是指定的类型,它会抛出一个错误。

为了处理那些不应该是undefinednull的类型,TypeScript 甚至提供了一个特殊的类型实用程序。它名为NonNullable<T>。这个类型实用程序的意思是,无论类型T是什么,它都不是undefinednull。让我们看看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));
  }
}
Enter fullscreen mode Exit fullscreen mode

最后一个必须允许的问题是:当我们抛出错误时,为什么不一开始就直接将它们从参数的联合类型中移除呢?比如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
PREV
WebSocket 101
NEXT
ReactiveScript 的探索