TypeScript中使用字面表达式构造断言的方法

99 阅读9分钟

随着TypeScript 3.4,const 断言被添加到语言中。const 断言是一种特殊的类型断言,其中使用了const 关键字而不是类型名称。在这篇文章中,我将解释const 断言是如何工作的,以及为什么我们可能想要使用它们。

const 断言的动因

假设我们写了下面这个fetchJSON 函数。它接受一个URL和一个HTTP请求方法,使用浏览器的Fetch API对该URL进行GET或POST请求,并将响应反序列化为JSON。

function fetchJSON(url: string, method: "GET" | "POST") {
  return fetch(url, { method }).then(response => response.json());
}

我们可以调用这个函数,将一个任意的URL传给url 参数,将字符串"GET" 传给method 参数。注意,我们在这里使用了两个字符串字面

// OK, no type error
fetchJSON("https://example.com/", "GET").then(data => {
  // ...
});

为了验证这个函数调用的类型是否正确,TypeScript将根据函数声明中定义的参数类型检查函数调用的所有参数类型。在这种情况下,两个参数的类型都可以分配给参数类型,因此这个函数调用的类型检查正确。

现在我们来做一点重构。HTTP规范定义了各种额外的请求方法,如DELETE、HEAD、PUT和其他。我们可以定义一个HTTPRequestMethod 枚举式的映射对象,列出各种请求方法。

const HTTPRequestMethod = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE",
};

现在我们可以用HTTPRequestMethod.GET 替换我们的fetchJSON 函数调用中的字符串字面"GET"

fetchJSON("https://example.com/", HTTPRequestMethod.GET).then(data => {
  // ...
});

但是现在,TypeScript产生了一个类型错误!类型检查器指出,HTTPRequestMethod.GET 的类型不能被分配给method 参数的类型。

// Error: Argument of type 'string' is not assignable
// to parameter of type '"GET" | "POST"'.

为什么呢?HTTPRequestMethod.GET 评估为字符串"GET" ,也就是我们之前作为参数传递的值。属性HTTPRequestMethod.GET 和字符串字头"GET" 的类型之间有什么区别?要回答这个问题,我们必须了解字符串字面类型是如何工作的,以及TypeScript如何执行字面类型拓宽

String Literal Types

让我们看看当我们将值"GET" 赋值给一个用const 关键字声明的变量时,它的类型。

// Type: "GET"
const httpRequestMethod = "GET";

TypeScript为我们的httpRequestMethod 变量推断出了类型"GET""GET" ,就是所谓的字符串字面类型。每个字面类型都精确地描述了一个值,例如,一个特定的字符串、数字、布尔值或枚举成员。在我们的例子中,我们要处理的是字符串值"GET" ,所以我们的字面类型是字符串字面类型"GET"

请注意,我们已经用const 关键字声明了httpRequestMethod 这个变量。因此,我们知道,以后不可能重新分配这个变量;它将永远保持值"GET" 。TypeScript理解这一点,并自动推断出字符串字面类型"GET" ,以表示类型系统中的这一信息。

字面类型的拓宽

现在让我们看看如果我们使用let 关键字(而不是const )来声明httpRequestMethod 这个变量会发生什么。

// Type: string
let httpRequestMethod = "GET";

TypeScript现在执行了所谓的字面类型拓宽httpRequestMethod 变量被推断为具有string 类型。我们用字符串"GET" 来初始化httpRequestMethod ,但由于该变量是用let 关键字声明的,所以我们可以在以后为它赋值。

// Type: string
let httpRequestMethod = "GET";

// OK, no type error
httpRequestMethod = "POST";

后来分配的值"POST" 是类型正确的,因为httpRequestMethod 的类型是string 。TypeScript推断出了类型string ,因为我们很可能想在以后改变使用let 关键字声明的变量的值。如果我们不想重新分配变量,我们应该使用const 关键字来代替。

现在让我们来看看我们的枚举式映射对象。

const HTTPRequestMethod = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE",
};

HTTPRequestMethod.GET 是什么类型的?让我们来看看。

// Type: string
const httpRequestMethod = HTTPRequestMethod.GET;

TypeScript 为我们的httpRequestMethod 变量推断了类型string 。这是因为我们用HTTPRequestMethod.GET (它的类型是string )来初始化这个变量,所以类型string 被推断出来。

那么为什么HTTPRequestMethod.GET 的类型是string ,而不是"GET" ?我们用字符串字面意思"GET" 来初始化GET 属性,而HTTPRequestMethod 对象本身是用const 关键字定义的。结果的类型不应该是字符串字面的类型"GET" 吗?

TypeScript为HTTPRequestMethod.GET (以及所有其他属性)推断出类型string ,是因为我们可以在以后为任何一个属性分配另一个值。对我们来说,这个具有ALL_UPPERCASE属性名称的对象看起来像一个枚举,它定义了不会随时间变化的字符串常量。然而,对TypeScript来说,这只是一个有几个属性的普通对象,而这些属性恰好被初始化为字符串值。

下面的例子更明显地说明了为什么TypeScript不应该为用字符串字头初始化的对象属性推断出一个字符串字头类型。

// Type: { name: string, jobTitle: string }
const person = {
  name: "Marius Schulz",
  jobTitle: "Software Engineer",
};

// OK, no type error
person.jobTitle = "Front End Engineer";

如果jobTitle 属性被推断为具有"Software Engineer" 类型,那么如果我们以后试图分配除"Software Engineer" 以外的任何字符串,这将是一个类型错误。我们对"Front End Engineer" 的赋值将不会是类型正确的。对象属性默认是可变的,所以我们不希望TypeScript推断出的类型限制我们进行完全有效的变异。

那么,我们如何使我们的HTTPRequestMethod.GET 属性在函数调用中的使用类型检查正确呢?我们需要先了解非拓扑字面类型

非拓扑字面类型

TypeScript有一种特殊的字面类型,被称为非拓扑字面类型。顾名思义,非拓扑字面类型不会被拓扑到一个更通用的类型。例如,在通常会发生类型拓宽的情况下,非拓宽的字符串字面类型"GET" 不会被拓宽为string

我们可以通过对每个属性值应用相应的字符串字面类型的类型断言,使我们的HTTPRequestMethod 对象的属性接受一个非拓扑的字面类型。

const HTTPRequestMethod = {
  CONNECT: "CONNECT" as "CONNECT",
  DELETE: "DELETE" as "DELETE",
  GET: "GET" as "GET",
  HEAD: "HEAD" as "HEAD",
  OPTIONS: "OPTIONS" as "OPTIONS",
  PATCH: "PATCH" as "PATCH",
  POST: "POST" as "POST",
  PUT: "PUT" as "PUT",
  TRACE: "TRACE" as "TRACE",
};

现在,让我们再次检查一下HTTPRequestMethod.GET 的类型。

// Type: "GET"
const httpRequestMethod = HTTPRequestMethod.GET;

而事实上,现在httpRequestMethod 变量的类型是"GET" ,而不是stringHTTPRequestMethod.GET 的类型(即"GET" )可以分配给method 参数的类型(即"GET" | "POST" ),因此fetchJSON 的函数调用现在可以正确地进行类型检查。

// OK, no type error
fetchJSON("https://example.com/", HTTPRequestMethod.GET).then(data => {
  // ...
});

这是个好消息,但是看看我们为了达到这一点而不得不写的类型断言的数量吧。那是一个很大的噪音!现在每个键/值对都包含了三次HTTP请求方法的名称。我们可以简化这个定义吗?使用TypeScript的const 断言功能,我们当然可以!

const 字面表达式的断言

我们的HTTPRequestMethod 变量是用一个字面表达式初始化的,这个字面表达式是一个具有几个属性的对象字面,所有这些属性都是用字符串字面初始化的。从TypeScript 3.4开始,我们可以对字面表达式应用const 断言。

const HTTPRequestMethod = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE",
} as const;

const 断言是一个特殊的类型断言,它使用const 关键字而不是一个特定的类型名称。在一个字面表达式上使用const 断言有以下效果。

  1. 字面表达式中的任何字面类型都不会被加宽
  2. 对象字面将得到readonly 属性。
  3. 数组字面将成为readonly 图元。

有了const 断言,上述HTTPRequestMethod 的定义就等同于以下内容。

const HTTPRequestMethod: {
  readonly CONNECT: "CONNECT";
  readonly DELETE: "DELETE";
  readonly GET: "GET";
  readonly HEAD: "HEAD";
  readonly OPTIONS: "OPTIONS";
  readonly PATCH: "PATCH";
  readonly POST: "POST";
  readonly PUT: "PUT";
  readonly TRACE: "TRACE";
} = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE",
};

我们不希望必须手写这个定义。它很冗长,而且包含大量的重复;注意每个HTTP请求方法都被写了四次。const 断言as const ,另一方面,它非常简洁,是整个例子中唯一的一点TypeScript特定语法。

另外,请注意,现在每个属性都被打成了readonly 。如果我们试图给一个只读的属性赋值,TypeScript会产生一个类型错误。

// Error: Cannot assign to 'GET'
// because it is a read-only property.
HTTPRequestMethod.GET = "...";

通过const 断言,我们已经给了我们的HTTPRequestMethod 对象类似枚举的特性。但是正确的TypeScript枚举又是怎样的呢?

使用TypeScript的枚举

另一个可能的解决方案是使用TypeScript枚举,而不是一个普通的对象字面。我们可以使用enum 关键字来定义HTTPRequestMethod ,像这样。

enum HTTPRequestMethod {
  CONNECT = "CONNECT",
  DELETE = "DELETE",
  GET = "GET",
  HEAD = "HEAD",
  OPTIONS = "OPTIONS",
  PATCH = "PATCH",
  POST = "POST",
  PUT = "PUT",
  TRACE = "TRACE",
}

TypeScript枚举是用来描述命名的常量的,这就是为什么他们的成员总是只读的。字符串枚举的成员有一个字符串字面类型。

// Type: "GET"
const httpRequestMethod = HTTPRequestMethod.GET;

这意味着当我们把HTTPRequestMethod.GET 作为method 参数的参数时,我们的函数调用将进行类型检查。

// OK, no type error
fetchJSON("https://example.com/", HTTPRequestMethod.GET).then(data => {
  // ...
});

然而,一些开发者不喜欢在他们的代码中使用TypeScript枚举,因为enum 语法本身就不是有效的JavaScript。TypeScript编译器将为我们上面定义的HTTPRequestMethod 枚举发出以下JavaScript代码。

var HTTPRequestMethod;
(function (HTTPRequestMethod) {
  HTTPRequestMethod["CONNECT"] = "CONNECT";
  HTTPRequestMethod["DELETE"] = "DELETE";
  HTTPRequestMethod["GET"] = "GET";
  HTTPRequestMethod["HEAD"] = "HEAD";
  HTTPRequestMethod["OPTIONS"] = "OPTIONS";
  HTTPRequestMethod["PATCH"] = "PATCH";
  HTTPRequestMethod["POST"] = "POST";
  HTTPRequestMethod["PUT"] = "PUT";
  HTTPRequestMethod["TRACE"] = "TRACE";
})(HTTPRequestMethod || (HTTPRequestMethod = {}));

完全由你来决定你是想使用普通的对象字面还是正确的TypeScript枚举。如果你想尽可能地接近JavaScript,只使用TypeScript的类型注释,你可以坚持使用普通的对象字面和const 断言。如果你不介意使用非标准的语法来定义枚举,并且你喜欢简洁,TypeScript枚举可能是一个不错的选择。

const 断言用于其他类型

你可以将const 断言应用到...

  • 字符串。
  • 数字字样。
  • 布尔字样。
  • 数组字元,以及
  • 对象字样。

例如,你可以这样定义一个ORIGIN 变量,描述二维空间的原点。

const ORIGIN = {
  x: 0,
  y: 0,
} as const;

这相当于(而且比)下面的声明要简洁得多。

const ORIGIN: {
  readonly x: 0;
  readonly y: 0;
} = {
  x: 0,
  y: 0,
};

另外,你也可以将一个点的表示方法建模为X和Y坐标的一个元组。

// Type: readonly [0, 0]
const ORIGIN = [0, 0] as const;

因为有const 的断言,ORIGIN 被打成了readonly [0, 0] 。如果没有这个断言,ORIGIN 会被推断为类型为number[]

// Type: number[]
const ORIGIN = [0, 0];