TypeScript 中的动态静态类型

已发表: 2022-03-10
快速总结↬在本文中,我们将了解 TypeScript 的一些更高级的特性,例如联合类型、条件类型、模板文字类型和泛型。 我们希望以一种我们可以在大多数错误发生之前捕获它们的方式来形式化最动态的 JavaScript 行为。 我们在 50 课中应用了从 TypeScript 所有章节中学到的一些知识,这本书我们已于 2020 年底在 Smashing Magazine 上出版。如果您有兴趣了解更多信息,请务必查看!

JavaScript 是一种天生的动态编程语言。 作为开发人员,我们可以毫不费力地表达很多东西,而语言及其运行时会弄清楚我们打算做什么。 这就是 JavaScript 如此受初学者欢迎的原因,也是让经验丰富的开发人员富有成效的原因! 但是有一个警告:我们需要保持警惕! 错误、拼写错误、正确的程序行为:很多都发生在我们的脑海中!

看看下面的例子。

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { res.status(20).send({ message: "Got you, user " + req.params.userId }); } })

我们有一个 https://expressjs.com/ 风格的服务器,它允许我们定义路由(或路径),并在请求 URL 时执行回调。

回调有两个参数:

  1. request对象。
    在这里,我们获得了有关使用的 HTTP 方法的信息(例如 GET、POST、PUT、DELETE)以及传入的其他参数。在此示例中, userID应该映射到包含用户 ID 的参数userID
  2. responsereply对象。
    在这里,我们要准备从服务器到客户端的正确响应。 我们希望发送正确的状态代码(方法status )并通过网络发送 JSON 输出。

我们在这个例子中看到的东西被大大简化了,但很好地说明了我们在做什么。 上面的例子也充满了错误! 看一看:

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { /* Error 1 */ res.status(20).send({ /* Error 2 */ message: "Welcome, user " + req.params.userId /* Error 3 */ }); } })

哦哇! 三行实现代码,三个错误? 发生了什么?

  1. 第一个错误是细微的。 当我们告诉我们的应用我们想要监听GET请求(因此app.get )时,我们只有在请求方法是POST时才做一些事情。 在我们的应用程序的这个特定点上, req.method不能是POST 。 所以我们永远不会发送任何响应,这可能会导致意外超时。
  2. 太好了,我们明确发送了状态码! 但是, 20不是有效的状态码。 客户可能不明白这里发生了什么。
  3. 这是我们要发回的响应。 我们访问解析的参数,但有一个平均错字。 这是userID不是userId 。 我们所有的用户都会收到“欢迎,用户未定义!”的问候。 你肯定在野外见过的东西!

像这样的事情发生了! 尤其是在 JavaScript 中。 我们获得了表现力——我们不必为类型而烦恼——而是必须密切关注我们正在做的事情。

这也是 JavaScript 受到不习惯动态编程语言的程序员强烈反对的地方。 他们通常让编译器指出可能的问题并提前发现错误。 当他们对您必须在脑海中做的额外工作量以确保一切正常时皱眉头时,他们可能会变得傲慢。 他们甚至可能会告诉你 JavaScript 没有类型。 这不是真的。

TypeScript 的首席架构师 Anders Hejlsberg 在他的 MS Build 2017 主题演讲中说:“并不是说 JavaScript 没有类型系统。 只是没有办法将其正式化”。

这就是 TypeScript 的主要目的。 TypeScript 想比你更好地理解你的 JavaScript 代码。 如果 TypeScript 无法理解您的意思,您可以通过提供额外的类型信息来提供帮助。

跳跃后更多! 继续往下看↓

基本打字

这就是我们现在要做的。 让我们从 Express 风格的服务器中get方法并添加足够的类型信息,以便我们可以排除尽可能多的错误类别。

我们从一些基本的类型信息开始。 我们有一个指向get函数的app对象。 get函数接受path (一个字符串)和一个回调。

 const app = { get, /* post, put, delete, ... to come! */ }; function get(path: string, callback: CallbackFn) { // to be implemented --> not important right now }

虽然string是一种基本的所谓的原始类型,但CallbackFn是一种我们必须显式定义的复合类型。

CallbackFn是一个接受两个参数的函数类型:

  • req ,其类型为ServerRequest
  • reply类型为ServerReply

CallbackFn返回void

 type CallbackFn = (req: ServerRequest, reply: ServerReply) => void;

ServerRequest在大多数框架中是一个相当复杂的对象。 我们为演示目的做了一个简化版本。 我们传入一个method字符串,用于"GET""POST""PUT""DELETE"等。它还有一个params记录。 记录是将一组键与一组属性相关联的对象。 现在,我们希望允许将每个string键映射到string属性。 我们稍后重构这个。

 type ServerRequest = { method: string; params: Record<string, string>; };

对于ServerReply ,我们布置了一些功能,因为知道真正的ServerReply对象还有更多功能。 send函数接受一个带有我们要发送的数据的可选参数。 我们可以使用status功能设置状态代码。

 type ServerReply = { send: (obj?: any) => void; status: (statusCode: number) => ServerReply; };

这已经是一些东西了,我们可以排除几个错误:

 app.get("/api/users/:userID", function(req, res) { if(req.method === 2) { // ^^^^^^^^^^^^^^^^^ Error, type number is not assignable to string res.status("200").send() // ^^^^^ Error, type string is not assignable to number } })

但是我们仍然可以发送错误的状态码(任何数字都是可能的)并且不知道可能的 HTTP 方法(任何字符串都是可能的)。 让我们细化我们的类型。

较小的套装

您可以将原始类型视为该特定类别的所有可能值的集合。 例如, string包括所有可以用 JavaScript 表示的字符串, number包括所有可能的具有双浮点精度的数字。 boolean包括所有可能的布尔值,它们是truefalse

TypeScript 允许您将这些集合细化为更小的子集。 例如,我们可以创建一个Method类型,其中包含我们可以为 HTTP 方法接收的所有可能的字符串:

 type Methods= "GET" | "POST" | "PUT" | "DELETE"; type ServerRequest = { method: Methods; params: Record<string, string>; };

Method是较大string集合中的较小集合。 Method是文字类型的联合类型。 文字类型是给定集合的最小单位。 一个文字字符串。 一个字面数字。 没有歧义。 这只是"GET" 。 您将它们与其他文字类型放在一个联合中,创建您拥有的任何更大类型的子集。 您还可以使用stringnumber的文字类型或不同的复合对象类型来做一个子集。 有很多可能性可以将文字类型组合并放入联合中。

这对我们的服务器回调有直接影响。 突然之间,我们可以区分这四种方法(或者更多,如果需要的话),并且可以用尽代码中的所有可能性。 TypeScript 将指导我们:

 app.get("/api/users/:userID", function (req, res) { // at this point, TypeScript knows that req.method // can take one of four possible values switch (req.method) { case "GET": break; case "POST": break; case "DELETE": break; case "PUT": break; default: // here, req.method is never req.method; } });

对于您所做的每一个case陈述,TypeScript 都可以为您提供有关可用选项的信息。 自己试试吧。 如果你用尽了所有选项,TypeScript 会在你的default分支中告诉你这never发生。 这实际上是never类型,这意味着您可能已达到需要处理的错误状态。

那是一类错误少。 我们现在确切地知道哪些可能的 HTTP 方法可用。

我们可以对 HTTP 状态码做同样的事情,通过定义statusCode可以采用的有效数字的子集:

 type StatusCode = 100 | 101 | 102 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 444 | 449 | 450 | 451 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 598 | 599; type ServerReply = { send: (obj?: any) => void; status: (statusCode: StatusCode) => ServerReply; };

类型StatusCode再次是联合类型。 有了这个,我们排除了另一类错误。 突然,这样的代码失败了:

 app.get("/api/user/:userID", (req, res) => { if(req.method === "POS") { // ^^^^^^^^^^^^^^^^^^^ 'Methods' and '"POS"' have no overlap. res.status(20) // ^^ '20' is not assignable to parameter of type 'StatusCode' } })
我们的软件变得更加安全! 但我们可以做得更多!

输入泛型

当我们使用app.get定义路由时,我们隐含地知道唯一可能的 HTTP 方法是"GET" 。 但是使用我们的类型定义,我们仍然必须检查联合的所有可能部分。

CallbackFn的类型是正确的,因为我们可以为所有可能的 HTTP 方法定义回调函数,但是如果我们显式调用app.get ,最好节省一些额外的步骤,这些步骤只需要符合类型。

TypeScript 泛型可以提供帮助! 泛型是 TypeScript 的主要功能之一,它允许您从静态类型中获得最动态的行为。 在 TypeScript in 50 Lessons 中,我们在最后三章中深入研究了泛型的所有复杂性及其独特的功能。

您现在需要知道的是,我们希望以一种可以指定Methods的一部分而不是整个集合的方式定义ServerRequest 。 为此,我们使用通用语法来定义参数,就像我们对函数所做的那样:

 type ServerRequest<Met extends Methods> = { method: Met; params: Record<string, string>; };

这就是发生的事情:

  1. ServerRequest成为泛型类型,如尖括号所示
  2. 我们定义了一个名为Met的泛型参数,它是Methods类型的子集
  3. 我们使用这个泛型参数作为泛型变量来定义方法。

我还鼓励您查看我关于命名泛型参数的文章。

通过该更改,我们可以指定不同的ServerRequest ,而无需重复:

 type OnlyGET = ServerRequest<"GET">; type OnlyPOST = ServerRequest<"POST">; type POSTorPUT = ServerRquest<"POST" | "PUT">;

由于我们更改了ServerRequest的接口,我们必须对使用ServerRequest的所有其他类型进行更改,例如CallbackFnget函数:

 type CallbackFn<Met extends Methods> = ( req: ServerRequest<Met>, reply: ServerReply ) => void; function get(path: string, callback: CallbackFn<"GET">) { // to be implemented }

使用get函数,我们将一个实际参数传递给我们的泛型类型。 我们知道这不仅仅是Methods的一个子集,我们确切地知道我们正在处理哪个子集。

现在,当我们使用app.get时,我们只有req.method的可能值:

 app.get("/api/users/:userID", function (req, res) { req.method; // can only be get });

这确保了我们在创建app.get回调时不会假设像"POST"或类似的 HTTP 方法可用。 我们现在确切地知道我们正在处理什么,所以让我们在我们的类型中反映这一点。

我们已经做了很多工作来确保request.method的类型合理并代表实际的事务状态。 对Methods联合类型进行子集化的一个好处是,我们可以在app.get之外创建一个类型安全的通用回调函数:

 const handler: CallbackFn<"PUT" | "POST"> = function(res, req) { res.method // can be "POST" or "PUT" }; const handlerForAllMethods: CallbackFn<Methods> = function(res, req) { res.method // can be all methods }; app.get("/api", handler); // ^^^^^^^ Nope, we don't handle "GET" app.get("/api", handlerForAllMethods); // This works

键入参数

我们尚未触及的是键入params对象。 到目前为止,我们得到了一个允许访问每个string键的记录。 我们现在的任务是让它更具体一点!

我们通过添加另一个通用变量来做到这一点。 一个用于方法,一个用于我们Record中可能的键:

 type ServerRequest<Met extends Methods, Par extends string = string> = { method: Met; params: Record<Par, string>; };

泛型类型变量Par可以是类型string的子集,默认值是每个字符串。 有了它,我们可以告诉ServerRequest我们期望哪些键:

 // request.method = "GET" // request.params = { // userID: string // } type WithUserID = ServerRequest<"GET", "userID">

让我们将新参数添加到我们的get函数和CallbackFn类型中,这样我们就可以设置请求的参数:

 function get<Par extends string = string>( path: string, callback: CallbackFn<"GET", Par> ) { // to be implemented } type CallbackFn<Met extends Methods, Par extends string> = ( req: ServerRequest<Met, Par>, reply: ServerReply ) => void;

如果我们没有显式设置Par ,则该类型会像我们习惯的那样工作,因为Par默认为string 。 如果我们设置它,我们突然就有了req.params对象的正确定义!

 app.get<"userID">("/api/users/:userID", function (req, res) { req.params.userID; // Works!! req.params.anythingElse; // doesn't work!! });

那太棒了! 不过,有一点可以改进。 我们仍然可以将每个字符串传递给app.getpath参数。 如果我们也能在其中反映Par不是更好吗?

我们可以! 随着 4.1 版的发布,TypeScript 能够创建模板文字类型。 从语法上讲,它们就像字符串模板文字一样工作,但在类型级别上。 我们能够将集合string拆分为具有字符串文字类型的子集(就像我们对方法所做的那样),模板文字类型允许我们包含整个字符串范围。

让我们创建一个名为IncludesRouteParams的类型,我们希望确保Par正确地包含在 Express 样式的参数名称前添加冒号的方式中:

 type IncludesRouteParams<Par extends string> = | `${string}/:${Par}` | `${string}/:${Par}/${string}`;

泛型类型IncludesRouteParams采用一个参数,它是string的子集。 它创建两个模板文字的联合类型:

  1. 第一个模板文字以任何string开头,然后包括一个/字符,后跟一个:字符,然后是参数名称。 这可以确保我们捕获参数位于路由字符串末尾的所有情况。
  2. 第二个模板文字以任何string开头,后跟/:和参数名称的相同模式。 然后我们有另一个/字符,后跟任何字符串。 联合类型的这个分支确保我们捕获参数位于路由中某处的所有情况。

这是具有参数名称userIDIncludesRouteParams在不同测试用例中的行为方式:

 const a: IncludeRouteParams<"userID"> = "/api/user/:userID" // const a: IncludeRouteParams<"userID"> = "/api/user/:userID/orders" // const a: IncludeRouteParams<"userID"> = "/api/user/:userId" // const a: IncludeRouteParams<"userID"> = "/api/user" // const a: IncludeRouteParams<"userID"> = "/api/user/:userIDAndmore" //

让我们在get函数声明中包含我们的新实用程序类型。

 function get<Par extends string = string>( path: IncludesRouteParams<Par>, callback: CallbackFn<"GET", Par> ) { // to be implemented } app.get<"userID">( "/api/users/:userID", function (req, res) { req.params.userID; // YEAH! } );

伟大的! 我们获得了另一种安全机制,以确保我们不会错过将参数添加到实际路线的机会! 多么强大。

通用绑定

但你猜怎么着,我还是不满意。 当您的路线变得更加复杂时,这种方法会出现一些问题。

  1. 我遇到的第一个问题是我们需要在泛型类型参数中显式声明我们的参数。 我们必须将Par绑定到"userID" ,即使我们会在函数的路径参数中指定它。 这不是 JavaScript-y!
  2. 这种方法只处理一个路由参数。 我们添加联合的那一刻,例如"userID" | "orderId" "userID" | "orderId"故障安全检查仅满足这些参数之一可用。 这就是集合的工作方式。 它可以是一个,也可以是另一个。

一定会有更好的办法。 有。 否则,这篇文章将以非常痛苦的方式结束。

让我们颠倒顺序! 让我们不要尝试在泛型类型变量中定义路由参数,而是从我们作为app.get的第一个参数传递的path中提取变量。

要获得实际值,我们必须了解泛型绑定在 TypeScript 中是如何工作的。 让我们以这个identity函数为例:

 function identity<T>(inp: T) : T { return inp }

它可能是你见过的最无聊的通用函数,但它完美地说明了一点。 identity接受一个参数,并再次返回相同的输入。 该类型是泛型类型T ,它也返回相同的类型。

现在我们可以将T绑定到string ,例如:

 const z = identity<string>("yes"); // z is of type string

这种显式通用绑定确保我们只将strings传递给identity ,并且由于我们显式绑定,返回类型也是string 。 如果我们忘记绑定,就会发生一些有趣的事情:

 const y = identity("yes") // y is of type "yes"

在这种情况下,TypeScript 从您传入的参数推断类型,并将T绑定到字符串文字类型"yes" 。 这是将函数参数转换为文字类型的好方法,然后我们在其他泛型类型中使用它。

让我们通过改编app.get来做到这一点。

 function get<Path extends string = string>( path: Path, callback: CallbackFn<"GET", ParseRouteParams<Path>> ) { // to be implemented }

我们删除Par泛型类型并添加PathPath可以是任何string的子集。 我们将path设置为这个泛型类型Path ,这意味着当我们将参数传递给get时,我们会捕获它的字符串字面量类型。 我们将Path传递给尚未创建的新泛型类型ParseRouteParams

让我们处理ParseRouteParams 。 在这里,我们再次切换事件的顺序。 我们没有将请求的路由参数传递给泛型以确保路径正常,而是传递路由路径并提取可能的路由参数。 为此,我们需要创建一个条件类型。

条件类型和递归模板文字类型

条件类型在语法上类似于 JavaScript 中的三元运算符。 您检查一个条件,如果满足条件,则返回分支 A,否则返回分支 B。例如:

 type ParseRouteParams<Rte> = Rte extends `${string}/:${infer P}` ? P : never;

在这里,我们检查Rte是否是每个以 Express 样式结尾的参数结尾的路径的子集(前面带有"/:" )。 如果是这样,我们推断这个字符串。 这意味着我们将其内容捕获到一个新变量中。 如果满足条件,我们返回新提取的字符串,否则,我们从不返回,如:“没有路由参数”,

如果我们尝试一下,我们会得到类似的结果:

 type Params = ParseRouteParams<"/api/user/:userID"> // Params is "userID" type NoParams = ParseRouteParams<"/api/user"> // NoParams is never --> no params!

太好了,这已经比我们之前做的好多了。 现在,我们要捕获所有其他可能的参数。 为此,我们必须添加另一个条件:

 type ParseRouteParams<Rte> = Rte extends `${string}/:${infer P}/${infer Rest}` ? P | ParseRouteParams<`/${Rest}`> : Rte extends `${string}/:${infer P}` ? P : never;

我们的条件类型现在工作如下:

  1. 在第一个条件中,我们检查路由之间的某处是否有路由参数。 如果是这样,我们将提取路由参数和之后的所有其他内容。 我们在联合中返回新找到的路由参数P ,在联合中我们使用Rest递归地调用相同的泛型类型。 例如,如果我们将路由"/api/users/:userID/orders/:orderID"传递给ParseRouteParams ,我们将"userID"推断为P ,并将"orders/:orderID"推断为Rest 。 我们用Rest调用相同的类型
  2. 这就是第二个条件出现的地方。这里我们检查最后是否有类型。 "orders/:orderID"就是这种情况。 我们提取"orderID"并返回此文字类型。
  3. 如果没有更多的路由参数,我们永远不会返回。

Dan Vanderkam 为ParseRouteParams展示了一个类似的、更复杂的类型,但是您在上面看到的那个应该也可以工作。 如果我们尝试我们新改编的ParseRouteParams ,我们会得到这样的结果:

 // Params is "userID" type Params = ParseRouteParams<"/api/user/:userID"> // MoreParams is "userID" | "orderID" type MoreParams = ParseRouteParams<"/api/user/:userID/orders/:orderId">

让我们应用这个新类型,看看我们最终使用app.get的样子。

 app.get("/api/users/:userID/orders/:orderID", function (req, res) { req.params.userID; // YES!! req.params.orderID; // Also YES!!! });

哇。 这看起来就像我们一开始的 JavaScript 代码!

动态行为的静态类型

我们刚刚为一个函数app.get创建的类型确保我们排除了大量可能的错误:

  1. 我们只能将正确的数字状态代码传递给res.status()
  2. req.method是四个可能的字符串之一,当我们使用app.get时,我们知道它只是"GET"
  3. 我们可以解析路由参数并确保我们的回调中没有任何拼写错误

如果我们查看本文开头的示例,我们会收到以下错误消息:

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { // ^^^^^^^^^^^^^^^^^^^^^ // This condition will always return 'false' // since the types '"GET"' and '"POST"' have no overlap. res.status(20).send({ // ^^ // Argument of type '20' is not assignable to // parameter of type 'StatusCode' message: "Welcome, user " + req.params.userId // ^^^^^^ // Property 'userId' does not exist on type // '{ userID: string; }'. Did you mean 'userID'? }); } })

在我们真正运行我们的代码之前所有这些! Express 风格的服务器是 JavaScript 动态特性的完美示例。 根据您调用的方法,您为第一个参数传递的字符串,回调内部的许多行为会发生变化。 再举一个例子,你所有的类型看起来都完全不同。

但是使用一些定义明确的类型,我们可以在编辑代码时捕捉到这种动态行为。 在编译时使用静态类型,而不是在运行时事情进展顺利!

这就是 TypeScript 的强大之处。 一个静态类型系统,它试图将我们熟知的所有动态 JavaScript 行为形式化。 如果您想尝试我们刚刚创建的示例,请前往 TypeScript 游乐场并摆弄它。


Stefan Baumgartner 的 TypeScript 50 课 在本文中,我们涉及了许多概念。 如果您想了解更多信息,请查看 TypeScript in 50 Lessons,在那里您可以通过简单易懂的小型课程温和地介绍类型系统。 电子书版本立即可用,印刷书将为您的编码库提供很好的参考。