打字稿类型警卫:编写更安全的代码的简介
#javascript #网络开发人员 #typescript #backend

在最近的一个项目中,我的任务是创建一个端点,该端点将通过训练有素的神经网络解析设备固件生成的文件。

该设备将使用多部分算法通过Node.js后端内的REST API发送JSON文件。到那时,任务似乎很简单。

但是,当我开始使用逻辑时,我意识到,假设负责处理输入的函数将始终接收预期输入。

那是我开始探索打字稿类型后卫的潜力的时候,这将有助于我确保输入的预期形状并避免运行时错误。

静态型检查的局限性

打字稿使您可以在编译时间内执行静态类型检查验证。只要我们处理接受输入的功能,这种效果就非常有效。

在评估表达式中产生的值。

现在,当我们收到来自系统边界之外的某些值时会发生什么?将其视为数据库查询,向外部API的请求以及与其他设备的蓝牙通信以命名的结果。在这些情况下,我们绝不应该对传入数据的样子做任何事情,因为如果这样做,我们将产生一种可能导致系统中意外行为的偏见。

那么在这些情况下我们该怎么办?如果输入类型不是我们所期望的,我们是否应该让它信仰并观察失败?值得庆幸的是,TypeScript为我们提供了非常强大的工具,可以在运行时执行这些方案的类型验证。

任何类型的行为和潜在问题

考虑到我上面所述的内容,假设我不想从解析函数的输入中假设任何东西。那我应该如何键入?一个人很容易倾向于使用aany type。

有问题吗?请注意当我们想访问any类型的属性时会发生什么。首先,让我们看一下:

class Dog {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  bark() {
    console.log("Woof woof");
  }
}

class Fish {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  swim() {
    console.log("Splash splash");
  }
}

const scooby = new Dog("Scooby Doo");
const nemo = new Fish("Nemo");

scooby.bark();
nemo.swim();
nemo.bark(); // ❌ Property 'bark' does not exist on type 'Fish'.

如果我们尝试从aFish实例中调用树皮FN,我们显然会得到一个例外,因为班级Fishbark

现在让我们看看稍微更改变量声明会发生什么。

const nemo: any = new Fish("Nemo");

nemo.swim();
nemo.bark(); // ✅ None compile errors.

// On execution:
// ❌ [ERR]: "Executed JavaScript Failed:"
// ❌ [ERR]: nemo.bark is not a function

自开始以来,我们知道这注定要发生。但是为什么这样呢?正如我们推断的那样,打字稿的编译器也应该能够做到。

让我们去any type的definition

Typescript还具有特殊类型any,您可以在不希望特定值引起类型检查错误时使用。

当一个值为any的值时,您可以访问它的任何属性(依次为any type any),称其为函数,将其分配给(或来自)任何类型的值,或句法合法的其他许多内容。

我们可以注意到,当您希望避免对变量检查静态检查时,any类型旨在使用。考虑到这一点,我们应该避免在几乎所有情况下使用这种类型。

因此,如果any类型在输入我们没有信息的内容中不起作用,是否有其他类型可以这样做?

unkonwn类型

在Typesscript 3.0中引入了unkonwn类型。您可以将其视为any类型的类型安全。如何?尽管我们都使用他们两个来键入我们一无所知的值,但主要的区别是,unknown在某种意义上是您要访问的任何属性或您想使用的任何类型的操作员的限制性更大,都会结果在编译器错误中。

以先前的情况为参考:

const nemo: unknown = new Fish("Nemo");

nemo.swim(); // ❌ 'nemo' is of type 'unknown'.
nemo.bark(); // ❌ 'nemo' is of type 'unknown'.

显而易见的是问自己:如果不允许使用任何形式的操作员或访问其属性的某些类型的东西,那么使用它的目的是什么?

这为我们提供了介绍type guards的基础。

介绍type guards

人们经常将动态类型的语言与缺乏类型混淆。

JavaScript中是否有类型?当然,如果您有任何疑问,请通过打开浏览器的控制台并执行简单句子来撤离它们:

var x = 5;
typeof x; // 'number'

如果JavaScript提供了执行类型验证的操作员,那么如果可以对某些流中变量的类型做出一些假设,这将很有趣。沿unknown类型的类型守卫可用于这些情况。

让我们在行动中查看它:

// Without using type guards
function isOdd(x: unknown) {
  return x % 2 === 0; // ❌ 'x' is of type 'unknown'
}

// Using type guards
function isOdd(x: unknown) {
  if (typeof x !== "number") return false;

  return x % 2 === 0; // ✅ None compile errors.
}

我们刚刚找到了一种限制给定变量类型的方法。在打字稿中,我们可以识别使用类型防护的五种不同方法:

  • instanceof关键字
  • typeof关键字
  • in关键字
  • 平等缩小类型守卫
  • 带有谓词的自定义类型守卫

我不会分析前4个,但可以随意访问文档,并在其中详细说明它们。让我们强调具有谓词的自定义。

自定义类型守卫

尽管将在某些情况下我们无法对词汇和句法分析进行任何推论,但一系列其他准则可能会使我们明确区分某物是否是特定类型的东西。

一个明确的示例将是枚举值。假设我们有一个枚举来定义爆米花软件包的不同大小,我们想评估是否为给定值,例如来自外部API调用,我们需要评估它是否确实是爆米花的枚举值。

我们该怎么做?打字稿使我们能够通过使用is运算符将任何谓词功能变成自定义类型的护罩。

export enum PopCornSize {
  Small = "Small",
  Medium = "Medium",
  Large = "Large",
}

const isPopCornSize = (size: unknown): size is PopCornSize =>
  Object.values(PopCornSize).includes(size as PopCornSize);

这很清楚证明某事是枚举的成员。您只需要检查其值是否与枚举的任何值匹配。****

通过一个示例将它们全部包裹在一起

让我们总结一下直到现在通过简化的现实情况掩盖的一切:

我们有一台用于医疗用途的机器,可以监视患者的心压以及他们正在移动多少。

心率是在BP中测量的,而运动是通过各种类型的传感器测量的,产生的离散值分别为0、1或2,该值分别对应于“低”,“媒介”和“高” 。

该机器会定期记录这些指标,并通过按需迹线的报告生成JSON。每个及其值及其值记录为时期的时间戳,并记录下来的时间。

格式如下所述:

{
  "log_uuid": 141,
  "version": 0,
  "user_metrics": [
    { "ts": 1673550600, "evt": 1, "val": 63 },
    { "ts": 1673550600, "evt": 2, "val": 1 },
    { "ts": 1673550630, "evt": 1, "val": 71 },
    { "ts": 1673550630, "evt": 2, "val": 0 },
    { "ts": 1673550660, "evt": 1, "val": 69 },
    { "ts": 1673550660, "evt": 2, "val": 2 },
    { "ts": 1673550690, "evt": 1, "val": 66 }
  ]
}

虽然可以记录预期的数据格式,但如果固件团队在没有适当通知的情况下进行更改,仍然存在接收意外数据或出现故障的风险。

我们如何确认该文件确实是JSON文件?为了确保其完整性,让我们从执行验证检查开始。

export const parseSleepSession = (file: Express.Multer.File): SleepSession => {
  const fileStr = file.buffer.toString();

  if (typeof fileStr !== "string") {
    throw new ForbiddenError("INVALID_JSON_FILE", { file: file.buffer });
  }

  const rawJson = JSON.parse(fileStr) as unknown;

  if (
    rawJson === null ||
    typeof rawJson !== "object" ||
    Array.isArray(rawJson)
  ) {
    throw new ForbiddenError("INVALID_JSON_FILE", { rawJson });
  }
};

重要的是要注意,如果JSON文件中的任何内容与我们的预期格式不匹配,则该程序将立即使用错误代码返回异常并将其与文件一起登录。虽然记录过程本身可能并不明显,但对于调试目的而言至关重要。就个人而言,我喜欢配置一种捕获异常的中间件,并根据某些情况,将其报告给Sentry等平台。

现在,我们知道该文件是JSON,让我们继续验证前两个字段。鉴于我们之前介绍的概念现在应该很重要。

const id = (rawJson as RawSleepMetrics).log_uuid as unknown;
const version = (rawJson as RawSleepMetrics).version as unknown;
const userMetricsRaw = (rawJson as RawSleepMetrics).user_metrics as unknown;

if (typeof id !== "number") {
  throw new ForbiddenError("INVALID_ID", { id, rawJson });
}

if (typeof version !== "number") {
  throw new ForbiddenError("INVALID_VERSION", { rawJson, version });
}

typeof操作员是一种类型的防护件,因此足以验证它们是数字字段。请记住,我们将限制自己验证文档格式的正确性。标识符可能已经存在于先前解析的文件或任何其他类型的验证的事实,它指的是业务逻辑将逃脱这些验证的范围。

让我们继续使用user_metrics阵列。让我们验证该字段的存在,并且确实是一个数组。

const userMetricsRaw = (rawJson as RawSleepMetrics).user_metrics as unknown;

if (isNil(userMetricsRaw)) {
  throw new ForbiddenError("USERMETRICS_MISSING", { rawJson });
}

if (!Array.isArray(userMetricsRaw)) {
  throw new ForbiddenError("USERMETRICS_NOT_AN_ARRAY", {
    rawJson,
    userMetricsRaw,
  });
}

上面的块中的语句转化为以下内容:

  • 该密钥存在属性。
  • 其价值不是零。
  • 值是数组。

简单,对吗?让我们继续使用user_metrics的内容。

const activityScore: ActivityScoreRegistry[] = [];
const heartRateMonitor: HeartRateMonitorRegistry[] = [];

userMetricsRaw.forEach((currMetric: unknown) => {
  if (
    currMetric === null ||
    typeof currMetric !== "object" ||
    Array.isArray(currMetric)
  ) {
    throw new ForbiddenError("USER_METRIC_INVALID", { currMetric, rawJson });
  }

  const timestampEpoch = (currMetric as RawMetricRecord).ts as unknown;
  const metric = (currMetric as RawMetricRecord).evt as unknown;
  const value = (currMetric as RawMetricRecord).val as unknown;
});

让我们从验证时间戳确实是日期开始。由于多种原因,这可能很棘手。首先,通过时期,我们通常指1970年1月1日从1970年1月1日经过的秒数,尽管某些语言将其定义为毫秒的计数(它们之间)。

另一方面,JS日期类允许您将时期传递给构造函数,以作为初始化实例的一种方式。 JS的作弊来自于以下事实,即它是在网络上没有任何破坏的范式下创建的事实,那就是,如果我们发生的事情是不是有效的时代的事,而不是返回的例外,那会发生什么是它将返回一个值,即数字NaN。验证看起来有点特殊,但我们会得到这样的事情:

if (typeof timestampEpoch !== "number") {
  throw new ForbiddenError("INVALID_TIMESTAMP", { rawJson, timestampEpoch });
}

if (!isEpochInSeconds(timestampEpoch)) {
  throw new ForbiddenError("INVALID_TIMESTAMP_NOT_IN_EPOCH_SECONDS", {
    rawJson,
    timestampEpoch,
  });
}

const timestamp = new Date(timestampEpoch * 1000);
if (isNaN(timestamp.getTime()))
  return new ForbiddenError("INVALID_TIMESTAMP", { rawJson, timestamp });

继续前进,在JSON中,我们可以看到一个字段,指示它是什么类型的度量。如果您问我,这不是JSON结构的最佳选择。就我个人而言,我会发送两个单独的列表,其中包括运动和心率的值,但是,这就是它。

有两种类型的指标,因此我们可以清楚地将其解释为列表。现在,我们如何验证枚举?我们将不得不求助于创建一个自定义型护罩。

enum Metric {
  ActivityScore = 2,
  HeartRateMonitor = 1,
}

const isMetric = (m: unknown): m is Metric =>
  Object.values(Metric).includes(m as Metric);

if (!isMetric(metric)) {
  throw new ForbiddenError("INVALID_METRIC_ENUM", { metric, rawJson });
}

if (typeof value !== "number") {
  throw new ForbiddenError("INVALID_METRIC_VALUE", { rawJson, value });
}

既然我们已经解析了枚举,其余的应该像检查它是什么类型的度量并执行验证一样容易,此时应该很简单。

if (metric === Metric.ActivityScore) {
  if (value < ACTIVITY_SCORE_LOWER_LEVEL) {
    throw new ForbiddenError("INVALID_ACTIVITY_SCORE_BELOW_RANGE", {
      rawJson,
      value,
    });
  }

  if (ACTIVITY_SCORE_UPPER_LEVEL < value) {
    throw new ForbiddenError("INVALID_ACTIVITY_SCORE_ABOVE_RANGE", {
      rawJson,
      value,
    });
  }

  activityScore.push(entry);
}

if (metric === Metric.HeartRateMonitor) {
  if (value < HEART_RATE_LOWER_LEVEL) {
    throw new ForbiddenError("INVALID_HEART_RATE_BELOW_RANGE", {
      rawJson,
      value,
    });
  }

  if (HEART_RATE_UPPER_LEVEL < value) {
    throw new ForbiddenError("INVALID_HEART_RATE_ABOVE_RANGE", {
      rawJson,
      value,
    });
  }

  heartRateMonitor.push(entry);
}

这样,我们的解析器就被覆盖了。现在,我们可以确认正在进行所有必要的验证,以确保JSON以预期的格式出现。请注意,每当我们与无法触及的系统交互时,我们都应执行此类验证(无论是数据库,API等)。

欣赏类型的后卫如何使我们能够动态执行类型验证以及语言使我们使用它们的轻松性是很有趣的。

结论

有时,在我们渴望具有高度可预测性的情况下,我们可能会犯错误,因为他们想键入诸如API或查询数据库的请求之类的内容。

陷入这些类型的方案的错误在于不了解来自范围之外的要素的那些实体需要以与内部的方式截然不同的方式对待。

在构想什么类型的结构时,来自外部的信息将拥有的信息将不够,从某种意义上说,我们永远不会控制这些领域。

正如我们在这篇文章中介绍的那样,最惯用的方法是避免做出假设,而是在边界上执行必要的验证。这使我们能够在收到的数据不符合预期格式的灾难性情况下采取必要的行动。通过这样做,我们可以在早期触发警报,并避免可能难以检测到的潜在错误。