├── .gitignore ├── LICENSE ├── README.md ├── assets ├── chapter1 │ ├── hierarchy.png │ ├── research.png │ └── subtypes.png ├── chapter2 │ ├── named_tuple.gif │ ├── tuple_argument.png │ ├── tuple_index.png │ └── tuple_length.png ├── chapter3 │ ├── check.png │ ├── report.png │ └── uppercase.gif ├── chapter4 │ ├── demo.gif │ └── trpc.gif └── chapter5 │ ├── effective_typescript.png │ ├── learning_typescript.png │ └── string_ts.gif ├── chapter1.md ├── chapter2.md ├── chapter3.md ├── chapter4.md └── chapter5.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 你可能不知道的 TypeScript 2 | 3 | ## 前言 4 | 5 | 欢迎你,体操家!本文将从实用角度出发,介绍 TypeScript 中的许多可能不为人所知的理论和特性,希望能够通过这种方式尽可能地抹平我们关于 TypeScript 的一些进阶知识和技巧的信息差,并启发我们使用 TypeScript 解决生产实践中的一些看似无法解决的类型问题。 6 | 7 | 本文不是所谓的「类型体操」指南。在介绍各种内容时,我会侧重于揭露它的生产价值。我不会涉及很多理论价值高于生产价值的高级技巧,例如使用类型系统写一个正则表达式的 Parser,或者是写一个五子棋游戏。 8 | 9 | 本文欢迎各位读者共同建设。如果你发现了表述不正确的地方,或者有一些好的 good idea,欢迎发表 issue。如果你觉得本文写得不错,欢迎转发和 star 哦! 10 | 11 | > 作者的博客上面还有很多好康的文章哦,虽然现在不怎么更新了:[https://darkyzhou.net](https://darkyzhou.net) 12 | 13 | ## 目录 14 | 15 | > [!NOTE] 16 | > 推荐在阅读时点击文件右上角的大纲按钮(编辑按钮右边那个)来展示章节的大纲,这样有助于理解章节内容 17 | 18 | - [第一章:基础知识](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md) 19 | - [第二章:进阶话题](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md) 20 | - [第三章:类型编程](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md) 21 | - [第四章:生产实践](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter4.md) 22 | - [第五章:探索之路](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter5.md) 23 | 24 | ## 参考资料 25 | 26 | 本文的内容部分参考了下面的文章或书籍,没有它们的作者们的贡献就没有这篇文章。 27 | 28 | - [Handbook - The TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/intro.html) 29 | - [The type hierarchy tree](https://www.zhenghao.io/posts/type-hierarchy-tree) 30 | - [https://twitter.com/mattpocockuk](https://twitter.com/mattpocockuk) 31 | - [Transform Any Union in TypeScript with the IIMT](https://www.totaltypescript.com/immediately-indexed-mapped-type) 32 | - Effective TypeScript by Dan Vanderkam 33 | 34 | ## TODO 35 | 36 | 本文章编写过程较为仓促,我还有一些想写进来的话题: 37 | 38 | - 更多的生产实践 39 | - Optional Variance Annotations 以及函数参数的双向协变 40 | - 聊聊对象类型 41 | - `type` 和 `interface` 关键字的区别,包括隐式的 [index] 声明(忘记这个叫啥了) 42 | -------------------------------------------------------------------------------- /assets/chapter1/hierarchy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter1/hierarchy.png -------------------------------------------------------------------------------- /assets/chapter1/research.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter1/research.png -------------------------------------------------------------------------------- /assets/chapter1/subtypes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter1/subtypes.png -------------------------------------------------------------------------------- /assets/chapter2/named_tuple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter2/named_tuple.gif -------------------------------------------------------------------------------- /assets/chapter2/tuple_argument.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter2/tuple_argument.png -------------------------------------------------------------------------------- /assets/chapter2/tuple_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter2/tuple_index.png -------------------------------------------------------------------------------- /assets/chapter2/tuple_length.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter2/tuple_length.png -------------------------------------------------------------------------------- /assets/chapter3/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter3/check.png -------------------------------------------------------------------------------- /assets/chapter3/report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter3/report.png -------------------------------------------------------------------------------- /assets/chapter3/uppercase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter3/uppercase.gif -------------------------------------------------------------------------------- /assets/chapter4/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter4/demo.gif -------------------------------------------------------------------------------- /assets/chapter4/trpc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter4/trpc.gif -------------------------------------------------------------------------------- /assets/chapter5/effective_typescript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter5/effective_typescript.png -------------------------------------------------------------------------------- /assets/chapter5/learning_typescript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter5/learning_typescript.png -------------------------------------------------------------------------------- /assets/chapter5/string_ts.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darkyzhou/You-Might-Not-Know-TypeScript/c40dc1c3294946783955ef7959d9ea4fa1fd33e6/assets/chapter5/string_ts.gif -------------------------------------------------------------------------------- /chapter1.md: -------------------------------------------------------------------------------- 1 | | **目录** | **下一章** | 2 | | :----------: | :------: | 3 | | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第二章:进阶话题](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md) | 4 | 5 | --- 6 | 7 | # 第一章:基础知识 8 | 9 | ## 关于 TypeScript 10 | 11 | 早期的 JavaScript 应用普遍存在一个问题:开发效率难以随着团队规模的扩大而扩展(scale)。当时,来自微软的 Steve Lucco 等人认为解决问题的关键是一套类型系统,在它的基础上提供包括但不限于下面的能力: 12 | 13 | - 自动补全(Code Completion) 14 | - 跳转到定义(Go to Definition) 15 | - 安全的重构(Safe Refactoring) 16 | 17 | 当我们今天讨论 TypeScript 时,应该意识到它不仅仅是一个语言或者一个类型系统,它也是一套完整的 JavaScript 工具链,赋予了 IDE 分析 JavaScript/TypeScript 代码的能力。 18 | 19 | > TypeScript 和 VS Code 的诞生是相辅相成的。没有 TypeScript 带来的类型系统和上述的功能,就没有 VS Code 这个由多人团队构建的基于 Web 的大型应用程序。而 VS Code 的开发也为 TypeScript 带来了大量的改进反馈。TypeScript 和 VS Code 都是 2010 年代微软在 Web 上的投入计划的一环。 20 | 21 | 本文的主题是 TypeScript 的类型系统部分,并不涉及提供上述功能的部分,它被称为语言服务(language service)。因此,如果你在后文看到了「TypeScript」一词,它很可能指的是类型系统。 22 | 23 | ## 结构化类型(Structural Typing) 24 | 25 | 一个类型系统的重要属性是:它使用结构化类型(structural typing)还是名义类型(nominal typing)。这项属性决定了:当类型系统检查某个值是否能够被赋给(assign to)具有某个类型的变量时,它所使用的判据是什么。 26 | 27 | 对于名义类型,判据是值和变量的类型名称,就像下面的 Java 代码。 28 | 29 | ```java 30 | class Dog { 31 | public void walk() {} 32 | } 33 | 34 | class Duck { 35 | public void walk() {} 36 | } 37 | 38 | Dog dog; 39 | // 错误,Dog 和 Duck 是没有任何关系的类型,「狗就是狗,鸭就是鸭」 40 | dog = new Duck(); 41 | ``` 42 | 43 | 而对于结构化类型,判据是值和变量的类型结构,就像下面的 TypeScript 代码: 44 | 45 | ```java 46 | class Dog { 47 | public walk() {} 48 | } 49 | 50 | class Duck { 51 | public walk() {} 52 | } 53 | 54 | // 没有错误,因为狗和鸭的类型的结构一样(都由一个相同的 walk 函数构成) 55 | // 「因为都能够走路,所以狗和鸭一样」 56 | const dog: Dog = new Duck(); 57 | ``` 58 | 59 | > [!NOTE] 60 | > 你可能听说过[鸭子理论(Duck Typing)](https://en.wikipedia.org/wiki/Duck_typing),它可以说是结构化类型的思想的一种体现。 61 | > 62 | > Duck typing in computer programming is an application of the duck test—"If it walks like a duck and it quacks like a duck, then it must be a duck"—to determine whether an object can be used for a particular purpose. 63 | > 64 | > With nominative typing, an object is of a given type if it is declared as such. With duck typing, an object is of a given type if it has all methods and properties required by that type. 65 | > 66 | > Duck typing may be viewed as a usage-based structural equivalence between a given object and the requirements of a type. 67 | 68 | TypeScript 的类型系统选择了结构化类型,这是出于兼容 JavaScript 生态的考虑。因为从人们使用 JavaScript 的方式来看,它更适合使用结构化类型去建模。这里举一个简单的例子: 69 | 70 | ```typescript 71 | function logPoint(p) { 72 | console.log(`${p.x}, ${p.y}`); 73 | } 74 | 75 | logPoint({ x: 12, y: 26 }); 76 | ``` 77 | 78 | 就像上面的 `logPoint` 一样,在人们编写的很多 API(特别是 DOM API 或者 JavaScript 语言内置的 API)中,人们只关心这个值是否存在一些发挥功能必要的属性。就像[你可以使用 `Array.from({ length: 10 })` 创建定长数组](https://darkyzhou.net/articles/js-array-creation)一样,JavaScript 并不关心你传入的参数是否真的是一个数组,它只关心发挥功能所必需的 `length` 属性。如果 TypeScript 采用名义类型,则需要为这些 API 的参数提供大量的内置类型,这样的做法无疑提高了 TypeScript 接入 JavaScript 的现有 API 的复杂度。 79 | 对于已经习惯了采用的名义类型的语言(大多数的面向对象语言,比如 Java)的人来说,结构化类型有时候会给他们带来一些意想不到的惊喜,比如他们可能会惊讶于下面的例子: 80 | 81 | ```typescript 82 | interface MyInterface { 83 | // 空接口 84 | } 85 | 86 | // 不报错! 87 | const a: MyInterface = "seele"; 88 | // 也不报错! 89 | const b: MyInterface = () => {}; 90 | ``` 91 | 92 | 不过,结构化类型赋予了 TypeScript 的类型系统非常强大的功能,让我们可以通过编程的方式「组合」出自己想要的类型,这一点在后续的类型编程中会详细讨论。 93 | 94 | > [!NOTE] 95 | > 虽然我们说 TypeScript 的类型系统采用了结构化类型,但这不是绝对的。在一些细节上,它采用了名义类型,比如对于 `class` 中使用 `private` 或 `protected` 修饰的属性:[https://github.com/microsoft/TypeScript/wiki/FAQ#when-and-why-are-classes-nominal](https://github.com/microsoft/TypeScript/wiki/FAQ#when-and-why-are-classes-nominal)。 96 | > 97 | > 此外,我们会在后文看到,仍然可以通过一些特殊的手段在 TypeScript 的类型系统中模拟出名义类型的效果,来达到一些需要名义类型才能实现的目的。 98 | 99 | ## 类型系统的目的 100 | 101 | [TypeScript 的设计目标(design goals)的第一条](https://github.com/microsoft/TypeScript/wiki/TypeScript-Design-Goals#goals): 102 | 103 | > Statically identify constructs that are likely to be errors. 104 | 105 | 我认为这是在说 TypeScript 应该为程序员提供一些手段,让他们在编码或编译时就能识别出各种结构的错误。所谓的「结构」可以理解为包括但不局限于以下的内容: 106 | 107 | - 函数的返回值必须是一个 `Promise`,且它 resolve 之后返回的值必须是个字符串 108 | - 函数的两个入参必须是长度相同的数组 109 | - 对象要么含有 `A` 属性要么含有 `B` 属性,不能同时含有、也不能都不含有 110 | - 若干个对象必须含有相同的键 111 | - 字符串必须包含若干个特定子串 112 | 113 | 从上面的内容可以看出,在 TypeScript 的视野中,标注并检查符号的原始类型只是它目的的一个方面。TypeScript 的团队在这十多年来不断地追求着更为宏大的设计目标。我们会在后文介绍如何利用 TypeScript 实现这些复杂的规则。 114 | 115 | TypeScript 想要为这些「结构(construct)」提供一种在编译期就能够进行自动化检查的手段,从而避免过去人们使用 JavaScript 时将很多编码错误(不局限于类型错误)泄漏到运行时,带来不必要的损失。 116 | 换句话说,TypeScript 的目的是提供一种自动化的工具,对代码进行广义上的静态检查,只不过具体的检查规则需要程序员接入(opt in)到代码中的对应部分。如果程序员什么规则都不施加,那么一段 TypeScript 代码就跟 JavaScript 代码无异,这也在一定程度上解释了为什么 [TypeScript 的仓库介绍里说「TypeScript is a superset of JavaScript」](https://github.com/microsoft/typescript)。 117 | 118 | TypeScript 提供的自动化检查对于团队协作来说是一个可扩展(scalable)的方案,因为它将过去的很多通过注释、文档和口口相传的「结构」固化在了代码中,并且不依赖人就能检查这种「结构」的完整性。毕竟相比起记性很差、非常粗心的人来说,死板的规则有时候更适合做这类事情。当然,TypeScript 不能解决所有的「结构」问题,因为这不仅仅是编程语言层面的问题。 119 | 120 | > [!NOTE] 121 | > **扩展性(scalability)是我的个人见解** 122 | > 123 | > 我在评估某个解决方案时,会考察它在时间和空间两个维度上的扩展性: 124 | > 125 | > - 时间维度:时间的流逝等 126 | > - 空间维度:团队人员的变动,包括规模的扩大、成员的改变等 127 | > 128 | > 当我们说传统的基于注释、文档和 CR 等方法维护「结构」的方案扩展性不佳时,我们是在说: 129 | > 130 | > - 当时间流逝时,团队成员会不断忘记大量的注释和文档,导致方案效果弱化 131 | > - 当团队人员变动时,需要花费更多人力在理解注释、文档和进行 CR 上才能确保方案效果的持续 132 | > 133 | > 当我们说 TypeScript 提供的方案是可扩展的时候,我们基于以下理由: 134 | > 135 | > - 当结构的规则被完成后,它通过 TypeScript 的编译检查确保效果的实施,不依赖于人力的变化 136 | > - 当结构的规则被完成后,无论时间如何流逝,这个规则的效果并不会发生改变 137 | 138 | ## 类型是值的集合 139 | 140 | 在 TypeScript 的类型系统中,我们最好将所谓的「类型」理解为「值的集合」,即一个类型的本质是它所框定的值的集合。基于这个定义有: 141 | 142 | - 如果一个类型是另一个类型的子集,那么称前者是后者的子类型(subtype) 143 | - 如果一个类型是全集,那么其它所有类型都是它的子类型 144 | - 如果一个类型是空集,那么它是其它所有类型的子类型 145 | 146 | 在 TypeScript 对赋值语句 `a = b` 进行类型检查时,它会检查 `b` 的类型是不是 `a` 的子类型。如果 `a` 的类型是全集,那么这个赋值总是会通过检查。如果 a 的类型是空集,那么这个赋值总是无法通过检查。对应到具体的类型来说,上述的「全集」指的是 `any` 和 `unknown` 而「空集」指的是 `never`。 147 | 148 | 如果使用箭头表示类型的父子关系,可以得到下面的这张图: 149 | 150 |

151 | 152 |

153 | 154 | 像 `any` 和 `unknown` 这样在整个类型系统中处于顶部位置(其它所有类型是它们的子集)的类型,被称为「top types」。而像 `never` 这样在整个类型系统中处于底部位置(它是其它所有类型的子集)的类型,被称为「bottom types」。谁框定的值更多谁就处于这个体系中更高的位置,反之则处于更低的位置。 155 | 156 | 我们还有下面的讨论: 157 | 158 | - 当使用 `string` 标注变量时,我们是在表达「这个变量的值可能是任意一个字符串值」 159 | - 当使用 `'foo' | 'bar'` 标注变量时,我们是在表达「这个变量的值只能是 `'foo'` 或 `'bar'`」 160 | - `null` 和 `undefined` 类型有些特殊,它们都只框定了一个值,分别是 `null` 和 `undefined` 161 | 162 | ### 对象类型 163 | 164 | 对于对象类型来说,情况有些复杂:当使用 `{ foo: string }` 时,我们实际上框定的值是「任何含有名为 `foo` 的属性的对象」,而不是仅含有那个属性的对象。在下面的例子中,观察 `Vector1D`、`Vector2D` 和 `Vector3D` 之间的关系,我们向对象类型添加更多属性的过程是在收缩而不是扩充这个类型对应的集合。 165 | 166 | ```typescript 167 | interface Vector1D { 168 | x: number; 169 | } 170 | 171 | interface Vector2D extends Vector1D { 172 | y: number; 173 | } 174 | 175 | interface Vector3D extends Vector2D { 176 | z: number; 177 | } 178 | ``` 179 |

180 | 181 |

182 | 183 | 特别地,TypeScript 不允许对一个对象字面量值进行 upcast: 184 | 185 | ```typescript 186 | const _0: Vector1D = { x: 114, y: 514 }; 187 | // ERROR: Object literal may only specify known properties, and 'y' does not exist in type 'Vector1D' 188 | ``` 189 | 190 | 这倒不是在说前面的说法有问题,而是它就是一个附加的「特例」规则,这在一些场景下很有用(特别是向 React 组件传对象字面量参数时)。这个特例只发生在对象字面量值中,如果使用一些间接的方式赋值就不会报错: 191 | 192 | ```typescript 193 | const _1: Vector2D = { x: 114, y: 514 }; 194 | const _: Vector1D = _1; // OK 195 | ``` 196 | 197 | > [!NOTE] 198 | > **类型 `{}` 到底是什么?** 199 | > 200 | > 按照刚才有关对象类型的讨论,是否可以得出 `{}` 框定的是「任何对象类型」的结论?唔,情况其实有些复杂。 201 | > 202 | > 事实上,从 TypeScript 4.8 开始,`{}` 等价于「任何非 `null` 且非 `undefined` 的类型」,并且有:`type NonNullable = T extends null | undefined ? never : T` 等价于 ` T & {}`。换句话说,`string`、`number`、`boolean` 等常见类型也能够被赋给 `{}`。 203 | > 204 | > 因此,`{}` 框定的值的范围实际上要大于「任何对象类型」。如果确实想表示对象类型,`Record` 一般会是更好的选择。 205 | 206 | ### Upcast 和 Downcast 207 | 208 | 在 TypeScript 检查一个值是否可以被赋给(assign to)一个变量时,如果这个值的类型是变量类型的子类型,那么称这个过程为 upcast。例如将 `true` 赋给一个 `boolean` 类型的变量。根据里氏替换原则,这种 upcast 基本上是安全的,因此 TypeScript 不会对这种赋值语句做额外的要求。 209 | 210 | 如果方向相反,变量的类型是值的子类型,这个过程则被称为 downcast。例如将 `{}` 赋给 `{ foo: string }`。这种赋值并不安全,因此 TypeScript 在进行类型检查时会报错。我们一般需要通过类型断言(type assertion)去告诉 TypeScript 我们十分肯定这件事情是正确的,不过这也成为了 `as` 被滥用的开端,在[后文](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E6%BB%A5%E7%94%A8%E7%B1%BB%E5%9E%8B%E6%96%AD%E8%A8%80type-assertion)会展开讨论。 211 | 212 | > [!WARNING] 213 | > **不推荐使用 any 类型!** 214 | > 215 | > 你可能听说过 TypeScript 被称为 AnyScript 的调侃。 any 类型是类型系统提供的一种「后门」。在使用它时,TypeScript 只会检查语法,而不会执行任何类型检查。在大多数我们不关心具体的类型为何的场景中,应该使用 unknown 而不是 any,因为它更安全。我们会在后文详细讨论 any 类型。 216 | 217 | ### TypeScript 概念和对应的集合概念 218 | 219 | | TypeScript 概念 | 对应的集合概念 | 220 | | ------------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------: | 221 | | `never` | $$\emptyset(空集)$$ | 222 | | `unknown` | 全集 | 223 | | 字面量,例如 `'foo'` | 单元素集合 | 224 | | 某个值可以被赋给(is assignable to)类型 `T` | $$Value \in T$$ | 225 | | 类型 `A` 可以被赋给(is assignable to)类型 `B` | $$A \subseteq B$$ | 226 | | 类型 `A` 是类型 `B` 的子类型 | $$A \subseteq B$$ | 227 | | A extends B,其中 A 和 B 为某种类型(这个指的不是类继承语法哦) | $$A \subseteq B$$ | 228 | | `A \| B`,其中 A 和 B 为某种类型 这个概念被称为联合类型(union types) | $$A \cup B$$ | 229 | | `A & B`,其中 A 和 B 为某种类型 这个概念被称为交叉类型(intersection types) | $$A \cap B$$ | 230 | 231 | > [!NOTE] 232 | > 赋值(assignment)和子类型(subtype)具体检查的项目其实不完全等价。 233 | > 234 | > [TypeScript 的官方文档提到](https://www.typescriptlang.org/docs/handbook/type-compatibility.html#subtype-vs-assignment),前者在后者的基础上扩展了两条规则: 235 | > 236 | > - `any` 总是可以被 downcast 而不会报错 237 | > - 不同枚举类型间的赋值规则基于它们实际代表的数字值(numeric values) 238 | > 239 | > 事实上,在 TypeScript 的类型检查系统中[存在下列的五种关系](https://github.com/microsoft/TypeScript-Compiler-Notes/blob/main/codebase/src/compiler/checker-relations.md#relations),它们会被用到不同的场合中,并在具体细节上存在区别。不过,上述的表格总体来说在多数日常场景中仍然是正确的。 240 | > 241 | > - Identity(相等) 242 | > - Strict Subtyping(严格子类型) 243 | > - Subtyping(子类型) 244 | > - Assignability(可赋值) 245 | > - Comparability(可比较) 246 | 247 | ## 类型的来源 248 | 249 | TypeScript 在进行类型检查时,实际上需要知道源码中所有节点的类型。但是,正如我们可以直接使用 `const example = "Hello, World!"` 声明一个类型为 `string` 的变量一样,TypeScript 在我们不使用显式的类型标注的情况下也能够进行类型检查。TypeScript 可以通过下面的方法获取类型信息: 250 | 251 | ### 程序员的手动标注 252 | 253 | 手动标注符号的类型的方式包括声明符号时的 `variable: type` 语法和类型断言(Type assertion)等方式。对于后者来说,主要包括 `as` 关键字的使用以及与之等价的 `` 前缀。 254 | 255 | #### [JSDoc 的类型标注](https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html) 256 | 257 | JSDoc 中[存在一种 @type 类型的注释](https://jsdoc.app/tags-type.html)可以让程序员在 JavaScript 代码中标注一个符号或者函数返回值的类型,TypeScript 的语言服务也能够读取这些注释来给符号限定类型,提供和 TypeScript 代码相当的类型检查能力。 258 | 259 | > 基于 JSDoc 的这种能力和在 TypeScript 的 DX 上遇到的痛点,知名的前端框架 Svelte 选择将部分 TypeScript 源文件迁移至使用 JSDoc 的 JavaScript 源文件。参见 https://github.com/sveltejs/svelte/pull/8569 中的评论以及互联网上的相关讨论。 260 | 261 | #### [类型推导(Type Inference)](https://www.typescriptlang.org/docs/handbook/type-inference.html) 262 | 263 | TypeScript 在进行类型推导时存在两套特殊的机制:类型泛化(type widening)和类型具化(type narrowing)。虽然类型推导是一个复杂的机制,不过下面讨论的两种机制是日常编写代码时接触得最多的。 264 | 265 | ##### 类型泛化(Type Widening) 266 | 267 | 我们在编写 TypeScript 代码(甚至是纯 JavaScript 代码)时,在很多情况下不需要通过上述方法标注类型,因为 TypeScript 可以通过分析源代码去自动推断出类型。例如当写下 `let foo = 'bar'` 时,虽然我们并没有指出 `foo` 应该是 `string` 类型,但 TypeScript 能够依据它是变量声明语句以及它的 rhs 表达式(right-hand side expression)是一个字符串字面量(string literal)等事实,来推导出一种「合适」的类型。 268 | 269 | 不过,所谓的「合适」其实是一个复杂的话题。TypeScript 只能从源码上掌握有限的信息,有时并不能以此推断出程序员的意图。例如,我们经常在声明变量时遇到下面这样的两难问题(dilemma): 270 | 271 | ```typescript 272 | declare function myApi(someTuple: [string, boolean]): void; 273 | 274 | const firstTry = ['foo', false]; 275 | // 推导的类型为:(string | boolean)[] 276 | myApi(firstTry); 277 | // 报错! 278 | // Argument of type '(string | boolean)[]' is not assignable to parameter of type '[string, boolean]'. 279 | // Target requires 2 element(s) but source may have fewer. 280 | 281 | const secondTry = ['foo', false] as const; 282 | // 推导的类型为:readonly ["foo", false] 283 | myApi(secondTry); 284 | // 还是报错! 285 | // Argument of type 'readonly ["foo", false]' is not assignable to parameter of type '[string, boolean]'. 286 | // The type 'readonly ["foo", false]' is 'readonly' and cannot be assigned to the mutable type '[string, boolean]' 287 | 288 | // 这样才可以…… 289 | myApi(['foo', false]); 290 | ``` 291 | 292 | 只有当我们将元组字面量直接作为函数调用的入参时,TypeScript 才会得知这个字面量的类型应该被推断为 `[string, boolean]` 而不是别的一些看上去符合道理,但不符合我们意图东西。当然,也可以选择手动类型标注来解决问题,但是这样就太麻烦了,引入了额外的维护成本,看上去不像一种能够扩展的办法。 293 | 294 | 说到这里大家应该就明白了,类型泛化是 TypeScript 在类型推断中将值的类型从对应的字面量类型开始泛化,直到寻找到一种平衡的过程。如果推导出的类型太宽泛,比如将上述的 `firstTry` 推导为 `unknown[]`,这确实可以通过类型检查,却会让程序员不知所措。如果推导出的类型太具体,比如将上述的 `firstTry` 推导为 `['foo', false]`,可能也不太好。TypeScript 掌握的信息实在有限,以至于很多情况下它会做错事。 295 | 296 | ##### [类型具化(Type Narrowing)](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#typeof-type-guards) 297 | 298 | 除了对类型进行泛化,TypeScript 还有一套机制用来让某个值的类型变得更加具体。这套机制被称为具化(narrowing),主要(但不全是)发生在条件控制流语句中,用于提高程序员的开发体验。下面是一些例子: 299 | 300 | - 通过感叹号判空 301 | 302 | ```typescript 303 | const element = document.getElementById('#input'); 304 | // 此时,element 类型为 HTMLElement | null 305 | if (!element) { 306 | // 此时,element 类型为 null 307 | } else { 308 | // 此时,element 类型为 HTMLElement 309 | } 310 | ``` 311 | 312 | - 通过 `instanceof` 判断类型 313 | 314 | ```typescript 315 | declare const value: string | RegExp; 316 | if (value instanceof RegExp) { 317 | // 此时,value 类型为 RegExp 318 | } else { 319 | // 此时,value 类型为 string 320 | } 321 | ``` 322 | 323 | - 类似地,可以通过 `in` 关键字、`Array.isArray()`、`typeof` 关键字、[type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) 的方式具化类型,具体可以参考 TypeScript 官方文档 [Documentation - Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)。 324 | 325 | 值得一提的是,类型具化的结果可以为空集(也就是 `never`),在多重的 `if-else` 语句或者 `switch` 语句中有时候可以帮助检查我们是否在前面的分支中覆盖了所有可能的情况,参见后文。 326 | 327 | ## 为何而钻研 328 | 329 | 本节介绍了许多 TypeScript 官方文档(下面简称「文档」)中没有涉及或详细介绍的基础知识,接下来我们会介绍更多这样的内容。一些读者可能因此会觉得,如果你总是钻研一些超出了官方文档范畴的知识,那么这些知识一般都会脱离「群众」,会超过大多数 TypeScript 程序员的「公共认知」,从而难以在实际项目中落地,甚至只能沦落为茶余饭后的些许谈资。 330 | 331 | 我却要在这里说:TypeScript 的官方文档写得不够好,大多数程序员能容易接触到的学习资料也写得不够好。这些资料有好的方面:它们都有介绍一些基础知识,有的还讲得活灵活现。但是它们大多没有涉及到一些「更隐蔽」的内容。所谓「更隐蔽」指的是 TypeScript 的开发者明明做出来了一些特别有用的特性,但是没有将它写到文档里,而只是在更新日志或者 GitHub 上的某个 issue 的评论里提了一嘴,像是生怕别人知道似的。或许也不应该因此苛责 TypeScript 的开发者们,因为工具的开发者和使用者之间存在一种信息上的不对称。 332 | 333 | 所谓的「类型体操」在某种程度上就是在充当一个这样的一个角色:发掘这些隐蔽特性,理清它们的功能,找到它们的用处,探索 TypeScript 真正的上限。因此「钻研」除了是一些人对存在主义不断追求的体现,也是对 TypeScript 能力上限的探索。就像奥林匹克的格言「更快、更高、更强」,人们总是希望自己一成不变甚至有些枯燥的生活(或许有些幸运的人不是这样)能够迎来某种更好的变化。 334 | 335 | 「钻研」的客体如下图所示,它们会贯穿全文。~~这个图看上去有点像某个国产手机的镜头模组~~。 336 | 337 |

338 | 339 |

340 | 341 | > [!NOTE] 342 | > 为了便于读者分辨某个特性被正式引入 TypeScript 的版本,我们在后文提及特性的地方使用了版本号进行标注。例如,当读者看到「`4.9+`」字样时,意味着这项特性在 TypeScript 的 4.9 版本被引入,并从这个版本开始可用。这些版本信息的标注可能不全面或者有错误,欢迎读者划线评论指出。 343 | 344 | --- 345 | 346 | | **目录** | **下一章** | 347 | | :----------: | :------: | 348 | | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第二章:进阶话题](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md) | 349 | 350 | -------------------------------------------------------------------------------- /chapter2.md: -------------------------------------------------------------------------------- 1 | | **上一章** | **目录** | **下一章** | 2 | | :------------- | :----------: | :------: | 3 | | [第一章:基础知识](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第三章:类型编程](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md) | 4 | 5 | --- 6 | 7 | # 第二章:进阶话题 8 | 9 | ## [声明合并(Declaration Merging)](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) 10 | 11 | 在 TypeScript 中,一个「声明」实际上会包含下列至少一种概念的声明: 12 | 13 | - 命名空间(namespace) 14 | - 类型(type) 15 | - 值(value) 16 | 17 | 下表列出了当声明某个类型时,我们实际声明了上述的哪些概念。例如,当我们使用 `class Foo` 声明了一个 `Foo` 类型时,符号 `Foo` 实际上指向了一个类型和一个值。其中,类型指的是 `Foo` 的实例类型(instance type),即通过 `new` 关键字创建出来的对象类型;值指的是 `Foo` 的构造函数。 18 | 19 | 必须要强调的是,类型是仅在编译期才存在的概念,当编译为 JavaScript 代码之后,类型不会再存在。而命名空间和值却会存在于编译产物中(尽管命名空间的存在形式比较特殊)。 20 | 21 | | **声明的类型** | **命名空间** | **类型** | **值** | 22 | | :------------- | :----------: | :------: | :----: | 23 | | `namespace` | ✅ | | ✅ | 24 | | `class` | | ✅ | ✅ | 25 | | `enum` | | ✅ | ✅ | 26 | | `interface` | | ✅ | | 27 | | `type` | | ✅ | | 28 | | 函数 | | | ✅ | 29 | | 变量 | | | ✅ | 30 | 31 | 当一个标识符同时指向类型和值时,我们可以通过直接引用它的名称来获得指向的类型,通过 `typeof` 关键字获得它指向的值类型,就像下面的例子这样: 32 | 33 | ```typescript 34 | class Foo { 35 | bar!: "seele"; 36 | 37 | static baz: "sirin"; 38 | } 39 | 40 | // 直接引用名称指代它的类型,对于 class 来说,指的是实例类型 41 | const _0: Foo = { bar: "seele" }; 42 | const _1: Foo = new Foo(); 43 | 44 | // 使用 typeof 获得它的值类型,对于 class 来说,指的是构造函数的类型 45 | const _2: typeof Foo = Foo; 46 | 47 | // 注意 class 的实例类型和值类型的区别 48 | type _3 = Foo["bar"]; 49 | // ^? type _3 = "seele" 50 | type _4 = (typeof Foo)["baz"]; 51 | // ^? type _4 = "sirin" 52 | ``` 53 | 54 | 类似地,如果一个名称指向一个变量声明,我们需要通过 `typeof` 来获得它指向的值类型(尽管这个名称只声明了一个值,并没有声明命名空间和类型)。 55 | 56 | ### `interface` 的声明合并 57 | 58 | TypeScript 中同一个模块下的同名 `interface` 声明会被合并。例如,下面的两段代码是等价的。 59 | 60 | ```typescript 61 | interface Document { 62 | createElement(tagName: any): Element; 63 | } 64 | 65 | interface Document { 66 | createElement(tagName: "div"): HTMLDivElement; 67 | createElement(tagName: "span"): HTMLSpanElement; 68 | } 69 | 70 | interface Document { 71 | createElement(tagName: string): HTMLElement; 72 | createElement(tagName: "canvas"): HTMLCanvasElement; 73 | } 74 | ``` 75 | 76 | ```typescript 77 | interface Document { 78 | createElement(tagName: "canvas"): HTMLCanvasElement; 79 | createElement(tagName: "div"): HTMLDivElement; 80 | createElement(tagName: "span"): HTMLSpanElement; 81 | createElement(tagName: string): HTMLElement; 82 | createElement(tagName: any): Element; 83 | } 84 | ``` 85 | 86 | 这种特性有什么用呢?对于 TypeScript 来说,它被用在了 TypeScript 自带的 JavaScript 基础库和 DOM 库的类型定义中。例如,在 ECMAScript 2019 标准中,数组被引入了 `flat` 和 `flatMap` 函数,它被 TypeScript 定义在了 `lib.es2019.array.d.ts` 文件中: 87 | 88 | ```typescript 89 | interface Array { 90 | flatMap( 91 | callback: ( 92 | this: This, 93 | value: T, 94 | index: number, 95 | array: T[] 96 | ) => U | ReadonlyArray, 97 | thisArg?: This 98 | ): U[]; 99 | 100 | flat(this: A, depth?: D): FlatArray[]; 101 | } 102 | ``` 103 | 104 | 当我们在配置文件中将目标(target)设置为了 `ES2019` 或更新的版本时,TypeScript 就会自动引入这个定义文件,为已有的数组类型定义(定义在更老的 ECMAScript 版本的类型文件中)扩充数组的类型定义。这就是为什么当调整目标之后,自动补全能够获得更多的提示。此外,这项特性还会被后文要介绍的其它进阶话题所使用。 105 | 106 | ### 命名空间的声明合并 107 | 108 | 命名空间除了能够和自身进行合并外,还能够和同名的类、函数或枚举声明进行合并,向这个名字指代的值定义中扩充更多的值。在下面的例子中,我们通过将命名空间和类声明进行合并,从而构造出了一个「内部类」定义: 109 | 110 | ```typescript 111 | class Album { 112 | label: Album.AlbumLabel; 113 | } 114 | 115 | namespace Album { 116 | export class AlbumLabel {} 117 | } 118 | ``` 119 | 120 | 类似地,在下面的例子中,我们通过和函数声明进行合并,表示出了这个函数的附加属性: 121 | 122 | ```typescript 123 | function buildLabel(name: string): string { 124 | return buildLabel.prefix + name + buildLabel.suffix; 125 | } 126 | 127 | namespace buildLabel { 128 | export let suffix = ""; 129 | export let prefix = "Hello, "; 130 | } 131 | 132 | console.log(buildLabel("Sam Smith")); 133 | ``` 134 | 135 | 上面的方法是 TypeScript 中推荐的向函数附加额外属性的方法,它能够提供良好的类型支持。 136 | 137 | 在下面的例子中,我们通过和枚举声明进行合并,为它附加了额外的函数: 138 | 139 | ```typescript 140 | enum Color { 141 | red = 1, 142 | green = 2, 143 | blue = 4, 144 | } 145 | 146 | namespace Color { 147 | export function mixColor(colorName: string) { 148 | if (colorName == "yellow") { 149 | return Color.red + Color.green; 150 | } else if (colorName == "white") { 151 | return Color.red + Color.green + Color.blue; 152 | } else if (colorName == "magenta") { 153 | return Color.red + Color.blue; 154 | } else if (colorName == "cyan") { 155 | return Color.green + Color.blue; 156 | } 157 | } 158 | } 159 | ``` 160 | 161 | ### 模块扩充(Module Augmentation) 162 | 163 | 模块扩充指的是这样一种机制:在不同的模块(或源文件)中对其它模块(或源文件)的类型定义通过进行扩充。特别地,通过 `declare global` 可以向全局空间扩充类型定义,就像下面的例子一样。 164 | 165 | ```typescript 166 | declare global { 167 | // 扩充值定义 168 | const foo: string; 169 | function bar(): void; 170 | 171 | // 扩充类型定义 172 | interface Baz {} 173 | } 174 | 175 | // 下面的代码都可以通过类型检查 176 | console.log(foo); 177 | bar(); 178 | const x: Baz = {}; 179 | 180 | export {}; 181 | ``` 182 | 183 | 请注意,模块扩充只是在扩充声明(declaration),这意味着我们编写的内容不会在编译产生的 JavaScript 代码中出现。这个过程可以被类比为编写 `d.ts` 文件。上述代码的编译结果如下所示,如果确实没有向全局空间附加这些额外的变量或函数,那么我们会在运行时遇到报错。 184 | 185 | ```typescript 186 | console.log(foo); 187 | bar(); 188 | const x = {}; 189 | export {}; 190 | ``` 191 | 192 | 值得一提的是,我们可以在模块扩充中使用声明合并。在下面的代码中,我们在 `map.ts` 中扩充了来自 `Observable.ts` 的类型定义。用户可以通过导入 `map.ts` 来获得扩充后的类型定义。 193 | 194 | ```typescript 195 | // observable.ts 196 | export class Observable { 197 | // ... 198 | } 199 | 200 | // map.ts 201 | import { Observable } from "./observable"; 202 | 203 | declare module "./observable" { 204 | interface Observable { 205 | map(f: (x: T) => U): Observable; 206 | } 207 | } 208 | 209 | Observable.prototype.map = function (f) { 210 | // ... another exercise for the reader 211 | }; 212 | 213 | // consumer.ts 214 | import { Observable } from "./observable"; 215 | import "./map"; 216 | let o: Observable; 217 | o.map((x) => x.toFixed()); 218 | ``` 219 | 220 | 我们会在生产实践中看到更多有关模块扩充的使用例子。 221 | 222 | ## 元组(Tuple) 223 | 224 | 在 TypeScript 的类型系统中,元组是具有固定长度的有序数组。这意味着下面的特性: 225 | 226 | 访问类型的 `length` 属性会获得数字字面量类型 | 直接使用下标访问元素会被类型检查 | 作为参数类型时,不会接受数组值,也不会接受长度不对的数组字面量 227 | :-------------------------:|:-------------------------:|:-------------------------: 228 | ![](./assets/chapter2/tuple_length.png) | ![](./assets/chapter2/tuple_index.png) | ![](./assets/chapter2/tuple_argument.png) | 229 | 230 | TypeScript 还提供了一些特殊的功能,让元组类型发挥着独特的作用。 231 | 232 | ### [具名元组元素(Labeled Tuple Elements)](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#labeled-tuple-elements)`4.0+` 233 | 234 | 从 TypeScript 4.0 开始,可以为元组的元素赋予名称,就像下面的例子一样: 235 | 236 | ```typescript 237 | type Foo = [first: number, second?: string, ...rest: any[]]; 238 | ``` 239 | 240 | 除了能够传达各个元素用作何用的信息之外,它还能在被作为函数的 [Rest Parameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters) 时将名称传递为函数参数名: 241 | 242 |

243 | 244 |

245 | 246 | ### [可变元组类型(Variadic Tuple Types)](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#variadic-tuple-types) `4.0+` 247 | 248 | 同样是从 TypeScript 4.0 开始,元组类型被赋予了可变的能力,它包含两个层面:展开语法(Spread syntax)支持泛型、剩余元素(Rest elements)可位于任意一项。 249 | 250 | #### 展开语法(Spread Syntax)支持泛型 251 | 252 | 在元组类型中出现的展开语法,如果作用于一个泛型类型(需要约束为数组或元组类型),那么它可以在类型推导时被推导为元组类型。下面是一个例子: 253 | 254 | ```typescript 255 | // 获得除了第一个元素之外的剩余元素 256 | declare function tail( 257 | _: readonly [unknown, ...T] 258 | ): T; 259 | 260 | const myTuple = [1, 2, 3, 4] as const; 261 | const myArray = ["hello", "world"]; 262 | 263 | const _1 = tail(myTuple); 264 | // ^? const _1: [2, 3, 4] 265 | 266 | const _2 = tail([...myTuple, ...myArray] as const); 267 | // ^? const _2: [2, 3, 4, ...string[]] 268 | ``` 269 | 270 | > [!NOTE] 271 | > **为什么 `tail` 函数泛型 `T` 的约束需要使用 `readonly unknown[]`?** 272 | > 273 | > 在 TypeScript 的类型系统中,只读数组(readonly array)实际上是可变数组(mutable array,即不加 `readonly` 的数组类型)的父类型。根据[接口隔离原则(Interface segregation principle)](https://en.wikipedia.org/wiki/Interface_segregation_principle),`tail` 函数不会对输入的数组做任何修改,它只依赖数组的只读接口(访问下标等),因此我们最好显式地声明这一点。这样,用户通过 `as const` 获得的只读数组也可以传入函数。 274 | > 275 | > 如果将 `T` 约束为 `unknown[]`,用户不仅无法传入只读数组,也可能被误导,认为我们的函数会修改数组内容,从而防御性地传入了一份数组的拷贝(例如,`tail([...myArray])`),这样做通常是不必要的。 276 | > 277 | > 更一般地,`readonly` 关键字以及它背后的理念能够辅助程序员对函数接口的合约(contract)进行建模,帮助我们写出更为严谨可靠的代码。它不仅能够像上面那样作用于数组,也能够作用于一般的对象。可惜的是,它并没有得到太多人的关注。 278 | 279 | #### 剩余元素(Rest Elements)可位于任意一项 `4.2+` 280 | 281 | 过去,剩余元素只能位于元组的最后一项,限制了它能够发挥的作用。现在,可以做到类似下面这样的事情: 282 | 283 | ```typescript 284 | // 合并两个元组,返回正确的类型 285 | declare function concat< 286 | T extends readonly unknown[], 287 | U extends readonly unknown[] 288 | >(arr1: T, arr2: U): [...T, ...U]; 289 | 290 | const _1 = concat([1], [2]); 291 | // ^? const _1: number[] 292 | 293 | const _2 = concat(["foo", "bar"], [1, 2]); 294 | // ^? const _2: (string | number)[] 295 | 296 | const _3 = concat([1] as number[], ["foo"] as string[]); 297 | // ^? const _3: (string | number)[] 298 | 299 | const _4 = concat([1] as const, ["foo"] as const); 300 | // ^? const _4: [1, "foo"] 301 | ``` 302 | 303 | 如果结合「展开语法支持泛型」的特性,可以这样改进上面的例子: 304 | 305 | ```typescript 306 | // 合并两个元组,返回正确的类型 307 | declare function concat< 308 | T extends readonly unknown[], 309 | U extends readonly unknown[] 310 | >(arr1: [...T], arr2: [...U]): [...T, ...U]; 311 | 312 | const _1 = concat([1], [2]); 313 | // ^? const _1: [number, number] 314 | 315 | const _2 = concat(["foo", "bar"], [1, 2]); 316 | // ^? const _2: [string, string, number, number] 317 | 318 | const _3 = concat([1] as number[], ["foo"] as string[]); 319 | // ^? const _3: (string | number)[] 320 | 321 | const _4 = concat([1] as const, ["foo"] as const); 322 | // ^? const _4: [1, "foo"] 323 | ``` 324 | 325 | 这样,我们就能在用户传入元组时得到元组类型,而不是数组类型。 326 | 327 | ### [可选元素(Optional Element)](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#optional-elements-in-tuple-types) `3.0+` 328 | 329 | TypeScript 允许元组中存在可选元素,即这个元素可以存在也可以不存在。它主要被用来表示包含可选参数的函数,请看下面的例子。注意当 [`strictNullChecks`](https://www.typescriptlang.org/tsconfig#strictNullChecks) 配置被打开时,TypeScript 会向可选元素的类型中附加一个 `undefined` 类型形成联合类型。 330 | 331 | ```typescript 332 | declare function foo(arg1?: string, arg2?: number): void; 333 | 334 | type _1 = Parameters; 335 | // ^? type _1 = [arg1?: string | undefined, arg2?: number | undefined] 336 | ``` 337 | 338 | 可选元素的存在会导致元组类型的长度虽然固定但不唯一,请看下面的例子: 339 | 340 | ```typescript 341 | type _1 = [number, string, boolean]['length']; 342 | // ^? type _1 = 3 343 | 344 | type _2 = [number, string?, boolean?]['length']; 345 | // ^? type _2 = 1 | 2 | 3 346 | ``` 347 | 348 | ### 工具函数 `asTuple` `5.0+` 349 | 350 | 我们还能够扩展上述的 `concat` 函数,注意其中的变量 `_1`、`_2` 和 `_4`,为什么非得使用 `as const` 才能让 TypeScript 推导出字面量类型呢?实际上,我们可以通过 [`const` 类型参数(`const` type parameters)](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#const-%E7%B1%BB%E5%9E%8B%E5%8F%82%E6%95%B0const-type-parameters-50) `5.0+` 来改进这一点,让用户无需使用 `as const` 就能让 TypeScript 推导出元组字面量类型: 351 | 352 | ```typescript 353 | declare function concat< 354 | const T extends readonly unknown[], 355 | const U extends readonly unknown[] 356 | >(arr1: [...T], arr2: [...U]): [...T, ...U]; 357 | 358 | const _1 = concat([1], [2]); 359 | // ^? const _1: [1, 2] 360 | 361 | const _2 = concat(["foo", "bar"], [1, 2]); 362 | // ^? const _2: ["foo", "bar", 1, 2] 363 | 364 | const _3 = concat([1] as number[], ["foo"] as string[]); 365 | // ^? const _3: (string | number)[] 366 | 367 | const _4 = concat([1] as const, ["foo"] as const); 368 | // ^? const _4: [1, "foo"] 369 | ``` 370 | 371 | 更一般地,这里介绍一个工具函数 `asTuple`,它能够利用上述特性实现将给定的元组字面量推导为对应的元组字面量类型,注意它和 `as const` 的区别: 372 | 373 | ```typescript 374 | function asTuple(input: [...T]) { 375 | return input; 376 | } 377 | 378 | const _1 = asTuple([1, "foo"]); 379 | // ^? const _1: [1, "foo"] 380 | 381 | const _2 = [1, "foo"] as const; 382 | // ^? const _2: readonly [1, "foo"] 383 | ``` 384 | 385 | > [!NOTE] 386 | > 一般来说不必担心多了一个函数调用带来的 overhead,因为 minifier 很多时候都会自动去掉。不过我还没有调查过在跨模块调用的场景下类似的效果是否还会发生。 387 | 388 | ## [模板字面量类型(Template Literal Types)](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) `4.1+` 389 | 390 | 从 TypeScript 4.1 开始,字符串字面量类型(string literal types)可以用来构造其它字符串字面量类型。具体的构造方法和 JavaScript 的模板字符串(template literals)类似,如下面的例子所示: 391 | 392 | ```typescript 393 | type Seele = "Seele"; 394 | type HelloSeele = `Hello ${Seele}`; 395 | // ^? type HelloSeele = "Hello Seele" 396 | ``` 397 | 398 | 模板字面量类型接受下列类型的插值: 399 | 400 | - `string` 401 | - `number` 402 | - `bigint` 403 | - `boolean` 404 | - `undefined` 405 | - `null` 406 | 407 | 它们能够提供针对字符串字面量的检查手段,请看下面的例子: 408 | 409 | ```typescript 410 | // 以数字以及一个下划线开头的字符串 411 | type StartsWithNumber = `${number}_${string}`; 412 | 413 | // ERROR! 414 | const _0: StartsWithNumber = "seele"; 415 | 416 | // OK! 417 | const _1: StartsWithNumber = "114514_seele"; 418 | ``` 419 | 420 | ### 在联合类型(Union Types)中使用 421 | 422 | 一些人可能不知道的是,上述的插值并非只能使用单个的字符串字面量类型,联合类型也是可被接受的。此时,TypeScript 会对模板字面量类型做联合类型运算,这个过程可以被理解为对联合类型等价的集合做笛卡尔积运算: 423 | 424 | ```typescript 425 | type VerticalAlignment = "top" | "middle" | "bottom"; 426 | type HorizontalAlignment = "left" | "center" | "right"; 427 | 428 | type Alignment = `${VerticalAlignment}-${HorizontalAlignment}`; 429 | // ^? type Alignment = "top-left" | "top-center" | "top-right" | "middle-left" | "middle-center" | "middle-right" | "bottom-left" | "bottom-center" | "bottom-right" 430 | ``` 431 | 432 | ### 在条件类型(Conditional Types)中使用 433 | 434 | ```typescript 435 | // 检查是否以 Seele 结尾 436 | type _1 = "Hello Seele" extends `${string}Seele` ? true : false; 437 | // ^? type _1 = true 438 | 439 | // 取出括号内的文字 440 | type _2 = "Test (foo) bar" extends `${string}(${infer T})${string}` ? T : never; 441 | // ^? type _2 = "foo" 442 | ``` 443 | 444 | 我们会在[后文](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E6%9D%A1%E4%BB%B6%E7%B1%BB%E5%9E%8Bconditional-types)对条件类型进行详细的讨论。 445 | 446 | ### 映射类型中键的重映射(Key Remapping in Mapped Types) 447 | 448 | 我们可以在映射类型(mapped types)中使用额外的 `as` 关键字来重命名构造的属性的名称。 449 | 450 | ```typescript 451 | type Getters = { 452 | [K in keyof T as `get${Capitalize}`]: () => T[K]; 453 | }; 454 | 455 | interface Person { 456 | name: string; 457 | age: number; 458 | location: string; 459 | } 460 | 461 | type LazyPerson = Getters; 462 | // ^? type LazyPerson = { 463 | // getName: () => string; 464 | // getAge: () => number; 465 | // getLocation: () => string; 466 | // } 467 | ``` 468 | 469 | > [!NOTE] 470 | > **代码中的 `string & K` 是什么意思?** 471 | > 472 | > 对象的属性名的类型可能是 `string | number | symbol` 等,而我们在这里只关心那些类型为 `string` 的属性名。可以使用交叉类型(intersection types)来实现这个功能,具体的原理是: 473 | > 474 | > - 当 `K` 满足 `string` 类型时,结果为 `K` 对应的字符串字面量类型 475 | > - 否则,结果为 `never`,映射类型会过滤掉类型为 `never` 的键 476 | 477 | ## 枚举(Enum) 478 | 479 | ### 常值枚举(Const Enum) 480 | 481 | 常值枚举具有和一般的枚举显著不同的特点:对它的成员的引用会被内联(inline)到使用处(caller site)。请看下面的对比。 482 | 483 | ```typescript 484 | enum MyEnum { 485 | Foo, 486 | Bar, 487 | Baz, 488 | } 489 | 490 | console.log(MyEnum.Baz); 491 | var MyEnum; 492 | (function (MyEnum) { 493 | MyEnum[(MyEnum["Foo"] = 0)] = "Foo"; 494 | MyEnum[(MyEnum["Bar"] = 1)] = "Bar"; 495 | MyEnum[(MyEnum["Baz"] = 2)] = "Baz"; 496 | })(MyEnum || (MyEnum = {})); 497 | console.log(MyEnum.Baz); 498 | ``` 499 | 500 | 对于一般的枚举,它的编译产物是一个对象。 501 | 502 | ```typescript 503 | const enum MyEnum { 504 | Foo, 505 | Bar, 506 | Baz, 507 | } 508 | 509 | console.log(MyEnum.Baz); 510 | console.log(2 /* MyEnum.Baz */); 511 | ``` 512 | 513 | 对于常值枚举,注意到它并没有像一般的枚举那样在编译产物中留下对象的定义,而是直接将它的值内联到使用它的地方。常值枚举的这种特点可以大大减少枚举在编译产物中占据的体积,也能够带来一定的运行时性能提升。 514 | 515 | #### 局限性 516 | 517 | 由于常值枚举的特性,它不能像一般的枚举那样使用[计算成员(computed member)](https://www.typescriptlang.org/docs/handbook/enums.html#computed-and-constant-members)。 518 | 519 | 换句话说,常值枚举的成员的值只能通过下面任一方式得到: 520 | 521 | - 作为枚举的第一个成员没有使用初始化语句(initializer) 522 | 523 | > 此时,它会被赋予默认值 `0` 524 | 525 | - 没有使用初始化语句(initializer),而且在它前面的成员都被赋予了数字常量作为值 526 | 527 | > 此时,它会被赋予前一个成员的值加上 `1` 528 | 529 | - 常量表达式的计算,包括字符串字面量等,具体请见[官方文档](https://www.typescriptlang.org/docs/handbook/enums.html#computed-and-constant-members) 530 | 531 | #### 不推荐在共享库中使用 532 | 533 | 由于常值枚举的内联特性,TypeScript 不推荐在共享库(包括 `d.ts` 文件)中使用使用它,因为[它会导致一些问题](https://www.typescriptlang.org/docs/handbook/enums.html#const-enum-pitfalls),特别是下面的版本问题: 534 | 535 | 库 `A` 的使用者 `U` 在编译代码之后,实际上将 `A` 的*某个版本*的常值枚举的枚举值写入到了编译产物中,如果此时使用者依赖了其它库 `B`,这些库依赖了*不同版本*的 `A`,那么 `U` 和 `B` 内联得到的具体枚举值可能会不同,即便它们是同一个枚举成员,这可能会在运行时产生一些意料之外的报错。 536 | 537 | 当然,如果你只是在终端项目里使用常值枚举,并且自己的包不会被其它项目共享,那么上述问题一般不会出现。 538 | 539 | #### 在模块扩充中使用 540 | 541 | 还记得我们在[模块扩充](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E6%A8%A1%E5%9D%97%E6%89%A9%E5%85%85module-augmentation)讨论过的,模块扩充一般用来为现有的类型扩充类型定义,但是不能为它扩充值吗?实际上由于常值枚举的内联特性,我们可以使用对常值枚举使用模块扩充,此时能够实现「扩充值定义」的效果。 542 | 543 | 请看下面的例子,我们直接引用 `a.ts` 的 `Registry`,却可以访问到 `b.ts` 中扩充的枚举成员。而且,和对一般的枚举进行的模块扩充不同,我们不需要实际地给扩充类型定义的声明扩充对应的值,却可以在运行时顺利执行,不会遇到报错。这是因为常值枚举具有内联的特性,在 `c.ts` 的编译产物中只会得到 `console.log("foo")`。 544 | 545 | ```typescript 546 | // a.ts 547 | export const enum Registry {} 548 | 549 | // b.ts 550 | declare module "./a" { 551 | const enum Registry { 552 | Foo = "foo", 553 | } 554 | } 555 | 556 | export {}; 557 | 558 | // c.ts 559 | import { Registry } from "./a"; 560 | 561 | console.log(Registry.Foo); 562 | ``` 563 | 564 | 这种模块扩充的用法极其特殊,因为它做到了在不同的模块中向同一个中心化的定义同时扩充类型和值定义(尽管这个「值」只能是常值枚举支持的数字或字符串字面量)。 565 | 566 | > [!WARNING] 567 | > **对常值枚举使用模块扩充的要求很多!** 568 | > 569 | > 首先,这种用法要求编译器在编译 `c.ts` 时不仅需要扫描它直接导入的 `a.ts` 文件,还需要扫描几乎其它所有源文件(去检查它们是否使用了模块扩充),所以当你在 tsconfig 中打开了 `isolatedModules` 等选项时,这个用法是无法工作的(常值枚举会被降级为普通的枚举)。 570 | > 571 | > 其次,本节的内容基于的是 tsc 的行为,在生产环境中使用这个技巧前请先务必确认自己使用的编译工具链是否支持这种用法。包括 babel、swc、esbuild 在内的 TypeScript 编译器对常值枚举的兼容性可能不会太好,因为它们工作在这样的一个假设上:「所有的 TypeScript 源文件都可以被独立地编译」,而上述的技巧会破坏这条假设,以至于在编译产物中常值枚举可能会被降级为普通的枚举,导致模块扩充出现问题。 572 | > 573 | > 类似的问题也可能存在于命名空间上,如果你在多个源文件中扩充了同一个命名空间的定义,你必须确认编译产物符合预期。 574 | 575 | ### [枚举是联合类型](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#all-enums-are-union-enums) `5.0+` 576 | 577 | 从 TypeScript 5.0 开始,包括计算成员(computed member)在内的所有枚举成员都称为了独立类型。这意味着从此之后,一个枚举定义在类型定义的层面上实际上是一个由它的各个枚举成员对应的独立类型构成的联合类型。回忆我们在声明合并中的讨论,我们可以通过直接引用枚举定义的名字来引用它指向的类型定义。 578 | 579 | > 在 5.0 之前,如果一个枚举不包含计算成员,那么它可能也具有上述特性。 580 | 581 | 这一特性使得我们可以在模板字面量中使用枚举类型: 582 | 583 | ```typescript 584 | const enum MyEnum { 585 | Foo = "foo", 586 | Bar = "bar", 587 | Baz = "baz", 588 | } 589 | 590 | type _0 = `${MyEnum}`; 591 | // ^? type _0 = "foo" | "bar" | "baz" 592 | 593 | enum AnotherEnum { 594 | Simple = 114, 595 | Complex = Math.random(), 596 | } 597 | 598 | type _1 = `${AnotherEnum}`; 599 | // ^? type _1 = string 600 | // 由于 Complex 是计算成员,它的类型是 number 而不是字面量类型 601 | // 所以最终计算得到的字符串类型为 string 602 | ``` 603 | 604 | ### 枚举成员的透明性(opaque) 605 | 606 | 许多人可能遇到过这样的场景: 607 | 608 | ```typescript 609 | enum MyEnum { 610 | Foo = 'foo', Bar = 'bar', Baz = 'baz' 611 | } 612 | 613 | declare function myFunction(value: MyEnum): void; 614 | 615 | // 我们希望用户可以这样传参: 616 | myFunction(MyEnum.Foo); // 编译通过 617 | 618 | // 我们也希望用户不必导入 MyEnum 就能传参 619 | myFunction('foo'); // 编译不通过 :( 620 | ``` 621 | 622 | 结合前文对枚举是联合类型的讨论,我们很容易认为函数参数中的 `MyEnum` 类型就是它的成员值的联合类型,即 `'foo' | 'bar' | 'baz'`,因此也就觉得 `myFunction('foo')` 的用法是符合道理的。然而,为什么 TypeScript 会报错呢?简单来说,[这是一个设计决策](https://github.com/microsoft/TypeScript/issues/17690#issuecomment-321319291):TypeScript 的设计者希望枚举具备透明性(opaque),即枚举成员实际的值可以被修改却不会导致它的消费者出错,简单来说就是 TypeScript 不希望我们可以通过枚举的值去指代某个枚举成员,因为枚举的存在意义在于枚举成员的名字,而不是它的值,考虑下面的例子: 623 | 624 | ```typescript 625 | enum MyEnum { 626 | // 假如因为各种原因,我们去掉了 Foo,同时将 Bar 的值改为了 'foo' 627 | Bar = 'foo', Baz = 'baz' 628 | } 629 | 630 | // 假如 TypeScript 允许我们这么做,我们就会无声无息地得到一个 bug: 631 | // 在修改 MyEnum 前,'foo' 指的是 MyEnum.Foo,而之后它却指代了 MyEnum.Bar 632 | myFunction('foo'); 633 | 634 | function myFunction(value: MyEnum) { 635 | switch(value) { 636 | case MyEnum.Bar: 637 | // 啊哦!原本会进入 MyEnum.Foo 的逻辑进到了这里,天知道会发生什么事情! 638 | } 639 | } 640 | ``` 641 | 642 | 在 TypeScript 中,枚举实际上是一种「合约(contract)」,即「大家不关心成员的值是什么,只关心成员的名字」。特别是当被编译为 JavaScript 代码之后,常值枚举成员的名字不再存在,取而代之的是它实际的值。这种合约就像是在编译期(依托类型系统)对运行时中的某种常量值的约定,就像我们经常使用常量变量来在项目中「分享」某段字符串字面量,从而防止消费者不小心打错常量的值导致程序出问题那样。 643 | 644 | 一旦我们抛弃枚举的名字,试图使用枚举的值去指代枚举,那么我们很可能会在运行时中遇到类似上述的问题,此时枚举就失去了它存在的意义了。尽管你仍然可以[通过命名空间](https://www.typescriptlang.org/play?#code/HYQwtgpgzgDiDGEAEBZAngUWAVzEg3gFBIlIQAeMA9gE4AuS8VwUDAYlVUgLxIDkAM058A3MVIVq9Rs1ZIAQiBo9+AIyWjCAX0KEA9HqSAmNMBG1qkw48gDW1AFOqBZxMCwmoE8MwHdugELdAAxaBT80J00MZABVYABLZgAVKgBJYDoIGigIeDow4AAeQIA+FQAKcRJAsnI44AATKCRsYABrYCoAd2AkAH4kHKUAcwAuJECASh5sgAYkHuAIADd4wgGKEvK2zp6Q4AF4pCiB7mH8lo3dscn4sT8ApAAZEFYY4NSM7N5biOjY+MTku93CuYgyiqrag0mq0cuQev1BkgRocpjRCNkfn82mCkCs1spzlsdqQ9ucDkhxrCTv4gqFnuFsDAADYQDIAGguVwYvEu12AT3SWQeSAA2oEALpFeYVHmE+L83atHkSnE9HkAOkVHMiFOptIw5HgVOwpVpgQZrLomUyBqZ-LEuq1SmQAiqH2YSFKlKpIXgIBSzAA4tglKU0uFMjk6AALCBYXA9cJ9OXKqiqmlpaoQNBUARIAM8vg04AdEN8fmCxELPlk4Aq5208I8pMptPhfmZTPZ3PB-OC1pi5Q9PjhUM0ZDWx3O13uiClJATEDa6AAQk0TupI49wG9vpy6HDYAGUHdISgAhC0AJRxoYgMSEAboqAYO1AEb6gHh9NxIDo+milLr6QxMFgMAD6C5dbrLqur4xqW5ZqomyapkgpwQNBG6WI2Wa-C2bZCr8xaxvGtKwfBFi4NWUFprhaYIbgDZNiheYFnsOBUlSoz8L28QDv2Q6LoBY4TlO2CznwKh0VSYi+CS5ibgAajxR68CRYmWIRtYwSSeGbuauiWlSg62sA9pNDQVQ5JO049GRYCSdOUDRhOVAhKUwn6cA674WA8ocFQfQiEg54APIANKEA5OQAERCFQQUeTiJA+f5ui-Lg5gxHEHTxJuBBIG5DKKMoOiEBpWl2suSAOQATIZUkmWgiUQMlNCblZEw2XZuglU5VU1ZurmcBF0UBVUpVDBFkVDZFPVAA)或者对象字面量等方式来模拟可以被赋予字面量值的枚举,但我仍然不建议大家这么做。这种做法尽管能够提供更方便的 API,却也不可避免地带来了隐患。 645 | 646 | 不过,故事到这里还没有结束。有些出人意料的是,我们确实可以直接将数字字面量赋值给数字枚举类型: 647 | 648 | ```typescript 649 | enum MyEnum { 650 | Foo, Bar, Baz 651 | } 652 | 653 | declare function myFunction(value: MyEnum): void; 654 | 655 | myFunction(MyEnum.Foo); // OK! 656 | myFunction(0); // OK! <-- WTF? 657 | ``` 658 | 659 | TypeScript 的这种「漏洞」其实是一个重要特性带来的副作用:数字枚举可以参与数学运算,就像下面的例子。 660 | 661 | ```typescript 662 | const _1 = MyEnum.Foo | MyEnum.Bar; // OK 663 | const _2 = MyEnum.Foo * 2; // OK 664 | const _3 = MyEnum.Baz & 0; // OK 665 | ``` 666 | 667 | 虽然[从 5.0 开始,只有数字枚举成员对应的值的字面量才能被赋给枚举](https://github.com/microsoft/TypeScript/pull/51561#issue-1451913116),这依然没有解决上面提到的问题。特别地,我们还可能会因为调换枚举成员的顺序使得枚举成员的值发生改变,进而导致上述问题的出现,而这是一个更加可能犯的错误!我们或许又多了一个使用字符串枚举而不是数字枚举的理由。 668 | 669 | ## 名义类型(Nominal Typing) 670 | 671 | 还记得文章开头有关[结构化类型和名义类型之间区别](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%BB%93%E6%9E%84%E5%8C%96%E7%B1%BB%E5%9E%8Bstructural-typing)的讨论吗?在名义类型中,如果函数的入参是某种特定类型,那么我们就必须通过一定的手段构造出这个类型的值才能输入给函数。在实践中,名义类型的这种对 API 用户侧提供约束的功能有着它独特的用武之地。包括约束功能在内,名义类型有着多种特殊用法。 672 | 673 | ### 在现有类型上施加约束 674 | 675 | 假设我们想引入一系列 API,而且它们仅接受正数作为入参。传统的实践可能是在 API 的各个函数中重复地检查入参是否为正数。这种实践的扩展性很差,而且很容易引入多次不必要的检查。 676 | 677 | 通过名义类型,我们可以将这种繁琐的任务转移给用户侧,提供更加简洁、更加内聚的 API 实现: 678 | 679 | ```typescript 680 | declare const TYPE_TAG: unique symbol; // 2.7+ 681 | // 通过 & 并入一个特殊属性来定义名义类型(一些人将这个过程称为 Tagging) 682 | type PositiveValue = number & { [TYPE_TAG]: '_' }; 683 | 684 | // 我们的 API 685 | declare function doSomething(value: PositiveValue): void; 686 | 687 | // 我们自己提供一个将 number 转为 PositiveValue 的函数 688 | function asPositiveValue(value: number): PositiveValue { 689 | if (value <= 0) { 690 | throw new Error(...); 691 | } 692 | return value as PositiveValue; 693 | } 694 | 695 | doSomething(-123); // 报错 696 | doSomething(asPositiveValue(123)); // 不报错 697 | ``` 698 | 699 | 使用 `unique symbol` 而不是字符串作为属性名不是必要的,不过我们推荐使用这种方法,因为 Lanuage Service 提供的自动补全不会将这个属性考虑在内,避免对用户造成不必要的干扰,同时也能避免用户无意中访问这个「假的属性」造成运行时错误,因为这个属性只是我们在类型里附加上去的。 700 | 701 | > [!WARNING] 702 | > **错误处理实在是一个复杂的话题!** 703 | > 704 | > 关于一个函数在接受非法输入时是否应该抛错还是返回一种合法结果,需要考虑非常多复杂的状况(尽管大多数时候,人们根本不考虑这些)。名义类型为我们提供了另一种思路:用调用前检查取代调用后抛错,直接阻止用户传入非法输入。我们将在关于[非空数组的讨论](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E9%9D%9E%E7%A9%BA%E6%95%B0%E7%BB%84non-empty-array)中继续这个话题。 705 | 706 | > [!WARNING] 707 | > **对数组和对象类型使用本节介绍的方法是危险的!** 708 | > 709 | > 在 JavaScript 中数组和对象的引用是可以被随意共享的,并没有好的办法保证某个数组或者对象在某个时刻只存在一个引用。这就导致了如果你使用其中一个引用通过了类似 `asPositiveValue` 的名义类型转换,其它代码却可能通过它持有的那份引用修改了对象内部的属性,破坏了名义类型本身的约束。我们将在关于[非空数组的讨论](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E9%9D%9E%E7%A9%BA%E6%95%B0%E7%BB%84non-empty-array)中继续这个话题。 710 | 711 | ### 附加元信息 712 | 713 | 名义类型也可以用在给某个现有的类型附加一些「元信息」,这些元信息可以是泛型参数,也可以是字符串、数组等类型,用来给和它相关的函数的类型检查提供信息。在下面的例子中,我们通过给字符串附加一个泛型参数让它携带了这个字符串对应的依赖注入值的类型信息。 714 | 715 | ```typescript 716 | declare const TYPE_TAG: unique symbol; // 2.7+ 717 | type StringInjectToken = string & { [TYPE_TAG]: T }; 718 | 719 | // 依赖注入的函数 720 | declare function inject(token: StringInjectToken): T | null; 721 | 722 | // 我们的用户服务和对应的注入 Token 723 | declare class UserService {} 724 | const USER_SERVICE = "userService" as StringInjectToken; 725 | 726 | // 用户可以通过使用这个 token 获得类型提示 727 | const userService = inject(USER_SERVICE); 728 | // ^? const userService: UserService | null 729 | ``` 730 | 731 | ### 阻止 Type Alias Preservation 732 | 733 | 当我们使用一些复杂的联合类型时,由于 [4.2 版本引入的 Smarter Type Alias Preservation 特性](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-2.html#smarter-type-alias-preservation),TypeScript 不会展开这些联合类型。正如下面的例子,很难看出这个类型具体是哪些类型的联合。 734 | 735 | ```typescript 736 | type Foo = 1 | 2 | 3; 737 | type Bar = Foo | 4 | 5; 738 | // ^? type Bar = Foo | 4 | 5 739 | ``` 740 | 741 | 此时可以使用 `Foo & {}` 阻止 TypeScript 的这种行为: 742 | 743 | ```typescript 744 | type Foo = 1 | 2 | 3; 745 | type Bar = (Foo & {}) | 4 | 5; 746 | // ^? type Bar = 1 | 2 | 3 | 4 | 5 747 | ``` 748 | 749 | ### 阻止联合类型的 Subtype Reduction 750 | 751 | 当使用联合类型声明一些字面量类型和它对应的父类型(比如 `'foo'` 和 `string`)时,TypeScript 会将联合类型中的 `'foo'` 约去,因为这个字面量类型是它的子类型,而且它的值可以覆盖 `'foo'`。这个过程被称为 subtype reduction。 752 | 753 | 有时候我们并不希望出现这种行为,例如在下面的例子中,我们希望用户在使用 `foo()` 时得到 `'a'` 和 `'b'` 的自动补全提示,但碍于 TypeScript 的上述行为,自动补全只会提供 `string` 的补全提示。 754 | 755 | ```typescript 756 | declare function foo(input: "a" | "b" | string): void; 757 | ``` 758 | 759 | 此时,我们可以通过 `string & {}` 阻止这种行为。这里的原理(rationale)是通过提供一个名义类型阻止了 TypeScript 的约去动作。 760 | 761 | ```typescript 762 | declare function foo(input: "a" | "b" | (string & {})): void; 763 | ``` 764 | 765 | > 即将推出的 TypeScript 5.3 版本可能会解决上述问题,使得我们不需要再使用 `& {}`。 766 | 767 | ## 控制流中的类型具化 768 | 769 | TypeScript 在对源码的控制流分析中可能会施加[类型具化](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E5%85%B7%E5%8C%96type-narrowing),运用好这个特性能够帮助简化代码,以及提供类型检查。 770 | 771 | ### Discriminated Union Types 772 | 773 | 一种很常见的场景是,我们有一个对象值,它满足且只满足若干个 `interface` 定义的其中一个。同时,我们需要在代码中区分这个对象值究竟满足的是哪个 `interface` 定义。就像下面的例子一样: 774 | 775 | ```typescript 776 | interface Apple { 777 | foo: string; 778 | } 779 | 780 | interface Banana { 781 | bar: number; 782 | } 783 | 784 | interface Watermelon { 785 | foo: string; 786 | bar: number; 787 | } 788 | 789 | function myFunction(value: Apple | Banana | Watermelon) { 790 | // 如何类型安全地区分 value 的不同的类型? 791 | } 792 | ``` 793 | 794 | 在上面的例子中,如果三个 `interface` 都含有不同的属性,那么我们通过 `in` 关键字就能够让 TypeScript 利用类型具化的机制进行区分。但是,实际情况中我们更多地会遇到一些部分含有相同属性的类型。 795 | 796 | 除了为每个类型编写对应的 [type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) 这种费时费力且[不可扩展](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E7%B3%BB%E7%BB%9F%E7%9A%84%E7%9B%AE%E7%9A%84)的办法,我们还可以使用一种被称为 Discriminated Union Types 的技巧。在这种技巧中,需要为每个类型引入一个字符串字面量类型,作为这种类型的标签,它们的属性名必须相同,就像下面的代码那样。 797 | 798 | 这种技巧的原理是:TypeScript 在计算 `Apple | Banana | Watermelon` 这个联合类型时,会将它们的公共属性(即 `type`)提取出来作为联合类型整体的属性,此时 `type` 会指向 `'apple' | 'banana' | 'watermelon'`。通过使用控制流区分这个联合类型,TypeScript 会自动地具化它所属的 `value` 的类型。 799 | 800 | ```typescript 801 | interface Apple { 802 | type: "apple"; 803 | foo: string; 804 | } 805 | 806 | interface Banana { 807 | type: "banana"; 808 | bar: number; 809 | } 810 | 811 | interface Watermelon { 812 | type: "watermelon"; 813 | foo: string; 814 | bar: number; 815 | } 816 | 817 | function myFunction(value: Apple | Banana | Watermelon) { 818 | switch (value.type) { 819 | case "apple": 820 | // 此时,value 的类型被具化为 Apple 821 | break; 822 | // ... 823 | } 824 | } 825 | ``` 826 | 827 | ### Exhaustive Guard(Exhaustive Check) 828 | 829 | 在实践中,我们经常会对某个枚举值或者联合类型值进行特化处理,就像下面的代码这样。 830 | 831 | ```typescript 832 | enum MyType { 833 | Foo, 834 | Bar, 835 | } 836 | 837 | declare const someValue: MyType; 838 | 839 | switch (someValue) { 840 | case MyType.Foo: 841 | // 此时 someValue 的类型为 MyType.Foo 842 | break; 843 | case MyType.Bar: 844 | // 此时 someValue 的类型为 MyType.Bar 845 | break; 846 | default: 847 | // 因为我们已经在前面的分支语句处理了所有的情况 848 | // 所以现在 someValue 的类型为 never 849 | } 850 | ``` 851 | 852 | 如果 `MyType` 引入了新的枚举值 `Baz`,`default` 分支中的 `someValue` 的类型就会变为 `MyType.Baz`。利用这个特性,我们可以引入一种被称为 exhaustive guard (或者 [Exhaustive check](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking))的技巧: 853 | 854 | ```typescript 855 | function exhaustiveGuard(value: never): never { 856 | throw new Error(`Exhaustive guard failed with ${value}`); 857 | } 858 | ``` 859 | 860 | 在 `default` 分支中插入 `exhaustiveGuard(someValue)` 就可以检查当前分支下的值是不是 `never`——换句话说,通过这种方式我们可以在编译期就检查条件语句是否考虑了值的类型的所有情况,避免需要检查的值因为被引入了新的枚举值(或者联合类型)导致对应的情况进入了 `default` 分支造成各种问题。 861 | 862 | 理论上来说,`exhaustiveGuard()` 里的抛错语句不是必要的——因为在编译时就可以抛错了,对吧?不过,保险起见还是要在运行时做好兜底,因为[我们常常会通过滥用 as 等方式欺骗 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E6%BB%A5%E7%94%A8%E7%B1%BB%E5%9E%8B%E6%96%AD%E8%A8%80type-assertion),让这套仅存在于编译期的机制误以为我们已经处理了所有情况。 863 | 864 | 如果你不在乎运行时的兜底,可以使用 `satisfies 4.9+` 来做到相同的事情。我们会在[后文](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E6%BB%A5%E7%94%A8%E7%B1%BB%E5%9E%8B%E6%96%AD%E8%A8%80type-assertion)介绍这个关键字。 865 | 866 | ```typescript 867 | switch (someValue) { 868 | case MyType.Foo: 869 | // 此时 someValue 的类型为 MyType.Foo 870 | break; 871 | case MyType.Bar: 872 | // 此时 someValue 的类型为 MyType.Bar 873 | break; 874 | default: 875 | someValue satisfies never; 876 | } 877 | ``` 878 | 879 | ## 类型系统至暗时刻 880 | 881 | TypeScript 的类型系统不是完美的,在一些场景下它需要程序员去配合它才能正常工作。可是许多不耐烦的程序员总是倾向于通过一些强硬的手段让 TypeScript「闭嘴」,通过一些「不安全」的办法绕过类型检查(我曾经也会这样!)。下面,我们将介绍一些比较常见的让 TypeScript 闭嘴的方式,注意它们是如何阻碍 TypeScript 进行类型检查,造成使类型错误逃逸到运行时的风险的。 882 | 883 | ### 滥用 `any` 884 | 885 | > [!NOTE] 886 | > **回顾一下为什么 `any` 类型如此特殊!** 887 | > 888 | > `any` 类型是类型系统提供的一种「后门」。在使用它时,TypeScript 只会检查语法,而不会执行任何类型检查,尤其是在下面的两个场景中: 889 | > 890 | > - 当访问类型为 `any` 的值的属性时,TypeScript 不会进行类型检查 891 | > - 当类型为 `any` 的值被赋予子类型时,TypeScript 不会进行类型检查 892 | 893 | 这里我们不讨论将一些符号赋予为 `any` 类型的实践,因为通常它会被用在下面的场景: 894 | 895 | - 第三方依赖的类型定义有误,不得已而为之。这是第三方依赖的话题 896 | - 给予类型定义的收益不高。这是项目管理的话题 897 | 898 | 这里主要讨论一个比较常见的关于 `any` 的误用:当程序员不关心某个符号的具体类型,想要表达 **「这个符号的类型是什么都可以」** 时,使用 `any` 标注这个符号的类型。我认为,人们之所以选择使用 `any` 的原因,在于对它名字的误解。参考下面的例子,注意到 TypeScript 竟然没有报任何一个错误。 899 | 900 | ```typescript 901 | function myFunction(value: any) { 902 | value.bar(); 903 | const myNumber = value * Math.random(); 904 | console.log(value + myNumber); 905 | } 906 | ``` 907 | 908 | **「不关心类型为何」** 不能被等同于 **「可以忽略它的类型」**,而应被视为 **「它存在一个未知的类型」** 。许多人采用了前一种思考方式,让 `any` 类型禁用了相关的类型检查,导致代码出现了具有传播性的漏洞,这种漏洞会导致类型错误逃逸到运行时中,削弱了 TypeScript 本身的作用。试想,我们需要调用一个仅接受字符串的 API,而实际传入的是一个 `any` 类型的值,我们又做了什么事情确保它的类型是正确的呢? 909 | 910 | 同样是上面的例子,当使用 `unknown` 取代 `any` 之后,TypeScript 正确地提供了类型错误,让我们在使用 `value` 之前先确定好它的具体类型。从这个角度来说,`unknown` 远比 `any` 更加切合需求。事实上,除了「[都是 top types](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E6%98%AF%E5%80%BC%E7%9A%84%E9%9B%86%E5%90%88)」这一点外,`unknown` 和 `any` 几乎没有任何共同点了!当需要使用 top types 时,我们在绝大多数情况下需要的都会是 `unknown` 而不是 `any`。 911 | 912 | TypeScript 自带的类型库,特别是 `JSON.parse` 和 `fetch.json()` 的返回值都使用了 `any` 类型,饱受许多人的诟病。一些[工具类型库](https://github.com/total-typescript/ts-reset)会将它们修补为 `unknown` 类型。 913 | 914 | ### 滥用类型断言(Type Assertion) 915 | 916 | [类型断言](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions),包括 `as` 关键字在内,是 TypeScript 中又一个受到了大量滥用的功能。它的作用是将某个符号的类型进行*强制的 upcast 或 downcast*,让程序员可以为符号赋予一些 TypeScript 没能推导出来的类型。对它大多数滥用都将它当作了类似于声明变量时使用的 `variable: type` 这种手动标注类型的办法。 917 | 918 | 在下面的例子中,我们的目标是「让 `myObject` 具有 `MyObject` 类型」。注意到我们并没有给 `myObject` 填入应该填入的 `bar` 和 `baz` 属性,但 TypeScript 并没有提供任何错误。这是因为通过 `as` 将对象字面量推导出来的类型 `{ foo: string }` 被强制地 downcast 为了 `MyObject` 类型,然后将它分配给了符号 `myObject` 的类型。这个过程中并不会发生期望的类型检查。 919 | 920 | ```typescript 921 | interface MyObject { 922 | foo: string; 923 | bar: number; 924 | baz: boolean; 925 | } 926 | 927 | const myObject = { 928 | foo: "Hello, World!", 929 | } as MyObject; 930 | ``` 931 | 932 | 正如[类型是值的集合](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E6%98%AF%E5%80%BC%E7%9A%84%E9%9B%86%E5%90%88)提到的,downcast 不是一种安全的操作,但是 `as` 会让 TypeScript 忽略这个问题,毕竟这个功能叫「断言(assertion)」。如果我们将这样的 `myObject` 传给其它参数,我们很可能在运行时会遇到报错,完全放弃了 TypeScript 能够提供的类型检查能力。 933 | 934 | > 尽管如此,类型断言还是会在程序员尝试将类型转化为不兼容的类型时抛错,例如: 935 | > 936 | > ```typescript 937 | > const x = "hello" as number; 938 | > // Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. 939 | > ``` 940 | > 941 | > 为此,TypeScript 提供了一些方法绕过这种检查,例如先泛化再具化: 942 | > 943 | > ```typescript 944 | > const x = "hello" as unknown as number; 945 | > // OK 946 | > ``` 947 | 948 | > [!NOTE] 949 | > TypeScript 的 4.9 版本引入了[一个新的关键字 satisfies](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator),它的语义是「将某个值的类型推导为给定类型」(注意它和类型断言在语义上的显著区别)。在上面的例子中我们可以将 `as MyObject` 修改为 `satisfies MyObject`,这样 TypeScript 就会提供正确的报错。这里的原理(rationale)是:我们通过 `satisfies` 告诉 TypeScript 这个对象字面量的类型是 `MyObject`,当我们修改时请检查这个字面量的值是否满足这个类型。 950 | 951 | 具有讽刺意味的是,[TypeScript 的官方文档说,类型断言应该被运用在类似下面的场合](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions): 952 | 953 | ```typescript 954 | const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement; 955 | ``` 956 | 957 | TypeScript 不知道我们的 `#main_canvas` 指向一个 `canvas` 元素,但知道它是一个 `HTMLElement`,所以我们需要通过这种方式来将类型具化为 `HTMLCanvasElement`。看上去很有道理,但我认为这导致了关于类型断言的另一种滥用形式,它仍然不是一种安全的用法。注意到 `document.getElementById()` 的函数签名如下: 958 | 959 | ```typescript 960 | getElementById(elementId: string): HTMLElement | null; 961 | ``` 962 | 963 | 此函数在 `elementId` 指向的元素不存在时会返回 `null`,而我们通过类型断言将返回值限定为 `HTMLCanvasElement` 之后舍弃了这个信息。这就意味着我们依然抛弃了 TypeScript 的类型检查能力,在运行时可能遇到报错(可以据此想像有多少类型错误这种滥用而逃逸到了运行时中)。 964 | 965 | 类似这种返回值类型包含空值类型,程序员却通过类型断言忽视掉的问题甚至有可能造成 eslint 这类工具的误判。在我所在的团队中,近期发生了数个 Oncall 缺陷,它们的原因相同:eslint 认为某些使用了类型断言的变量(例如 `const foo = ... as Foo`)不可能包含空值,所以将引用了 `foo` 的 optional chain 去掉了(例如将 `foo?.bar()` 改为了 `foo.bar()`)。 966 | 967 | 一个可能的解决方法是:使用 `as HTMLCanvasElement | null`。但它不是一个[可扩展](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E7%B3%BB%E7%BB%9F%E7%9A%84%E7%9B%AE%E7%9A%84)的办法:如果这个函数发生了修改,返回了其它类型的元素,或者会返回 `undefined` 而不是 `null`,那么我们使用的类型断言很可能不会产生任何报错,类型错误又逃逸到了运行时中。 968 | 969 | 说到这里,我很倾向于告诉大家 **不要使用任何类型断言**,因为我几乎想不到它的任何安全的使用价值,除了[我们在名义类型中看到的用法](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%90%8D%E4%B9%89%E7%B1%BB%E5%9E%8Bnominal-typing),以及作为某些细分场景下不得已的 workaround。如果你真的遇到了需要使用类型断言的场景,那么这说明你使用的 API 有问题,应该从源头修复问题而不是在另一端假装自己避开了问题。正如我们看到的,类型断言很容易成为一种掩耳盗铃的、自欺欺人的手段。 970 | 971 | > [!NOTE] 972 | > 就上面的 `getElementById()` 来说,我认为它的类型定义应该被修改为: 973 | > 974 | > ```typescript 975 | > getElementById(id: string): T | null; 976 | > ``` 977 | > 978 | > 这样,用户既能够手动限定类型,又能够受到返回值可能为 `null` 的约束。 979 | 980 | ### 误用泛型 981 | 982 | 这里讨论一个常见的对泛型的误用:实例化(instantiate)某个泛型参数之后没有为它提供类型别名。 983 | 984 | 我们从下面的例子出发,这个例子定义了一个带泛型的类 `API`,它的泛型参数用来承载 `foo` 属性。我们常在生产实践中看到这样的类,它们接受泛型参数让用户可以指定具体的类型,而类本身不太关心具体的类型为何(或者说它们本身只工作在通过 `extends` 约束的顶层接口上)。 985 | 986 | ```typescript 987 | class API { 988 | constructor(readonly foo: T) {} 989 | } 990 | ``` 991 | 992 | 问题常常出现在不同模块或函数共享某个 `API` 的实例类型的时候。我们很容易忘记泛型信息的传递,导致用户不得不使用类型断言,就像下面的例子这样。 993 | 994 | ```typescript 995 | class OtherModule { 996 | constructor(readonly api: API); 997 | } 998 | 999 | const api = new API("It's a string!"); 1000 | const otherModule = new OtherModule(api); 1001 | doSomething(otherModule.api.foo as string); 1002 | ``` 1003 | 1004 | 就这个例子来说,问题的直接原因是:我们为 `API` 的泛型参数提供了默认值,导致用户可以「偷懒」地忽略对泛型参数的指定;根本原因则是:我们没有独立地看待实例化后的泛型类型。 1005 | 1006 | [后文](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E6%B3%9B%E5%9E%8Bgenerics)会详细讨论泛型,不过目前我们只需要知道:带泛型的类型一旦被实例化(被赋予具体的类型)之后会成为一个新的类型。换言之,`API` 和 `API`(由默认值等价于 `API`)不应该被视为相同的类型,即使有时候后者可以被赋给前者。 1007 | 1008 | `OtherModule` 的构造函数中 `api` 的类型被定义为 `API` 就是一个根本性的错误,但是即便我们通过去掉 `API` 的泛型默认值,也不能得到一个[可扩展](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E7%B3%BB%E7%BB%9F%E7%9A%84%E7%9B%AE%E7%9A%84)的解决方案。所有需要共享 `API` 类型的地方都需要手动地指定 `string` 作为 `T` 的类型,而一旦后续决定不再使用 `string` 类型,则需要花费很大力气修改代码。 1009 | 1010 | 一个可能的解决方案是:为 `API` 类型提供别名,尊重「它已经是一个新类型」的事实。就像下面这样: 1011 | 1012 | ```typescript 1013 | type MyAPI = API; 1014 | 1015 | class OtherModule { 1016 | constructor(api: MyAPI); 1017 | } 1018 | 1019 | const api: MyAPI = new API("It's a string!"); 1020 | const otherModule = new OtherModule(api); 1021 | doSomething(otherModule.api.foo); // 不再需要类型断言! 1022 | ``` 1023 | 1024 | 总的来说,一旦需要复用某些类型(特别是实例化后的泛型类型),我们都需要使用类型别名让这种类型称为一种「权威」,一种可供后续代码直接复用的东西。这些代码不应该承担「应该如何实例化泛型类型」的职责。 1025 | 1026 | ### 慎用 Type Predicate 1027 | 1028 | 我们在前文类型具化中提到,可以使用 Type Predicates 来手动具化类型,就像下面的代码这样: 1029 | 1030 | ```typescript 1031 | declare function isNumber(input: unknown): input is number; 1032 | 1033 | declare const someValue: unknown; 1034 | if (isNumber(someValue)) { 1035 | // 此时,someValue 的类型被具化为 number 1036 | } 1037 | ``` 1038 | 1039 | Type Predicates 其实是一种和类型断言类似的强制性手段,尽管它确实存在运行时的检查能够保障这种断言的正确性,不过我们必须注意:这只是一种快照式的保障,它只能确保给定的值在输入函数的那一刻是某种类型,它不总能确保它在之后仍然是这种类型。请看下面的例子: 1040 | 1041 | ```typescript 1042 | declare function isArrayOfSingleNumber(array: unknown[]): array is [number]; 1043 | 1044 | declare const myArray: unknown[]; 1045 | if (isArrayOfSingleNumber(myArray)) { 1046 | // myArray 现在确实是 [number] 了 1047 | 1048 | // 😈 做点坏事! 1049 | myArray.push(2); 1050 | 1051 | // 呃……myArray 现在的类型仍然是 [number],尽管我们都知道它应该是 [number, number] 了 1052 | } 1053 | ``` 1054 | 1055 | 对于数组和对象这类能够就地(in-place)修改数据的类型来说,我们可能通过这些修改而导致它的类型不再和 Type Predicates 具化为的类型一致,从而可能引发和类型断言一样的问题。从这点上看,Type Predicates 除非被用在原始类型上,否则它至少和类型断言同等危险,尤其是许多人都以为它能够在运行时进行检查于是就将它作为一种安全的手段而放松了警惕。因此,在使用类型断言时需要特别注意检查后的值是否会从原本的类型空间迁移出去。 1056 | 1057 | 就数组和对象来说,我们可以修改 Type Predicate 的返回值类型为只读(readonly)类型,这样就可以避免用户后续再对它做出修改,就像下面的代码: 1058 | 1059 | ```typescript 1060 | // 👇 注意新增的 readonly 关键字 1061 | declare function isArrayOfSingleNumber(array: unknown[]): array is readonly [number]; 1062 | 1063 | if (isArrayOfSingleNumber(myArray)) { 1064 | // 👿 怎么编译不通过了,原来是因为 ReadonlyArray 类型没有 push 函数! 1065 | myArray.push(2); 1066 | } 1067 | ``` 1068 | 1069 | 在本节末尾,我想额外讨论一个我讨厌 Type Predicate 的原因:*它对 API 提供者自己来说也不是安全的*。 1070 | 1071 | 笔者最近在编写幻灯片的动画播放控制功能,我提供了一个时间线抽象来承载需要先后按顺序触发的动画,在时间线上,我们有两种不同的步骤,一种位于时间线的两个端点,用于标识时间线的界限;另一种位于端点之间,用于标识各种动画的执行。这两种步骤包含不同的信息,其中后者会包含一个 `index` 属性用于区分它们之间的相对顺序,就像下面的代码: 1072 | 1073 | ```typescript 1074 | // 位于端点的步骤类型 1075 | export interface BoundaryStep { 1076 | kind: 'initial' | 'final'; 1077 | } 1078 | 1079 | // 位于端点之间的步骤类型 1080 | export interface ActionStep { 1081 | kind: 'leading' | 'gap' | 'ending' | 'formal'; 1082 | index: number; 1083 | } 1084 | 1085 | // 它们构成的联合类型 1086 | export type AnimationTimelineStep = BoundaryStep | ActionStep; 1087 | ``` 1088 | 1089 | 现在,给定一个 `AnimationTimelineStep` 类型的值,我的代码需要能够区分这两种不同类型的步骤。我们自然而然地想到可以提供一个 Type Predicate 来完成这件事,就像下面的代码: 1090 | 1091 | ```typescript 1092 | export function isBoundaryStep(step: AnimationTimelineStep): step is BoundaryStep { 1093 | return step.kind === 'initial' || step.kind === 'final'; 1094 | } 1095 | ``` 1096 | 1097 | 这似乎是个可行的方案,只是给 API 提供者带来了一个问题:如果后续 `BoundaryStep` 的 `kind` 新增了一些情况,又或者我们把 `'initial'` 的情况搬到了另一种步骤类型,那么这个 Type Predicate 不会有任何编译错误,但它在运行时却不能正确地工作了。 1098 | 1099 | 也许你会尝试将上述判据改为 `!('index' in step)`,但这是一个更加危险的做法: 1100 | 1101 | - `BoundaryStep` 未来可能也会引入一个 `index` 属性,届时你的判据会失效,但仍然编译通过。 1102 | - `ActionStep` 未来可能会去掉 `index` 属性或者改名,届时你的判据也会失效,但仍然编译通过。 1103 | 1104 | 总之,在这种情况下 Type Predicate 无法为你的判断条件提供充足的类型检查,这就是它尴尬的处境!它很难保证代码的正确性,无论是对 API 的用户还是作者而言皆如此。对于上面的例子,我想到了下面的两种解决办法,但他们无一不带来了额外的问题,很难称得上实用且安全。 1105 | 1106 | ```typescript 1107 | // 方案一:转向 class 1108 | export class BoundaryStep { 1109 | constructor( 1110 | readonly kind: 'initial' | 'final' 1111 | ) {} 1112 | } 1113 | 1114 | export class ActionStep { 1115 | constructor( 1116 | readonly kind: 'leading' | 'gap' | 'ending' | 'formal', 1117 | readonly index: number, 1118 | ) {} 1119 | } 1120 | 1121 | declare const someStep: AnimationTimelineStep; 1122 | 1123 | // 这样,我们就可以通过 instanceof 来安全地判断究竟是哪个类型了 1124 | if (someStep instanceof BoundaryStep) { 1125 | // ... 1126 | } 1127 | 1128 | // 😭 可是,用户被迫地需要使用构造函数来创建实例 1129 | // 通过函数传参的方式构造对象实例并不直观,而且不安全: 1130 | // 函数参数调换位置或发生其他改变时,调用它的用户可能不会产生编译错误 1131 | const myStep = new ActionStep('leading', 0); 1132 | ``` 1133 | 1134 | ```typescript 1135 | // 方案二:手动指定 Tag 1136 | export interface BoundaryStep { 1137 | tag: 'boundary'; 1138 | kind: 'initial' | 'final'; 1139 | } 1140 | 1141 | export interface ActionStep { 1142 | tag: 'action' 1143 | kind: 'leading' | 'gap' | 'ending' | 'formal'; 1144 | index: number; 1145 | } 1146 | 1147 | export function isBoundaryStep(step: AnimationTimelineStep): step is BoundaryStep { 1148 | return step.tag === 'boundary'; 1149 | } 1150 | 1151 | // 🙃 可是,用户以后创建实例时就必须带上奇怪的 tag,非常繁琐 1152 | const myStep = { tag: 'action', kind: 'leading', index: 0 }; 1153 | ``` 1154 | 1155 | 更好的解决方案通过引入特殊的构造方式和 API 来解决问题,这里推荐使用 [@practical-fp/union-types](https://www.npmjs.com/package/@practical-fp/union-types?activeTab=readme) 库。下面是一段解决了上述问题的代码。顺便说一句,这个库要求 TypeScript `4.2+` 版本。 1156 | 1157 | ```typescript 1158 | // 类似方案二的方式,不过它帮我们封装好了很多东西 1159 | type AnimationTimelineStep = 1160 | | Variant<"BoundaryStep", { kind: 'initial' | 'final' }> 1161 | | Variant<"ActionStep", { kind: 'leading' | 'gap' | 'ending' | 'formal' }> 1162 | 1163 | // impl() 使用了 Proxy,如果你不喜欢可以改用 constructor() 1164 | const { BoundaryStep, ActionStep } = impl() 1165 | 1166 | function doSomethingWithStep(step: AnimationTimelineStep) { 1167 | // 通过模式匹配来区分类型 1168 | return matchExhaustive(step, { 1169 | BoundaryStep: (...) => ..., 1170 | ActionStep: (...) => ..., 1171 | }) 1172 | } 1173 | 1174 | // 😘 相比之下,这种构造方式更加清晰直观! 1175 | const circle = BoundaryStep({ kind: 'initial' }) 1176 | ``` 1177 | 1178 | ## 一些零碎的技巧 1179 | 1180 | 本节会介绍一些零碎的 TypeScript 技巧,它们的内容不足以让它们成为单独的一节。 1181 | 1182 | ### `const` 类型参数(`const` Type Parameters) `5.0+` 1183 | 1184 | 在声明函数中的泛型时,可以通过额外的 `const` 关键字来约束类型系统在类型推导时尽量使用字面量类型。目前,这种约束只对函数调用时直接写在括号中的字面量有效。 1185 | 1186 | 下面是它的使用例子,注意到要想让 `names` 被推导为字面量类型,过去我们只能通过让函数调用方手动编写 `as const` 来实现。这是不方便的,因为对字面量的需求来自于函数定义方,不应该将这层抽象泄漏给函数调用方。 1187 | 1188 | ```typescript 1189 | type HasNames = { names: readonly string[] }; 1190 | declare function getNamesExactly(arg: T): T["names"]; 1191 | 1192 | const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] }); 1193 | // ^? const names: string[] 1194 | ``` 1195 | 1196 | 通过使用 `const` 类型参数,可以在函数定义侧做到和 `as const` 类似的事情: 1197 | 1198 | ```typescript 1199 | type HasNames = { names: readonly string[] }; 1200 | declare function getNamesExactly(arg: T): T["names"]; 1201 | 1202 | const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] }); 1203 | // ^? const names: readonly ["Alice", "Bob", "Eve"] 1204 | ``` 1205 | 1206 | ### [Immediately Indexed Mapped Type(IIMT)](https://www.totaltypescript.com/immediately-indexed-mapped-type) 1207 | 1208 | 这个技巧主要用于对一个联合类型进行迭代,进而构造出一个由对象类型构成的联合类型。它的基本形式如下: 1209 | 1210 | ```typescript 1211 | export type ResultType = { 1212 | // 👇 T 取自联合类型 👇 使用 T 构建对应的对象类型 1213 | [T in SomeUnionType]: {}; 1214 | }[SomeUnionType]; // 👈 创建一个马上运行的映射类型(mapped type) 1215 | ``` 1216 | 1217 | 下面是一个使用例子,它构造了 CSS 单位构成的对象类型的联合类型: 1218 | 1219 | ```typescript 1220 | type CSSUnits = "px" | "em" | "rem" | "vw" | "vh"; 1221 | 1222 | /** 1223 | * type CSSLength = 1224 | * | { length: number; unit: 'px'; } 1225 | * | { length: number; unit: 'em'; } 1226 | * | { length: number; unit: 'rem'; } 1227 | * | { length: number; unit: 'vw'; } 1228 | * | { length: number; unit: 'vh'; }; 1229 | */ 1230 | export type CSSLength = { 1231 | [U in CSSUnits]: { 1232 | length: number; 1233 | unit: U; 1234 | }; 1235 | }[CSSUnits]; 1236 | ``` 1237 | 1238 | 我们可以将这种技巧推广到由对象类型构成的联合类型,就像下面的例子这样: 1239 | 1240 | ```typescript 1241 | type Event = 1242 | | { 1243 | type: "click"; 1244 | x: number; 1245 | y: number; 1246 | } 1247 | | { 1248 | type: "hover"; 1249 | element: HTMLElement; 1250 | }; 1251 | 1252 | // 对某个含有 type 属性的对象类型,将它的 type 属性加上一个字符串前缀 1253 | // 同时,其它属性保持不变 1254 | type PrefixType = { 1255 | type: `PREFIX_${E["type"]}`; 1256 | } & Omit; 1257 | 1258 | /** 1259 | * | { type: 'PREFIX_click'; x: number; y: number; } 1260 | * | { type: 'PREFIX_hover'; element: HTMLElement; } 1261 | */ 1262 | type Example = { 1263 | // 👇 使用了「映射类型中键的重映射」 1264 | [E in Event as E["type"]]: PrefixType; 1265 | }[Event["type"]]; 1266 | ``` 1267 | 1268 | ### 工具类型 `Prettify` 1269 | 1270 | 当我们写出一些复杂的对象类型时,TypeScript 的类型提示并不会显示它的实际内容(或者说,它展示的类型不够直观),这对使用这个类型造成了一定的困难,因为我们不能马上知道这个类型究竟具有哪些属性。 1271 | 1272 | ```typescript 1273 | type TypeA = { foo: number }; 1274 | type TypeB = { a: TypeA } & { baz: number }; 1275 | // ^? type TypeB = { a: TypeA } & { baz: number } 1276 | ``` 1277 | 1278 | 我们可以使用名义类型中[阻止 Type Alias Preservation](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E9%98%BB%E6%AD%A2-type-alias-preservation) 的技巧,编写下面这样的工具类型。 1279 | 1280 | ```typescript 1281 | export type Prettify = { 1282 | [K in keyof T]: Prettify; 1283 | } & {}; 1284 | ``` 1285 | 1286 | 有了它的帮助,上面的 `TypeB` 会被 TypeScript 提示为更加清晰直观的形式。 1287 | 1288 | ```typescript 1289 | type TypeB = Prettify<{ a: TypeA } & { baz: number }>; 1290 | // ^? type TypeB = { a: { foo: number }; baz: number; } 1291 | ``` 1292 | 1293 | ### 非空数组(Non-Empty Array) 1294 | 1295 | 在一些场景下,长度为 `0` 的数组(即空数组)是有害的,或者说严重违背了 API 的语义。在这种情况下,用户向 API 传入的空数组具有着类似 `undefined` 或者 `null` 那样的破坏力。考虑一个 `last` 函数,它接受一个数组作为参数,返回这个数组的末尾元素。从设计的角度来说,如何处理「用户可以输入空数组」这一事实呢? 1296 | 1297 | - 允许用户输入空数组,当发现数组为空时,抛错 1298 | 1299 | 可是,抛错是一件很严肃的事情。我们需要在注释中写清楚这个函数可能抛出的错误。但是,在 JavaScript 中并不存在一种手段让用户在调用函数时必须处理潜在的抛错。所以,粗心的用户总是会什么都不检查就调用我们的函数,代码质量悄悄地降下了它的身姿。 1300 | 1301 | - 允许用户输入空数组,当发现数组为空时,返回 `null` 1302 | 1303 | 或许是一种可行的方案。用户需要手动检查返回值是否为 `null`,从而知道自己是否传入了非法的参数。不过,如果我们的函数不是 `last`,而是一些没有办法提供有意义的特殊返回值的函数(特别是一些存在复杂的副作用逻辑的函数),这个方法就行不通了。 1304 | 1305 | 顺着在[名义类型](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%90%8D%E4%B9%89%E7%B1%BB%E5%9E%8Bnominal-typing)中的讨论,我们提出另外一种解决思路:不允许用户输入空数组。这样,我们就将输入合法性的问题从函数的实现方转嫁给了调用方,从某种程度上说这样是更合理的,就像你不能向汽车油箱里加入茅台咖啡一样,那是 API 使用者的责任而不是提供者的责任。 1306 | 1307 | 在下面的例子中,用户必须先通过 `isNonEmptyArray` 检查它们的数组是否非空数组,然后才能调用 `last`。 1308 | 1309 | ```typescript 1310 | declare const TYPE_TAG: unique symbol; // 2.7+ 1311 | type NonEmptyArray = readonly T[] & { [TYPE_TAG]: never }; 1312 | 1313 | // 给用户提供一个函数来进行检查和类型转换 1314 | function asNonEmptyArray(array: readonly T[]): NonEmptyArray { 1315 | if (!array.length) { 1316 | throw new Error(...); 1317 | } 1318 | 1319 | return array as any; 1320 | } 1321 | 1322 | declare function last(array: NonEmptyArray): T; 1323 | 1324 | // 这样,用户在调用 last 函数之前就必须先确保自己的函数非空 1325 | const nonEmptyArray = asNonEmptyArray(myArray); 1326 | last(nonEmptyArray); 1327 | ``` 1328 | 1329 | 为什么需要对 `NonEmptyArray` 返回的数组类型施加 `readonly`?考虑下面的情况: 1330 | 1331 | ```typescript 1332 | const nonEmptyArray = asNonEmptyArray(myArray); 1333 | 1334 | // 🙀 还好有 readonly 的存在,让 nonEmptyArray 拥有 ReadonlyArray 类型 1335 | // 否则下面的代码是可以通过类型检查的,它会导致在运行时中数组重新变成了空数组! 1336 | nonEmptyArray.length = 0; 1337 | 1338 | // 🙀 类似地,如果不添加 readonly,那么下面的函数调用就会没有类型错误! 1339 | function evilFunction(array: unknown[]) { 1340 | array.length = 0; 1341 | } 1342 | evilFunction(nonEmptyArray); 1343 | 1344 | last(nonEmptyArray); 1345 | ``` 1346 | 1347 | > [!WARNING] 1348 | > **小心,对数组和对象使用名义类型存在风险!** 1349 | > 1350 | > 如果其它代码持有对 `myArray` 的引用,那么它们仍然可以随意修改数组的内容(包括将它清空)。不过,对于 `last` 函数来说由于它不是异步函数,一般不会被这个问题影响。此外,你可能会尝试提供一个 type predicate 方便用户将数组转换为非空数组类型,但它仍有上面的问题。 1351 | > 1352 | > 类似地,如果通过名义类型修饰一个对象类型,尽管你可以使用 `Readonly` 这类工具类型将它转换为只读类型,阻止用户后续的修改,你也还是暴露在了相同的风险中。 1353 | > 1354 | > 事实上,「在类型系统侧提供一个原生的非空数组类型」的话题是备受争议的,相关的讨论可以见: 1355 | > 1356 | > - https://github.com/sindresorhus/type-fest/issues/661#issuecomment-1666553729 1357 | > 1358 | > - https://github.com/microsoft/TypeScript/issues/38000 1359 | > 1360 | > - https://stackoverflow.com/questions/56006111/is-it-possible-to-define-a-non-empty-array-type-in-typescript 1361 | > 1362 | > 总之,我们需要记住像非空数组这样「对数组或者对象使用名义类型」的方法并不是完全可靠的。在生产实践中应该事先思考清楚上面的问题再决定是否使用。 1363 | 1364 | ### [函数中的 this](https://www.typescriptlang.org/docs/handbook/2/functions.html#declaring-this-in-a-function) 1365 | 1366 | 在 TypeScript 的函数类型定义中,我们实际上可以提供一个特殊的 `this` 参数,通过指定它的类型,可以指定在这个函数中 `this` 的指向。此时,用户若传入箭头函数,它的 `this` 仍然指向全局空间。 1367 | 1368 | ```typescript 1369 | interface User { 1370 | admin: boolean; 1371 | } 1372 | 1373 | interface DB { 1374 | filterUsers(filter: (this: User) => boolean): User[]; 1375 | } 1376 | 1377 | declare const db: DB; 1378 | const admins = db.filterUsers(function () { 1379 | // 👇 此时,this 指向 User 1380 | return this.admin; 1381 | }); 1382 | ``` 1383 | 1384 | 特别地,可以通过将 `this` 的类型指定为 `never` 来阻止用户在函数中使用 `this`。 1385 | 1386 | ### 调用类型的函数 1387 | 1388 | 如果你检查过 `keyof string` 这个类型里面有什么,你会发现它会包含字符串对象里面的各种属性名。类型系统为各种基础类型都提供了它的实例通过原型链获得的可访问的属性。其中,类型系统还会将一些类型的个别属性具化为有用的字面量类型,[正如我们在元组中看到的那样](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%90%8D%E4%B9%89%E7%B1%BB%E5%9E%8Bnominal-typing)。 1389 | 1390 | 这里介绍一个关于 `valueOf` 的技巧。我们可以通过取得这个函数的返回值类型来获得字面量类型对应的父类型。 1391 | 1392 | ```typescript 1393 | type GetParentTypeOf = ReturnType; 1394 | 1395 | type _1 = GetParentTypeOf; 1396 | // ^? type _1: number 1397 | 1398 | type _2 = GetParentTypeOf<123>; 1399 | // ^? type _2: number 1400 | ``` 1401 | 1402 | --- 1403 | 1404 | | **上一章** | **目录** | **下一章** | 1405 | | :------------- | :----------: | :------: | 1406 | | [第一章:基础知识](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第三章:类型编程](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md) | 1407 | -------------------------------------------------------------------------------- /chapter3.md: -------------------------------------------------------------------------------- 1 | | **上一章** | **目录** | **下一章** | 2 | | :------------- | :----------: | :------: | 3 | | [第二章:进阶话题](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第四章:生产实践](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter4.md) | 4 | 5 | --- 6 | 7 | # 第三章:类型编程 8 | 9 | 进阶话题中的内容远远不是 TypeScript 强大的类型系统的全部。还记得[类型系统的目的](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E7%B3%BB%E7%BB%9F%E7%9A%84%E7%9B%AE%E7%9A%84)说过的,TypeScript 的设计目标之一是为「结构」提供检查手段吗?我们已经在进阶话题中讨论了很多检查手段,不过它们大多都只是直接基于类型系统提供的原语实现的。事实上,这套类型系统还能构造出更加复杂的检查手段,而它们才真正代表了 TypeScript 的上限。 10 | 11 | 有关 TypeScript 的类型系统,你可能听说过一个极其经典的说法,即[它的类型系统是图灵完备(turing complete)的](https://github.com/microsoft/TypeScript/issues/14833)。我不是 PL 的研究者,所以不会在这里做复杂的讨论,我们只需要知道 TypeScript 的类型系统具有极为强大的计算能力,这种能力至少在图灵完备的视角上和 Java、C++、JavaScript 等现代编程语言相当。基于这种计算能力,理论上我们能够使用 TypeScript 表达很多复杂的类型限制(尽管难度可能不低)。 12 | 13 | 本节的故事会从 TypeScript 的类型系统本身开始,介绍它之所以具备图灵完备性所仰赖的几个关键功能。然后,我们会介绍如何通过函数来检查一些需要进行特殊检查的值。本节的话题被称为「类型编程」,因为我们将会看到,类型编程做的事情就是使用 TypeScript 的类型系统算出一个类型,然后检查传入的值是否满足这个类型。 14 | 15 | ## [泛型(Generics)](https://www.typescriptlang.org/docs/handbook/2/generics.html) 16 | 17 | 在 TypeScript 中,泛型被视为向类型系统中引入变量的手段,通过使用泛型我们可以在类型上下文中引入一些互相关联的类型。例如,在下面的 `sum` 函数中,通过引入泛型统一了函数的入参和返回值的类型。 18 | 19 | ```typescript 20 | declare function sum(numbers: T[]): T; 21 | ``` 22 | 23 | 顺带一提,如果没有对相关联的类型的需求,那么一般不需要引入泛型,这是一些人容易犯的失误。 24 | 25 | ```typescript 26 | // 没有必要的泛型,等价于 arg: unknown 27 | declare function foo(arg: T): void; 28 | ``` 29 | 30 | > [!NOTE] 31 | > 如果你熟悉函数式编程(functional programming),你可能会觉得这里使用「变量」一词并不贴切。 32 | > 33 | > TypeScript 的类型系统在形式上更接近函数式语言而不是命令式语言,而在函数式语言中严格来说并不存在大多数人理解的「变量」这种说法,所有的运算都是通过 [λ 演算(λ-calculus)](https://en.wikipedia.org/wiki/Lambda_calculus)表达的。不过,由于我猜测大多数阅读这篇文章的人相比函数式编程更熟悉传统的命令式编程,所以在这整一节中我都会使用更加偏向命令式编程的比喻修辞。 34 | 35 | ### 类型中的泛型 36 | 37 | 在使用 `type`、`interface`、`class` 等关键字定义类型时,可以为类型引入泛型,泛型参数可以被理解为「构造这个类型所需要输入的变量」。带有泛型参数的类型定义就像一个函数,它接受其它类型作为入参,返回构造出来的类型。 38 | 39 | 例如,下面的例子提供了一个 `Nullable` 类型,它接收一个泛型类型,输出它和 `null` 构成的联合类型: 40 | 41 | ```typescript 42 | type Nullable = T | null; 43 | ``` 44 | 45 | 我们可以将这个类型想象成下面这段伪代码,注意到它返回了一个联合类型,而 TypeScript 会使用这个联合类型执行后续的类型检查。 46 | 47 | ```typescript 48 | function nullable(someType) { 49 | return new UnionType([someType, NullType]); 50 | } 51 | ``` 52 | 53 | 这种基于给定类型构造其它类型的过程是类型编程的基础之一,我们会在[后文](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E5%87%BD%E6%95%B0%E6%B3%9B%E5%9E%8B)继续讨论这一点。 54 | 55 | ### 函数中的泛型 56 | 57 | 在声明函数时,我们也可以为函数引入泛型。不过,和类型中的泛型不同,函数中的泛型既可以手动指定也可以由 TypeScript 自动推导。而后者是利用 TypeScript 对值进行特殊的类型检查所依赖的底层能力。我们将马上在[后文](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E5%87%BD%E6%95%B0%E6%B3%9B%E5%9E%8B)继续讨论这一话题。 58 | 59 | ### 泛型约束(Generic Constraints) 60 | 61 | 通过对泛型参数使用 `extends` 关键字,可以限定这个泛型的类型范围,参见[类型是值的集合](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter1.md#%E7%B1%BB%E5%9E%8B%E6%98%AF%E5%80%BC%E7%9A%84%E9%9B%86%E5%90%88)。 62 | 63 | 在下面的例子中,我们定义了一个带泛型的类型,它接受一个字符串类型的泛型参数,输出一个函数类型。要使用这个类型,需要为泛型指定一个具体的类型,比如 `'seele'`。 64 | 65 | ```typescript 66 | type Foo = () => T; 67 | 68 | const _0: Foo<"seele"> = () => "seele"; // OK 69 | const _1: Foo<"seele"> = () => "sirin"; // ERROR 70 | ``` 71 | 72 | 如果继续使用「函数」的比喻,那么它看上去有些像下面这段伪代码。 73 | 74 | ```typescript 75 | function foo(t: StringType) { 76 | return new FunctionType({ returnType: t }); 77 | } 78 | 79 | check(foo(new LiteralStringType("seele")), () => "seele"); // OK 80 | check(foo(new LiteralStringType("seele")), () => "sirin"); // ERROR 81 | ``` 82 | 83 | ## [条件类型(Conditional Types)](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#handbook-content) 84 | 85 | 条件类型指的是一套机制,它根据输入类型是否满足给定条件,返回不同的类型作为结果。它具有下面的代码展示的形式。其中,若条件满足,`Result` 最终指向 `TrueType`;否则,指向 `FalseType`。 86 | 87 | ```typescript 88 | type Result = SomeType extends OtherType ? TrueType : FalseType; 89 | ``` 90 | 91 | 条件类型能够在使用泛型时发挥巨大的作用,正如下面的例子所示。 92 | 93 | ```typescript 94 | type NameOrId = T extends number 95 | ? IdLabel 96 | : NameLabel; 97 | 98 | // 如果用户传入的参数是 string,那么它的返回值类型为 NameLabel,否则为 IdLabel 99 | declare function createLabel( 100 | idOrName: T 101 | ): NameOrId; 102 | 103 | const _1 = createLabel("typescript"); 104 | // ^? const _1: NameLabel 105 | 106 | const _2 = createLabel(2.8); 107 | // ^? const _2: IdLabel 108 | ``` 109 | 110 | 通过上面的例子我们可以看到,条件类型是 TypeScript 用来提供条件分支(conditional branch)的手段,这是人们之所以称它为图灵完备的原因之一。接下来,我们介绍条件类型的几个重要机制。 111 | 112 | ### `infer` 关键字 113 | 114 | 在通过条件类型判断输入类型是否满足给定条件(这本质上是检查它是否为目标类型的子类型)时,TypeScript 提供了 `infer` 关键字来让我们可以在这个过程中根据目标元素的泛型类型,提取出输入类型的额外信息。 115 | 116 | 在下面的例子中,我们通过 `infer` 关键字在目标类型中引入了一个泛型类型,要求 TypeScript 在判断 `Type` 是否为 `Array` 的子类型时,将 `Item` 赋予一个有效的类型。 117 | 118 | ```typescript 119 | type Flatten = Type extends Array ? Item : Type; 120 | 121 | type Flatten = Type extends Array ? Item : Type; 122 | 123 | type _1 = Flatten; 124 | // ^? type _1 = string 125 | 126 | type _2 = Flatten; 127 | // ^? type _2 = string 128 | 129 | type _3 = Flatten<[string, number]>; 130 | // ^? type _3 = string | number 131 | ``` 132 | 133 | TypeScript 内置了许多使用了这个关键字的工具类型,例如: 134 | 135 | - `ReturnType`:获得给定函数类型的返回值类型 136 | 137 | ```typescript 138 | type ReturnType any> = T extends ( 139 | ...args: any 140 | ) => infer R 141 | ? R 142 | : any; 143 | ``` 144 | 145 | - `InstanceType`:获得构造函数类型对应的类的实例类型 146 | 147 | ```typescript 148 | type InstanceType any> = 149 | T extends abstract new (...args: any) => infer R ? R : any; 150 | ``` 151 | 152 | - `ThisParameterType`:获得给定函数类型的 `this 类型` 153 | 154 | ```typescript 155 | type ThisParameterType = T extends (this: infer U, ...args: never) => any 156 | ? U 157 | : unknown; 158 | ``` 159 | 160 | 在使用 `infer` 时,可以[使用泛型约束来附加额外的限定条件](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html#extends-constraints-on-infer-type-variables) `4.7+`,就像下面的例子这样。特别地,`extends` 的右边可以是字符串字面量类型,例如 `"foo"`、`${number}foo` 等。 161 | 162 | ```typescript 163 | type IsNumberString = T extends `${infer _ extends number}` 164 | ? true 165 | : false; 166 | // 这里只是举例子,可以简化成 T extends `${number}` 167 | 168 | type _0 = IsNumberString<"114514">; 169 | // ^? type _0 = true 170 | 171 | type _1 = IsNumberString<"seele">; 172 | // ^? type _1 = false 173 | 174 | type _2 = IsNumberString<"114514seele">; 175 | // ^? type _2 = false 176 | ``` 177 | 178 | > 这里留个小问题:如何修改代码使得 `_0` 和 `_1` 保持不变,而让 `_2` 返回 `true`? 179 | 180 | 使用 `infer` 结合泛型约束在一些场景下是必要的,例如当我们尝试匹配一个元组时,TypeScript 并不会为匹配得到的元素赋予对应元素的类型。此时必须使用 `infer First extends string`。 181 | 182 | ```typescript 183 | type Foo = 184 | T extends [infer First, ...infer _] ? Bar : never; 185 | // Type 'First' does not satisfy the constraint 'string' 186 | 187 | type Bar = // ...; 188 | ``` 189 | 190 | ### [分配式条件类型(Distributive Conditional Types)](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types) 191 | 192 | 在通过 `extends` 关键字表达泛型的类型约束时,很多人都会忘记要检查的类型可能是联合类型这一事实。例如,对于 `T extends string`,实际上当 `T` 为 `'foo' | 'bar'` 时也是满足约束的。 193 | 194 | 一个自然的问题是,联合类型是如何参与到条件类型的计算中的?在 TypeScript 中,这个过程被称为分配式条件类型。我们通过下面的例子理解这个特性。简单来说,当联合类型作为条件类型的输入时,TypeScript 会将联合类型中的每个类型单独拆出来计算条件类型,然后将得到的结果重新组合为联合类型。 195 | 196 | ```typescript 197 | type ToArray = T extends any ? T[] : never; 198 | type _1 = ToArray; 199 | // ^? type _1 = string[] | number[] 200 | ``` 201 | 202 | 下面是另一个例子,注意我们是如何过滤输入的联合类型中的 `2` 字面量类型的: 203 | 204 | ```typescript 205 | type NoTwo = T extends 2 ? never : T; 206 | type _2 = NoTwo<1 | 2 | 3>; 207 | // ^? type _2 = 1 | 3 208 | ``` 209 | 210 | ### [递归条件类型(Recursive Conditional Types)](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#recursive-conditional-types)`4.1+` 211 | 212 | 从 TypeScript 4.1 版本开始,我们可以像编写 JavaScript 代码一样,在类型定义中递归地调用自身。这使得像 `Awaited` 这样的工具类型得以以一种简单的形式实现(它现在是 TypeScript 自带的工具类型)。 213 | 214 | ```typescript 215 | type Awaited = T extends PromiseLike ? Awaited : T; 216 | 217 | type _1 = Awaited<"seele">; 218 | // ^? type _1 = "seele" 219 | 220 | type _2 = Awaited>; 221 | // ^? type _2 = "seele" 222 | 223 | type _3 = Awaited>>; 224 | // ^? type _3 = "seele" 225 | ``` 226 | 227 | ## 递归与循环 228 | 229 | 回忆起在大一刚刚开始学习程序设计的时候,我们在学完分支语句之后会学习什么?对啦,循环语句。循环语句也是图灵完备性的要素之一,而在 TypeScript 中实现循环语句的方法却有些曲折:通过递归实现循环。 230 | 231 | 注意递归的两个关键因素: 232 | 233 | - 自身调用:将问题分为子问题,调用自身继续处理 234 | - 终止条件:当条件满足时不再继续进行自身调用,返回结果 235 | 236 | 在 TypeScript 中,一般对元组进行递归来实现给定次数的循环,它的基本结构如下: 237 | 238 | ```typescript 239 | type Loop = 240 | // 👇 终止条件:数组的长度为 N 👇 自身调用:增加数组的长度 241 | Acc["length"] extends N ? "END" : Loop; 242 | ``` 243 | 244 | 这里的 `Acc` 并不是让用户传入的泛型参数,而是函数的内部变量(注意到它有一个默认值,即初始值)。一般来说,我推荐向用户隐藏掉这个泛型参数,以免引起误会: 245 | 246 | ```typescript 247 | type Loop = LoopImpl; 248 | 249 | type LoopImpl< 250 | N extends number, 251 | Acc extends never[] = [] 252 | > = Acc["length"] extends N ? "END" : LoopImpl; 253 | ``` 254 | 255 | 随着[模板字面量类型(template literal types)](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E6%A8%A1%E6%9D%BF%E5%AD%97%E9%9D%A2%E9%87%8F%E7%B1%BB%E5%9E%8Btemplate-literal-types-41)的引入,你可能会觉得字符串字面量类型也能够实现类似的功能。不过很可惜,TypeScript 目前并不会像对待元组那样给它的 `length` 属性返回具体的长度类型,而是返回 `number`。不过,你可以通过下面的方式计算一个字符串字面量类型的长度: 256 | 257 | ```typescript 258 | type LengthOfString = 259 | // 当 S 为空串时,条件为假;否则,_ 会被推导为第一个元素,Tail 会被推导为其余元素构成的子串 260 | S extends `${infer _}${infer Tail}` 261 | ? LengthOfString 262 | : T["length"]; 263 | ``` 264 | 265 | 除了上面这种通过递归构造数组的方法,还有一些较为少见的方法,它们通过向数组字面量中写入索引值实现,就像下面这个 TypeScript 内置的对 ES2019 中数组 `flat` 函数的类型定义。当 `Depth` 传入 `5` 时,下一层递归会从数组字面量的索引为 `5` 的值即 `4` 继续执行。 266 | 267 | ```typescript 268 | type FlatArray = { 269 | done: Arr; 270 | recur: Arr extends ReadonlyArray 271 | ? FlatArray 272 | : Arr; 273 | }[Depth extends -1 ? "done" : "recur"]; 274 | ``` 275 | 276 | 当 TypeScript 怀疑它碰到了无限递归(通过检查递归层数),或者一些需要花费很多时间计算的类型展开([比如对元组类型进行展开](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-2.html#tuple-size-limits-for-spreads))时,它会停止类型检查并直接报错。对于前者,它会抛出下面的错误: 277 | 278 | > Type instantiation is excessively deep and possibly infinite. 279 | 280 | 因此,在使用递归时,为了减少资源占用和防止 TypeScript 报错,我们最好将递归步骤处理成满足尾递归的形式。从 TypeScript 4.5 开始,它会对递归类型做尾递归优化,这意味着当递归类型[满足一定条件](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-5.html#tail-recursion-elimination-on-conditional-types)时,即使递归层数极其深也不会导致出现大量的资源占用,TypeScript 也不会报错。 281 | 282 | ### 使用 `infer` 关键字遍历元组类型 283 | 284 | 通过结合 `infer` 关键字,我们可以遍历一个元组类型。在下面的例子中: 285 | 286 | - 当 `T` 为空数组时,匹配失败 287 | - 当 `T` 为单元素数组类型时,推导得到的 `Tail` 为空数组元素 288 | - 当 `T` 为数组类型而不是元组类型时,匹配也会失败 289 | 290 | ```typescript 291 | type UppercaseArray = T extends [ 292 | infer Head extends string, 293 | ...infer Tail extends string[] 294 | ] 295 | ? [Uppercase, ...UppercaseArray] 296 | : []; 297 | 298 | type _1 = UppercaseArray<["foo", "bar"]>; 299 | // ^? type _1 = ["FOO", "BAR"] 300 | 301 | type _2 = UppercaseArray; 302 | // ^? type _2 = [] 303 | ``` 304 | 305 | ### 使用 `infer` 关键字遍历字符串字面量类型 306 | 307 | 对字符串字面量类型,我们也有类似的遍历方法。在下面的例子中: 308 | 309 | - 当 `T` 为空字符串时,匹配失败 310 | - 当 `T` 为长度为 `1` 的字符串类型,推导得到的 `Tail` 为空字符串类型 311 | - 当 `T` 为字符串类型而不是字面量类型时,匹配也会失败 312 | 313 | ```typescript 314 | type RemoveSpaces = T extends `${infer Head}${infer Tail}` 315 | ? `${Head extends " " ? "" : Head}${RemoveSpaces}` 316 | : ""; 317 | 318 | type _1 = RemoveSpaces<" f o o ">; 319 | // ^? type _1: "foo" 320 | 321 | type _2 = RemoveSpaces; 322 | // ^? type _2: "" 323 | ``` 324 | 325 | 在遍历元组类型和字符串字面量类型时,如果接收的类型为数组类型或字符串类型,我们一般会通过额外的前置判断来返回一些更有意义的特殊值,让工具类型具备更好的实用性。例如,对于上面的 `RemoveSpaces`,我们可以修改为下面这样来让用户在输入 `string` 时直接返回 `string` 类型。 326 | 327 | ```typescript 328 | type RemoveSpaces = string extends T // 👈 通过这种方式判断它是否为 string 类型 329 | ? string 330 | : T extends `${infer Head}${infer Tail}` 331 | ? `${Head extends " " ? "" : Head}${RemoveSpaces}` 332 | : ""; 333 | ``` 334 | 335 | ### 使用递归构造需要的类型 336 | 337 | 在下面的例子中,我们构造了一个和给定字符串长度相同的数组类型。 338 | 339 | ```typescript 340 | type WhichToUppercase = WhichToUppercaseImpl< 341 | LengthOfString, 342 | [], 343 | [] 344 | >; 345 | 346 | // 👇 通过 Res 承载最终得到的类型 347 | type WhichToUppercaseImpl< 348 | N extends number, 349 | Res extends boolean[], 350 | Acc extends never[] 351 | > = Acc["length"] extends N 352 | ? Res // 👇 更新最终返回的类型 353 | : WhichToUppercaseImpl; 354 | 355 | /** 356 | * 将字符串对应下标的元素替换为大写 357 | * 358 | * @param input - 输入的字符串 359 | * @param whichToUppercase - boolean 数组,每一项表示对应下标的字符串元素是否应该被大写 360 | */ 361 | declare function uppercaseProMax( 362 | input: T, 363 | whichToUppercase: WhichToUppercase 364 | ): string; 365 | ``` 366 | 367 |

368 | 369 |

370 | 371 | > [!NOTE] 372 | > 我们其实还能做得更好,下面的这些优化点你知道如何实现吗? 373 | > 374 | > - 当 `input` 不是一个字面量而是一个 `string` 类型的变量时,`WhichToUppercase` 返回了 `[]`,这显然是不合理的,应该回退为 `boolean[]` 375 | > - 当 `input` 为一个字面量时,返回值类型可以直接根据 `T` 和传入的 `whichToUppercase` 值的具体类型来计算得到。例如,当传入 `'foo'` 和 `[false, true, false]` 时,我们实际上可以计算出返回值类型应该为 `'fOo'` 而不是空泛的 `string` 376 | 377 | ## 函数泛型 378 | 379 | 前文提到,在函数泛型中,泛型参数的具体类型可以依据输入的参数值和函数的返回值进行推导。这里暗含了类型编程的重要功能:函数泛型可以让 TypeScript 从某个值中推导出类型,然后我们可以继续对这个类型进行计算(就像在前文讨论过的,如何基于给定类型计算新的类型),并使用最终得到的类型反过来对值进行类型检查。 380 | 381 | 从这点上说,函数泛型是最能够发挥 TypeScript 类型系统强大能力的手段之一。它能够对值(主要是字面量值)提供特殊的类型约束,这些约束要比一般的通过标注属性类型实现的约束更加进阶,包括: 382 | 383 | - 对象的某些元组属性必须具有相同的长度 384 | - 对于某个子串集合,对象的某些字符串必须都包含这些子串 385 | - 包括前面的例子在内的,需要针对多个属性使用相同的或相关联的类型进行约束的场景 386 | 387 | 本节将提出一个使用函数泛型检查字面量是否满足某些约束的「通用方法」,这些字面量包括:字符串字面量、元组字面量、对象字面量。关于「某些约束」,这一方面取决于实际的需求,另一方面取决于 TypeScript 类型系统的上限以及程序员花费时间的意愿。我们假设读者愿意花费时间编写一些复杂的类型检查规则,那么剩下的问题就是类型系统是否能够满足需求,我们会在本节当中以及之后的生产实践提供一些例子来展示类型系统能做到什么。 388 | 389 | ### Type Parameter Inference 390 | 391 | 在此之前,我们先来搞清楚 TypeScript 在检查带泛型参数的函数的调用语句时,具体发生了什么。考虑下面的代码,当我们传入的对象字面量中的 `initial` 函数返回了字符串类型的值时,TypeScript 将 `T` 推导为了 `string`。这个过程中发生了什么? 392 | 393 | ```typescript 394 | declare function setup(config: { initial(): T }): T | null; 395 | 396 | const _1 = setup({ initial() { return "seele"; } }); 397 | // ^? const _1: string | null 398 | ``` 399 | 400 | 这里发生的过程被称为 [Type Parameter Inference](https://github.com/microsoft/TypeScript-Compiler-Notes/blob/main/codebase/src/compiler/checker-inference.md#type-parameter-inference),本质上是计算出函数泛型参数究竟应该为何的过程。顺带一提,我们可以通过将鼠标放在第三行的 `setup` 上获得此时的函数签名来看到 TypeScript 对 `T` 的计算结果。 401 | 402 | 这个过程可以被粗略地分成下面的步骤: 403 | 404 | 1. **根据传入的值推导出一个类型,设为源类型(source type)** 405 | 406 | TypeScript 会根据传入的值 `{ initial() { return "seele" } }` 推导出它的类型,在这里会推导出 `{ initial(): string }`。这个类型被称为源类型。 407 | 408 | 关于返回值类型,TypeScript 其实有多种选择,例如 `"seele"`、`string`、`any` 等。此时,它会结合函数的签名信息(contextual signature)和泛型参数等类型(contextual type)进行考虑。 409 | 410 | 如果没有特别的信息,就像上面的例子,TypeScript 一般会选择 `string`([literal widening](https://github.com/microsoft/TypeScript-Compiler-Notes/blob/main/codebase/src/compiler/checker-widening-narrowing.md#literal-widening))。如果你使用了 ``,那么 TypeScript 会倾向于选择字面量类型 `"seele"` 而不是 `string`。 411 | 412 | 2. **解析方法签名中对应参数的类型,设为目标类型(target type)** 413 | 414 | 这里目标类型会被直接解析为 `{ initial(): T }`。对于一些复杂的类型,TypeScript 会做额外的处理,例如对于[条件类型](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E6%9D%A1%E4%BB%B6%E7%B1%BB%E5%9E%8Bconditional-types),TypeScript 会尝试同时取它的真分支和假分支对应的类型。 415 | 416 | 3. **将源类型和目标类型进行对比,匹配属性得到 `T` 的推导类型** 417 | 418 | TypeScript 会发现源类型和目标类型都是对象类型,于是检查它们的属性,在检查到 `initial` 时发现双方都有此属性。由于 `initial` 是函数类型,在继续推导到函数的返回值类型时,TypeScript 发现 `string` 可以被直接作为 `T` 的候选类型(candidate)。 419 | 420 | 在 Type Parameter Inference 中,TypeScript 一般来说会通过其它一些复杂的机制得到更多的候选类型,并最终根据优先级(priority)等信息选择「最好」的一个候选类型。在这里 TypeScript 最终选择了 `string`。 421 | 422 | 4. **实例化泛型参数,然后使用实例化后的类型对传入的值进行类型检查** 423 | 424 | TypeScript 在选择了 `string` 之后,会将它带入原函数。之后,它就像在使用下面的函数一样,对我们传入的对象字面量进行类型检查。 425 | 426 | ```typescript 427 | declare function setup(config: { initial(): string }): string | null; 428 | ``` 429 | 430 | 就这个例子来说,我们的目的是「无论用户传入的 `initial` 函数返回什么类型,这个函数返回值的类型为它或者 `null`」。而需要构造出的类型 `T | null` 被正确地推导为了 `string | null`。 431 | 432 | 根据上面的过程,可以得到 Type Parameter Inference 的一个可以好好利用的切入点:第三步中得到的 `T` 类型。这里的类型承载了用户传入的值的类型信息,或者说它充当了我们即将进行的类型编程的入口,接受的是用户的意图。根据 `T` 承载着的「意图」,我们可以结合本章前文讨论过的许多内容,在业务需求的基础上对 `T` 进行特殊处理,例如得到新的类型用于后续的类型检查或类型提示。 433 | 434 | ### 约束检查的通用方法 435 | 436 | 这里提出一个通过函数泛型对任意字面量施加特殊约束的通用方法。请区分「约束检查」和「类型检查」两个概念,前者是业务相关的、由我们施加的;后者是 TypeScript 自动执行的。当然,约束检查最终还是要由 TypeScript 通过类型检查来落实。 437 | 438 | 下面是所谓的「通用方法」的一般形式: 439 | 440 | ```typescript 441 | type DoCheck = T extends ... ? unknown : never; 442 | 443 | function check(input: T & DoCheck) { 444 | return input; 445 | } 446 | 447 | // VALUE 为我们要检查的字面量,当检查不通过时,TypeScript 会报错 448 | check(VALUE); 449 | ``` 450 | 451 | - 声明一个泛型参数 `T` 用于接收 TypeScript 的推导结果 452 | - 在 `DoCheck` 中对 TypeScript 推导出的 `T` 进行检查,如果满足约束则返回 `unknown`,否则返回 `never` 453 | - 将函数参数的类型声明为 `T & DoCheck` 有以下用意: 454 | - 让 TypeScript 顺利地通过 `T` 的部分推导出有意义的目标类型 455 | > 这里涉及到 Type Parameter Inference 的底层实现,由于篇幅原因不进行展开 456 | - 当约束检查通过时 `T & DoCheck` 等价于 `T`,类型检查通过;否则等价于 `never`,类型检查不通过 457 | 458 | 下面是一个例子,用于检查对象中是否*仅*含有一个名为 `foo` 的键: 459 | 460 | ```typescript 461 | type DoCheck = [keyof T] extends ["foo"] 462 | ? ["foo"] extends [keyof T] 463 | ? unknown 464 | : never 465 | : never; 466 | 467 | function check>(input: T & DoCheck) { 468 | return input; 469 | } 470 | 471 | check({}); // ERROR 472 | check({ foo: true }); // OK 473 | check({ foo: true, bar: false }); // ERROR 474 | ``` 475 | 476 | 上面的例子通过 `extends Record` 向 `T` 附加约束,这能够作为 contextual type 在 Type Parameter Inference 期间为 TypeScript 提供更好的提示,让它计算出更准确(更接近对象类型)的候选。在一些简单场景下这不是必要的,我们一般在遇到了问题时才会这样做。 477 | 478 | #### 对比其它方案 479 | 480 | 由于通用方法是通过对传入值的整体进行约束检查,并在不通过时触发 TypeScript 的类型检查错误,所以当约束检查没有通过时,TypeScript 会对输入函数的整个值进行报错,你会看到红色下划线标记了传入的整个字面量。这在一些场合下并不理想,因为造成约束不通过的原因往往只是字面量定义里的很小一部分。 481 | 482 | 就前文的例子来说,我们显然可以用更加直观的方法做到让 TypeScript 提供更精细化的报错信息,就像在下面的代码中,TypeScript 只会标红出错的 `{}` 和 `bar`,并且提供更加易懂的报错信息,和通用方法标红整片对象字面量和晦涩的报错信息相比更有优势。 483 | 484 | ```typescript 485 | function check(input: Record<"foo", unknown>) { 486 | return input; 487 | } 488 | 489 | check({}); // ERROR 490 | check({ foo: true }); // OK 491 | check({ foo: true, bar: false }); // ERROR 492 | ``` 493 | 494 | 通用方法之所以被称为「通用」是因为它具有很高的泛用性。对于上面的例子,我们也许不能很快就想到使用 `Record<'foo', unknown>` 解决问题,但是可以想到使用通用形式以及 `keyof` 获取并检查输入对象的键。对于一些更复杂的场景来说尤为如此,通用方法总是能给出一个能够工作的解决方案,尽管它可能不是最优解。 495 | 496 | 通用方法和其它方案也不是严格的互斥关系,它可以被融合到其它方案中。例如在下面的例子中,我们只想对传入对象的 `baz` 属性做复杂的检查,而其它属性的检查只需要使用 TypeScript 原语就能实现。此时,我们在一定程度上克服了通用方法提供的报错范围过于粗糙的问题(当 `baz` 的检查不通过时,TypeScript 只会给 `baz` 划线)。 497 | 498 | ```typescript 499 | type DoCheck = ...; 500 | 501 | declare function check( 502 | input: { 503 | foo: `${number}`; 504 | bar: [string, string]; 505 | baz: [...T] & DoCheck; 506 | } 507 | ); 508 | ``` 509 | 510 | 我们还能通过下面的方法来改善通用方法提供的报错信息过于晦涩的问题。 511 | 512 | #### 定制报错信息 513 | 514 | 当需要进行约束检查的 `T` 是对象和元组类型时,可以通过[名义类型](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%90%8D%E4%B9%89%E7%B1%BB%E5%9E%8Bnominal-typing)提供「约束检查报错信息」。上面的例子可以改成下面这样,注意报错信息的末尾是如何展现我们提供的报错信息的。 515 | 516 | ```typescript 517 | declare const ERROR_SYMBOL: unique symbol; 518 | type MyTypeError = { [ERROR_SYMBOL]: T }; 519 | 520 | type DoCheck = "foo" extends keyof T 521 | ? unknown 522 | : MyTypeError<"对象没有包含必须的类型 foo 哦">; 523 | 524 | // ... 525 | 526 | check({}); // ERROR! 527 | // Property '[ERROR_SYMBOL]' is missing in type '{}' 528 | // but required in type 'MyTypeError<"对象没有包含必须的类型 foo 哦">' 529 | ``` 530 | 531 | 基于名义类型,在检查其它类型例如字符串字面量类型时,依然能够通过类似的方法提供定制化的报错信息。如果你使用的 IDE 是 WebStorm,它提供的 TypeScript 报错信息会更加美观,就像下面的图片这样: 532 | 533 |

534 | 535 |

536 | 537 | #### 其它形式 538 | 539 | 除了将 `DoCheck` 通过交叉类型附加到参数类型上,还能够将其作为返回值的类型实现约束检查。此时约束检查的报错信息将具有更直观的形式。总之,通用方法的关键是能够在尽量不干扰 Type Parameter Inference 的同时具备触发 TypeScript 的类型报错的能力。 540 | 541 | ```typescript 542 | type DoCheck = "foo" extends keyof T 543 | ? never 544 | : MyTypeError<"对象没有包含必须的类型 foo 哦">; 545 | 546 | function check(input: T): DoCheck { 547 | return input as any; 548 | } 549 | 550 | check({}) satisfies never; 551 | // Type 'MyTypeError<"对象没有包含必须的类型 foo 哦">' does not satisfy the expected type 'never' 552 | ``` 553 | 554 | ### 常用的匹配方法 555 | 556 | 了解完通用方法之后,我们来讨论一下如何对 `T` 提供 TypeScript 想要的 contextual signature 和 contextual type,进而让 `T` 被推导为我们需要进行约束检查的类型。毕竟谁也不想在需要检查某个字符串字面量的时候,收到了 TypeScript 传过来的 `string` 类型,而不是字面量类型。 557 | 558 | #### 匹配元组类型 `4.0+` 559 | 560 | 我们已经在介绍[可变元组类型](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%8F%AF%E5%8F%98%E5%85%83%E7%BB%84%E7%B1%BB%E5%9E%8Bvariadic-tuple-types-40)时讨论过,可以使用 JavaScript 中的数组相关语法来匹配元组。下面是一些例子: 561 | 562 | ```typescript 563 | // 获得除了第一个元素之外的剩余元素 564 | declare function tail( 565 | _: readonly [unknown, ...T] 566 | ): T; 567 | 568 | // 获得第一个元素 569 | declare function first(_: readonly [T, ...unknown[]]): T; 570 | 571 | const _1 = tail([114, "seele", false]); 572 | // ^? const _1 = [string, boolean] 573 | 574 | const _2 = first([114, "seele", false]); 575 | // ^? const _2 = number 576 | ``` 577 | 578 | 通过使用 `[...T]` 可以提示 TypeScript 将 `T` 推导为元组类型而不是数组类型: 579 | 580 | ```typescript 581 | declare function test(_: [...T]): T; 582 | 583 | const _3 = test(["seele", 114, false]); 584 | // ^? const _3: [string, number, boolean] 585 | ``` 586 | 587 | #### 匹配对象类型 588 | 589 | 我们已经在前文看到如何匹配对象中的特定属性了,接下来讨论的是如何匹配整个对象类型。 590 | 591 | ```typescript 592 | // 👇 匹配对象类型 👇 获得对象的键 593 | declare function getKeys>(input: T): keyof T; 594 | 595 | const _0 = getKeys({ bar: "seele", baz: 233 }); 596 | // ^? const _0: "bar" | "baz" 597 | ``` 598 | 599 | #### 匹配字面量类型 600 | 601 | 通过为 `T` 附加对应字面量的父类型(或者说 apparent type)约束可以让 TypeScript 保持选择字面量类型而不是它的父类型。 602 | 603 | ```typescript 604 | declare function setup(config: { initial(): T }): T; 605 | 606 | const _0 = setup({ initial() { return "seele"; }}); 607 | // ^? const _0: "seele" 608 | ``` 609 | 610 | 如果想将对象类型中的属性推导为字面量类型,除了对传入的值使用 `as const`,还可以通过 `const` 关键字 `5.0+` 实现,参考下面的例子。对于元组来说也是类似的方法。 611 | 612 | ```typescript 613 | declare function setup>(config: T): T; 614 | 615 | const _1 = setup({ foo: "seele", bar: 114514, baz: true }); 616 | // ^? const _1: { readonly foo: "seele"; readonly bar: 114514; readonly baz: true; } 617 | ``` 618 | 619 | ### 一些约束检查的例子 620 | 621 | #### 确保字符串包含特定子串 622 | 623 | 在下面的例子中,我们通过引入一个函数来对某个字符串字面量进行检查,查看它是否包含子串 `seele`。当不包含该子串时,通过将函数的参数类型设置为 `never` 来触发类型检查错误。 624 | 625 | ```typescript 626 | type DoCheck = string extends T 627 | ? string // 当输入的 T 为 string 类型而不是字面量类型时,直接返回 string 628 | : T extends `${string}seele${string}` // 否则,检查是否包含子串 629 | ? T 630 | : never; 631 | 632 | function check(input: DoCheck) { 633 | return input; 634 | } 635 | 636 | // 报错,Argument of type 'string' is not assignable to parameter of type 'never' 637 | const _0 = check("hmm"); 638 | 639 | const _1 = check("Hi, seele!"); 640 | // ^? const _1: "Hi, seele!" 641 | 642 | const _2 = check("seele, hello!"); 643 | // ^? const _2: "seele, hello!" 644 | ``` 645 | 646 | #### 确保两个数组长度相同 647 | 648 | 为了简洁,这里没有考虑输入的数组不是元组类型而是数组类型的情况。 649 | 650 | ```typescript 651 | // 当两个数组元素类型相同时,可以直接使用可变元组类型 652 | declare function check(a: [...T], b: [...T]): void; 653 | 654 | check([1], []); // ERROR 655 | check([], [1]); // ERROR 656 | check([1], [1]); // OK 657 | 658 | // 当两个数组元素类型不同时,可以通过对返回值类型进行检查 659 | declare function check2( 660 | a: [...T], 661 | b: [...U] 662 | ): T["length"] extends U["length"] ? true : false; 663 | 664 | check2([1], []) satisfies true; // ERROR 665 | check2([], [1]) satisfies true; // ERROR 666 | check2([1], ["foo"]) satisfies true; // OK 667 | 668 | // 对返回值类型进行检验不是解决问题的唯一的办法,我们还有: 669 | type DoCheck< 670 | T extends unknown[], 671 | U extends unknown[] 672 | > = T["length"] extends U["length"] ? unknown : never; 673 | 674 | // 考虑这样一个事实:unknown 是 top type(全集),所以其它类型对它取交都等于原类型 675 | // 而 never 是 bottom type(空集),其它类型对它取交都等于 never 676 | declare function check3( 677 | a: [...T] & DoCheck, 678 | b: [...U] & DoCheck 679 | ): void; 680 | 681 | check3([1], []); // ERROR 682 | check3([], [1]); // ERROR 683 | check3([1], ["foo"]); // OK 684 | ``` 685 | 686 | ### 函数类型闭包 687 | 688 | 除了能够实现约束检查,函数泛型还有一项重要的功能是:提供函数类型闭包。通过函数类型闭包,函数的泛型参数能够被传递到返回值类型中从而被保留下来。这和我们熟悉的 JavaScript 变量的闭包有些类似。 689 | 690 | 借助函数类型闭包,我们能够构建一些具有强大类型功能的 API,这些 API 能够「记住」向函数传入的参数值的一些类型信息,并将这些信息用于对后续的函数调用进行类型检查。当然,除了类型检查,这些信息还能用来提供类型提示,提高我们的开发效率。 691 | 692 | 在下面的例子中,我们编写了一个带类型检查功能的验证器构造函数 `buildValidatorFn`,它返回一个 type predicate 函数,能够检查给定的对象是不是满足特定的类型要求。类型要求由验证器构造函数接受的参数推导得到的泛型参数计算而来。请观察我们给 `buildValidatorFn` 传入的对象字面量中包含的类型信息是如何通过函数类型闭包传递到了它的返回值,也就是 type predicate 函数中的。 693 | 694 | ```typescript 695 | type ValidatorMap = Record< 696 | string, 697 | NumberConstructor | StringConstructor | BooleanConstructor 698 | >; 699 | 700 | // 使用 mapped types 构造目标类型 701 | type BuildResultType = { 702 | // 上述 Constructor 类型的返回值类型就是它们对应的原始值类型 703 | [K in keyof T]: ReturnType; 704 | }; 705 | 706 | // Prettify 工具类型来自前文 707 | declare function buildValidatorFn( 708 | input: T 709 | ): (input: Record) => input is Prettify>; 710 | 711 | const validatorFn = buildValidatorFn({ 712 | foo: String, 713 | bar: Number, 714 | baz: Boolean, 715 | }); 716 | // ^? const validatorFn: (input: Record) => 717 | // input is { foo: string; bar: number; baz: boolean } 718 | 719 | // 下面来试用一下这个 validatorFn 720 | declare const _0: any; 721 | if (validatorFn(_0)) { 722 | _0; 723 | // ^? const _0: { foo: string; bar: number; baz: boolean; } 724 | } 725 | ``` 726 | 727 | 我们会在接下来的生产实践章节中介绍更多有关函数类型闭包的例子,进一步展现它的强大功能。 728 | 729 | ### Homomorphic Mapped Types 730 | 731 | 你或许听说过 TypeScript 有一种叫 [Mapped Types](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html) 的概念,它可以根据一个类型构造出另一个类型,具体的形式类似下面的代码,它展示了基于给定泛型参数 `C` 构造新的对象类型,这个对象类型的值类型用 `X` 表示。 732 | 733 | ```typescript 734 | type HMT = { 735 | [K in C]: X; 736 | }; 737 | ``` 738 | 739 | Homomorphic Mapped Types(简称“HMT”)指的是一类特殊的 Mapped Types:它们的 `C` 来自 `keyof T`。其中,`T` 是一个泛型参数。注意到 HMT 并没有对 `X` 做出约束。 740 | 741 | HMT 有许多相当有意思的特性,其中我们最常遇到的可能是:当 `T` 为原始类型(primitive type)时,HMT 总是返回它本身,就像下面的代码这样。 742 | 743 | ```typescript 744 | type MyHMT = { 745 | [K in keyof T]: never; // 👈 基本上,将这个 never 改成其它类型,结果都不会变化 746 | }; 747 | 748 | type _1 = MyHMT<1>; 749 | // ^? type _1 = 1 750 | 751 | type _2 = MyHMT; 752 | // ^? type _2 = boolean 753 | 754 | type _3 = MyHMT; 755 | // ^? type _3 = bigint 756 | ``` 757 | 758 | 此外,HMT 还有一些强大特性: 759 | 760 | - 如果 `T` 为联合类型,那么 HMT 会进行分配(distributive)运算: 761 | ```typescript 762 | type HMT = { [P in keyof T]: F } 763 | // 这里,HMT 等价于 F | F 764 | ``` 765 | - 如果 `T` 为数组类型,那么 HMT 会对其中的元素类型进行计算,返回结果类型构成的数组类型: 766 | ```typescript 767 | type HMT = { [P in keyof T]: F } 768 | // 这里,HMT 等价于 F[] 769 | ``` 770 | - 如果 `T` 为元组类型,那么 HMT 会做类似数组的操作: 771 | ```typescript 772 | type HMT = { [P in keyof T]: F } 773 | // 这里,HMT<[A, B, C]> 等价于 [F, F, F] 774 | ``` 775 | 776 | 接下来,我们来看一个 HMT 的使用例子: 777 | 778 | ```typescript 779 | type Tuple = [key: string, value: unknown]; 780 | 781 | type ToObject = // TODO 782 | 783 | type Result = ToObject<[['foo', true], ['bar', 233]]>; 784 | // 🤔 如何将元组类型转换为对象类型:{ foo: true, bar: 233 } 785 | ``` 786 | 787 | 笔者的思路分为两步:首先将元组中的键提取出来,作为联合类型 `'foo' | 'bar'`,这样我们就能使用 Mapped Types 通过 ``{ [K in 'foo' | 'bar']: ... }`` 来构造出最终的对象类型。至于如何从 `K` 得到对应的值类型,我们可以对原本的元组类型使用前文介绍过的分配式条件类型来进行匹配。 788 | 789 | ```typescript 790 | type GetKeys = { [P in keyof T]: T[P][0] }[number]; 791 | // 根据上述 HMT 对元组计算的特性,GetKeys<[['foo', true], ['bar', 233]]> 等价于: 792 | // [ ['foo', true][0], ['bar', 233][0] ][number],等价于: 793 | // [ 'foo', 'bar' ][number],即 'foo' | 'bar" 794 | 795 | type ToObject = { 796 | [K in GetKeys]: 797 | T[number] extends infer U ? // 👈 T[number] 等价于 ['foo', true] | ['bar', 233] 798 | U extends readonly [K, infer V] ? // 👈 通过分配式条件类型 U 检查它的键是不是 K 799 | V 800 | : never 801 | : never; 802 | } 803 | ``` 804 | 805 | 其实 `ToObject` 还有一种不使用 HMT 的解法,其中的原理可以见 [TypeScript 仓库中的讨论](https://github.com/microsoft/TypeScript/issues/55762)。 806 | 807 | ```typescript 808 | type ToObject = 809 | { [P in keyof T & `${number}` as T[P][0]]: T[P][1] }; 810 | // 👆 这里是必要的,从元组(数组)类型的属性中筛选出 '0' 和 '1' 键 811 | ``` 812 | 813 | ### Reverse Mapped Types 814 | 815 | 在大多数的情况下,我们使用 TypeScript 的方式都是:先编写出类型,再用它约束值。不过,就像前文介绍的约束检查的通用方法那样,我们也可以做到:先接受值,再基于值产生类型,然后反过来利用产生的类型去约束值。本节会介绍一种被称为 Reverse Mapped Types (下文简称“RMT”)的技巧,它也利用了这种思想,不过具体的形式和通用方法不同。 816 | 817 | #### 原理 818 | 819 | RMT 利用了函数泛型的一个事实:TypeScript 需要依据使用了泛型参数的值来推导泛型参数究竟应该是什么类型,就像我们在前文 Type Parameter Inference 中介绍的那样。 820 | 821 | 注意下面的例子,我们通过修改函数参数的类型来让 `T` 被推导为了不同的类型。 822 | 823 | ```typescript 824 | declare function foo(obj: { values: T }): void; 825 | declare function bar(obj: { values: [T] }): void; 826 | 827 | // 此时,T 被推导为 number[] 828 | foo({ values: [1] }); 829 | 830 | // 此时,T 被推导为 [number] 831 | bar({ values: [1] }); 832 | ``` 833 | 834 | 一个想法是:能不能让 TypeScript 将 `T` 推导为对象类型,让它表现得像一个 `Map`,这样就可以存储一些特殊的类型信息,从而实现很多高级的类型功能。在下面的例子中,我们希望在传给 `check` 函数的对象字面量中,各个值对象里的 `fn` 函数的参数类型能够和 `value` 属性的类型关联起来。 835 | 836 | ```typescript 837 | check({ 838 | foo: { 839 | value: 233, 840 | fn: value => { 841 | // 希望 value 被推导为 number 842 | } 843 | }, 844 | bar: { 845 | value: 'seele', 846 | fn: value => { 847 | // 希望 value 被推导为 string 848 | } 849 | } 850 | }); 851 | ``` 852 | 853 | 根据前文的讨论,我们其实可以让 `T` 被推导为类似下面这样的对象类型,这样我们就能够将 `fn` 的类型标注为 ``(value: T[propertyName] => void`` 从而实现想要的功能了。不过这里的 `propertyName` 要怎么取?换句话说,当我们把 `T` 作为了某种 `Map` 之后,怎么对它进行写入和读取呢? 854 | 855 | ```typescript 856 | { 857 | foo: number, 858 | bar: string 859 | } 860 | ``` 861 | 862 | Reverse Mapped Types 通过 Mapped Types 来实现这一点,下面是它的其中一个形式。注意 `MyReverseMappedType` 必须是一个 Homomorphic Mapped Types。 863 | 864 | ```typescript 865 | type MyReverseMappedType = { 866 | // 利用给定对象值的类型信息,构造和使用 T 867 | [K in keyof T]: ...; // 利用 Mapped Types 构造最终的对象类型约束 868 | }; 869 | 870 | declare function check(obj: MyReverseMappedType): void; 871 | 872 | // 检查对象字面量的类型,当检查不通过时会报 obj 参数存在类型错误 873 | check({ foo: 123 }); 874 | ``` 875 | 876 | #### 例子的实现 877 | 878 | 前文的 `fn` 的例子可以通过下面的代码实现。我们也许可以按下面的步骤理解其中发生的事情(尽管这不一定是 TypeScript 编译器实际的工作方式): 879 | 880 | 1. TypeScript 注意到 `MyReverseMappedType` 是一个 Mapped Types,然后注意到向 `check` 函数输入的参数是对象字面量 `{ foo: ..., bar: ... }`,具有两个属性。于是 `T` 会被尝试推导为某种对象类型,`keyof T` 会得到 `'foo' | 'bar'`。 881 | 882 | 2. 在使用 `foo: { value: T[K], fn (value: T[K]) => void` 检查 `foo: { value: 233, fn: value => {}}` 时,TypeScript 会从 `233` 和 `value => {}` 这两个值中尝试弄清楚 `T[K]` 到底是什么。 883 | 884 | 3. 最终,TypeScript 得出 `T[K]` 应该是 `number` 的结论,相当于向 `T` 这个 `Map` 写入了 `T['foo'] = number`。`fn: (value: T[K]) => void` 相当于从 `T` 这个 `Map` 中读取了 `T['foo']` 的内容,获得了 `number` 类型。类似的过程也发生在了 `bar` 上。 885 | 886 | ```typescript 887 | type MyReverseMappedType = { 888 | [K in keyof T]: { 889 | value: T[K], 890 | fn: (value: T[K]) => void 891 | } 892 | }; 893 | 894 | declare function check(obj: MyReverseMappedType): void; 895 | 896 | check({ 897 | foo: { 898 | value: 233, 899 | fn: value => { 900 | // value 确实是 number! 901 | } 902 | }, 903 | bar: { 904 | value: 'seele', 905 | fn: value => { 906 | // value 确实是 string! 907 | } 908 | } 909 | }) 910 | ``` 911 | 912 | 将鼠标放到 `check` 调用上,我们会看到 `T` 被推导为了下图所示的对象类型。 913 | 914 | ![](./assets/chapter3/check.png) 915 | 916 | #### 其它形式 917 | 918 | 除了上面提到的 Homomorphic Mapped Types 形式,[Reverse Mapped Types 还存在一些不那么常见的形式](https://github.com/microsoft/TypeScript/pull/55811): 919 | 920 | - 形如 `{ [P in K]: ... }` 的 Mapped Type,其中 `K` 为一个类型参数。 921 | - 形如 `{ [P in A | B]: ... }` 的 Mapped Type,其中 `A | B` 表示某种联合类型,并且它至少包含一个这样的类型:要么满足 Homomorphic Mapped Type 的条件,要么满足上面第 1 种的条件。 922 | > 这种形式可以被用来确保 TypeScript 推导出的 RMT 必须包含某些键,或者说属性。 923 | - [`5.4+`](https://github.com/microsoft/TypeScript/pull/55811) 形如 `{ [P in A & B]: ... }` 的 Mapped Type,其中 `A & B` 表示某种交叉类型,并且它的结果要么满足 Homomorphic Mapped Type 的条件,要么满足上面第 1 种的条件。 924 | > 这种形式可以被用来确保 TypeScript 推导出的 RMT 不能包含某些键,或者说属性。 925 | 926 | 这里特别介绍一下上述第 3 种形式的用处,请看下面的代码: 927 | 928 | ```typescript 929 | declare function foo(input: { [K in keyof T]: () => T[K] }): void; 930 | 931 | // 是否有办法在使用 RMT 的同时,让用户能且只能传入 foo、bar 属性? 932 | foo({ 933 | foo: () => 1, 934 | bar: () => 'a', 935 | extra: () => 123, 936 | }); 937 | ``` 938 | 939 | 你可能会想,这可以通过引入一个 `interface Params { foo: () => number; bar: () => string }` 并让 `foo` 实现。但正如 `extends` 关键字所暗示的,我们向对象类型中附加更多属性的时候,会得到一个子类型,这是满足 `extends` 的约束的。此外,我们前文的一个类似的例子是通过直接将类型约束放置在参数上实现的,不适用于 RMT 的场景。 940 | 941 | 事实上,可以使用 RMT 的交叉类型实现这种约束。 942 | 943 | ```typescript 944 | declare function foo(input: { [K in keyof T & ('foo' | 'bar')]: () => T[K] }): void; 945 | 946 | foo({ 947 | foo: () => 1, 948 | bar: () => 'a', 949 | extra: () => 123, 950 | // TS 提出了正确的报错:Object literal may only specify known properties, 951 | // and 'extra' does not exist in type '{ foo: () => number; bar: () => string; }'.(2353) 952 | }); 953 | // 此时,T 被推导为:{ foo: number; bar: string; } 954 | ``` 955 | 956 | --- 957 | 958 | | **上一章** | **目录** | **下一章** | 959 | | :------------- | :----------: | :------: | 960 | | [第二章:进阶话题](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第四章:生产实践](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter4.md) | 961 | -------------------------------------------------------------------------------- /chapter4.md: -------------------------------------------------------------------------------- 1 | | **上一章** | **目录** | **下一章** | 2 | | :------------- | :----------: | :------: | 3 | | [第三章:类型编程](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第五章:探索之路](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter5.md) | 4 | 5 | --- 6 | 7 | # 第四章:生产实践 8 | 9 | 下面介绍一些使用了前文介绍的特性和技巧的实践,它们主要来源于我自己在工作中和私下里的项目中得到的灵感。 10 | 11 | ## 类型安全的路由器 12 | 13 | 这里的「路由器」不是指办公室墙上的那个,而是下面这种东西: 14 | 15 | ```typescript 16 | // https://expressjs.com/en/guide/routing.html 17 | app.get("/users/:userId/books/:bookId", (req) => { 18 | // req.params 是 unknown 类型,但是它会有下面的属性: 19 | // req.params.userId 20 | // req.params.bookId 21 | }); 22 | ``` 23 | 24 | 有没有什么办法可以为 `req.params` 提供类型支持,让它能够从传入的路由定义中解析出参数呢?为了方便展示,我们准备了下面的代码: 25 | 26 | ```typescript 27 | interface MyRequest { 28 | params: T; 29 | } 30 | 31 | type MakeParamsType = {}; // 如何实现? 32 | 33 | declare function get( 34 | route: T, 35 | handlerFn: (req: MyRequest>) => void 36 | ): void; 37 | 38 | get("/users/:userId/books/:bookId", (req) => { 39 | const { params } = req; 40 | // ^? 如何让它具有 { userId: unknown; bookId: unknown } 类型? 41 | }); 42 | ``` 43 | 44 | ### 解决方案 45 | 46 | 可以通过[模板字面量类型](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E6%A8%A1%E6%9D%BF%E5%AD%97%E9%9D%A2%E9%87%8F%E7%B1%BB%E5%9E%8Btemplate-literal-types-41)和[递归](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E9%80%92%E5%BD%92%E4%B8%8E%E5%BE%AA%E7%8E%AF)实现对路由定义的解析,这个过程就像在使用正则表达式匹配字符串。 47 | 48 | `Res` 的联合类型充当了 Set 的作用,即可自动去重的元组类型。特别地,`never` 表示「空状态」。 49 | 50 | ```typescript 51 | type ParseRouteString< 52 | T extends string, 53 | Res extends string = never 54 | > = T extends `${string}:${infer P}/${infer Rest}` 55 | ? ParseRouteString 56 | : T extends `${string}:${infer P}` // 参数位于路由定义末尾的情况 57 | ? Res | P 58 | : Res; // 此时两个 extends 都匹配失败,说明没有更多路由参数了 59 | 60 | type MakeParamsType = { 61 | [K in ParseRouteString]: unknown; 62 | } & {}; // 这里并上 {} 是为了阻止 Type Alias Preservation 63 | 64 | get("/users/:userId/books/:bookId", (req) => { 65 | const { params } = req; 66 | // ^? const params: { userId: unknown; bookId: unknown } 67 | }); 68 | ``` 69 | 70 | ### 进阶方案 71 | 72 | 上面的方案得到的 `params` 虽说有了正确的键,但是值的类型还是 `unknown`,使用上有些繁琐。我们可以提供更好的类型功能,让用户可以通过 `@` 指定参数类型,就像下面的例子。注意,当不使用 `@` 指定类型时,参数类型应该被设置为 `unknown`。 73 | 74 | ```typescript 75 | get("/users/:userId@number/books/:bookId@number", (req) => { 76 | const { params } = req; 77 | // ^? 如何让它具有 { userId: number; bookId: number } 类型? 78 | }); 79 | ``` 80 | 81 | 对于字符串的解析来说,上述要求并不难实现。但是如何承载对应的参数类型信息呢?请看下面的代码。 82 | 83 | ```typescript 84 | type ResolveRouteParam = 85 | // 👇 我们只判断了 number,你可以扩展其它类型! 86 | T extends `${infer P}@${infer _ extends "number"}` // 这是比较复杂的写法,可以改成 @number 87 | ? [P, number] 88 | : [T, unknown]; 89 | 90 | type ParseRouteString< 91 | T extends string, 92 | // 我们使用元组来承载解析出来的路由参数,当然它们还是通过联合类型去处理 93 | //(为了简单,这里没有考虑去重问题) 94 | Params extends [string, unknown] = never 95 | > = T extends `${string}:${infer P}/${infer Rest}` 96 | ? ParseRouteString> 97 | : T extends `${string}:${infer P}` 98 | ? Params | ResolveRouteParam

99 | : Params; 100 | ``` 101 | 102 | 对于路由定义 `/users/:userId@number/books/:bookId@number`,上述的 `ParseRouteString` 的返回类型为:`["userId", number] | ["bookId", number]`。接下来的问题是如何将它转换为期望的对象类型。 103 | 104 | ```typescript 105 | type MakeParamsType< 106 | T extends string, 107 | R extends [string, unknown] = ParseRouteString 108 | > = { 109 | // 👇 注意到 R[0] 返回 "userId" | "bookId" 110 | [K in R[0]]: R extends [K, infer U] ? U : never; 111 | // 👆 使用分配式条件类型找到 K 对应的元组的第二个元素,即它的路由参数类型 112 | // 分配式条件类型的计算过程(当 K 为 "bookId" 时): 113 | // 1. ["userId", number] | ["bookId", number] extends ["bookId", infer U]... 114 | // 2. ["userId", number] extends ["bookId", infer U]... | ["bookId", number] extends ["bookId", infer U]... 115 | // 3. never | number 116 | // 4. number 117 | } & {}; 118 | ``` 119 | 120 | 最后,我们检验一下上面的代码是否正常工作: 121 | 122 | ```typescript 123 | get("/users/:userId@number/books/:bookId@number", (req) => { 124 | const { params } = req; 125 | // ^? const params: { userId: number; bookId: number } 126 | }); 127 | 128 | get("/users/:userId/:phone@number", (req) => { 129 | const { params } = req; 130 | // ^? const params: { userId: unknown, phone: number } 131 | }); 132 | ``` 133 | 134 | ## 类型安全的装饰器 `5.0+` 135 | 136 | 这里讨论的「装饰器」并不是 [TypeScript 5.0 版本实现的新装饰器提案](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#decorators),而是旧的 Stage 2 装饰器提案。不过,我们会在本节末尾稍微讨论一下是否可以使用新装饰器提案实现本节的功能。 137 | 138 | 许多依赖注入框架提供了类似 `@Inject` 的装饰器用来修饰一个类的属性,这样框架就能在实例化这个类时根据装饰器注入的元数据知道应该向这个属性赋予什么值(注意,这个过程发生在运行时而不是编译时)。 139 | 140 | 假如有下面的代码,我们这里使用的框架需要通过向 `@Inject` 中提供需要注入的类实例的构造函数(即 `SomeService`)来让框架注入对应的元数据。这里我们遇到了一个问题:如何确保给 `someService` 属性标注的类型是正确的? 141 | 142 | ```typescript 143 | class SomeService {} 144 | 145 | class MyService { 146 | constructor(@Inject(SomeService) private readonly someService: SomeService) {} 147 | } 148 | ``` 149 | 150 | 考虑一个进阶的装饰器 `@OptionalInject`,它告诉框架如果对应的服务不存在就直接注入 `null` 值,而不是像 `@Inject` 那样直接抛错。在这种情况下应该给属性的类型额外标上 `null`,就像下面这样: 151 | 152 | ```typescript 153 | class MyService { 154 | constructor( 155 | @Inject(SomeService) private readonly someService: SomeService, 156 | @OptionalInject(OtherService) 157 | private readonly otherService: OtherService | null 158 | ) {} 159 | } 160 | ``` 161 | 162 | 这种依赖于用户自觉的办法是不稳妥的,因为他们很可能不会仔细阅读 `OptionalInject` 的文档,不会知道它可能会使得属性被赋予 `null` 值。另外,后续一旦 `OptionalInject` 发生了修改,从注入 `null` 值修改为了注入 `undefined` 值,那么所有的引用都需要一个个地查找和修改,这不是一种可扩展的方法。 163 | 164 | 可惜的是,TypeScript 不能通过装饰器的修饰来为类属性赋予类型,所以我们没有办法略去类型标注而让装饰器负责提供类型。不过,也许可以退而求次,提供一种类型检查的手段让 TypeScript 检查下面两个类型的关系: 165 | 166 | - 向装饰器传入的类构造函数的类型 167 | - 为装饰器装饰的属性提供的类型标注 168 | 169 | ### 解决方案 170 | 171 | TypeScript 要求属性装饰器函数必须满足下面的类型,注意到它的返回值类型必须为 `void` 或 `any`。 172 | 173 | ```typescript 174 | type ParameterDecorator = ( 175 | // 被装饰的属性所在的类的构造函数 176 | target: Object, 177 | // 被装饰的属性所在函数的键 178 | propertyKey: string | symbol | undefined, 179 | // 被装饰的属性是所在函数的第几个参数 180 | parameterIndex: number 181 | ) => void; 182 | ``` 183 | 184 | 当某个属性装饰器函数的类型返回值不为 `void` 或 `any` 时,TypeScript 会在*使用这个装饰器的地方*报错: 185 | 186 | > Decorator function return type is '...' but is expected to be 'void' or 'any'. 187 | 188 | 于是我们有了下面的实现思路:提供一种类型化的装饰器函数,当被装饰属性的类型(即 `target` 的类型)不满足一定条件时,让函数的返回值类型不再为 `void`,从而触发 TypeScript 的报错。 189 | 190 | 我们的实现先从装饰器函数本身开始,为了实现上述功能,需要一个返回装饰器的函数 `OptionalInjectDecoratorFactory`。注意在 `@Inject(...)` 中我们实际上是调用了 `Inject` 这个函数,然后再将它返回的装饰器(它本质上也是一个函数)交给 TypeScript。 191 | 192 | ```typescript 193 | interface OptionalInjectDecoratorFactory { 194 | // 通过函数类型推导,获得输入的构造函数对应的类实例类型 195 | (token: { new (): T }): TypedParameterDecorator; 196 | 197 | // 似乎不是必要的,但许多装饰器实现都会包含这个 198 | new (): OptionalInjectDecoratorFactory; 199 | } 200 | 201 | // 具体的装饰器实现就不讨论了 202 | declare const OptionalInject: OptionalInjectDecoratorFactory; 203 | ``` 204 | 205 | `TypedParameterDecorator` 类型的计算结果即为我们的装饰器的类型,请看下面的代码: 206 | 207 | ```typescript 208 | type TypedParameterDecorator = < 209 | Target extends abstract new (...args: any) => any, 210 | Index extends number 211 | >( 212 | target: Target, 213 | propertyKey: string | symbol | undefined, 214 | parameterIndex: Index 215 | // 👇 检查传入的 T 类型是否和被装饰的属性的类型相等 216 | ) => DoCheck[Index]>; 217 | // 👆 先得到构造函数的参数列表,然后使用下标访问 218 | 219 | // 当两个类型相等时,返回 `void` 让装饰器通过类型检查,否则返回 `false`。 220 | // 这里的 `false` 可以是其它一些能够满足上述条件的类型 221 | type DoCheck = IsEqual extends true ? void : false; 222 | 223 | // TypeScript 社区发明了很多方法来检查两个类型是否「相等」,但是它们都或多或少存在一些边界条件 224 | // 下面这个版本是泛用性比较强的,详见 Stack Overflow 上的讨论 225 | type IsEqual = (() => T extends X ? 1 : 2) extends () => T extends Y 226 | ? 1 227 | : 2 228 | ? true 229 | : false; 230 | ``` 231 | 232 | 下面,让我们来测试一下这个装饰器的效果如何: 233 | 234 | ```typescript 235 | class MyService { 236 | constructor( 237 | @OptionalInject(SomeService) // 类型检查通过! 238 | private readonly _0: SomeService | null, 239 | 240 | @OptionalInject(SomeService) // 类型检查不通过! 241 | private readonly _1: SomeService, 242 | 243 | @OptionalInject(SomeService) // 类型检查不通过! 244 | private readonly _2: boolean 245 | ) {} 246 | } 247 | ``` 248 | 249 | 尽管 TypeScript 在很久以前就支持了旧的 Stage 2 装饰器提案,但直到 5.0 版本开始 TypeScript 内部引入了某项未知的修改,使得我们能够在 `TypedParameterDecorator` 中得到 `Target` 的具体类型。在 4.9 版本及以前,我们只能获得 `unknown`,无法实现对被装饰的属性进行类型检查。 250 | 251 | ### 提供具体的报错信息 252 | 253 | 在上面的代码中,当装饰器的类型检查不通过时的报错信息是: 254 | 255 | > Decorator function return type is 'boolean' but is expected to be 'void' or 'any'. 256 | 257 | 这样的报错信息非常原始,甚至会让用户感到费解。是否有一种办法可以告诉用户「你的类型标错了」呢?有的,而且这种方法算是比较常见的,当人们在使用类似的进阶类型技巧时用来提示用户的方法。 258 | 259 | ```typescript 260 | declare const ERROR: unique symbol; 261 | type MyTypeError = { [ERROR]: S }; 262 | 263 | type DoCheck = IsEqual extends true 264 | ? void 265 | : MyTypeError<"Type of parameter is not equal to type of decorator">; 266 | ``` 267 | 268 | 此时,我们得到的错误信息如下,变得清晰了很多: 269 | 270 | > Decorator function return type is 'MyTypeError<"Type of parameter is not equal to type of decorator">' but is expected to be 'void' or 'any'. 271 | 272 | 如果 TypeScript 能够帮帮忙,引入一种能够在 type 定义中被编译器检测的特殊类型作为报错信息的直接来源,我们还能得到更加直观的错误信息,例如: 273 | 274 | > The type of parameter is not equal to the type of decorator 275 | 276 | ### 关于新装饰器的讨论 277 | 278 | 对于从 5.0 版本开始支持的新装饰器提案来说,它并不支持装饰声明在构造函数中的类成员变量: 279 | 280 | ```typescript 281 | class Main { 282 | constructor( 283 | @Inject(...) 284 | private readonly foo: SomeService // Error: Decorators are not valid here. 285 | ) {} 286 | } 287 | ``` 288 | 289 | 不过,我们可以选择另外一种形式,即装饰那些直接声明在类中的成员变量: 290 | 291 | ```typescript 292 | class Main { 293 | @Inject(...) 294 | private readonly foo: SomeService // OK 295 | } 296 | ``` 297 | 298 | 对于新装饰器提案,TypeScript 仍然对装饰器的函数返回值类型存在约束,我们依然可以利用前面的技巧完成功能,且由于 TypeScript 为新装饰器提供的类型定义本身就包含了被装饰属性的类型,实现起来会更加简单。不过,由于篇幅限制这里不再赘述,感兴趣的读者可以自行实现。 299 | 300 | > 或许就是因为新装饰器能够获得被装饰的属性的具体类型,从 TypeScript 5.0 开始旧装饰器也跟着具备了上述的访问 `Target` 类型的能力。 301 | 302 | ## 类型安全的 Event Emitter 303 | 304 | Event Emitter 相信大家用的都很多了,我们接下来用 TypeScript 写一个「类型安全」的 Event Emitter。本节的重心在如何使用 TypeScript 的类型系统,所以不会提供具体的代码实现,也会省略 `off()` 函数。 305 | 306 | 我们先来编写一个简易的 Event Emitter 实现: 307 | 308 | ```typescript 309 | // 调用这个函数释放事件,可以通过 `args` 向监听器传递参数 310 | declare function emit(key: string, ...args: unknown[]): void; 311 | 312 | // 调用这个函数监听事件 313 | declare function on(key: string, handler: (...args: unknown[]) => void): void; 314 | 315 | // 省略 off 316 | ``` 317 | 318 | ### 简易实现的问题 319 | 320 | 简易实现的这简简单单的几行代码,却存在着比有效行数更多的问题。我们接下来先分析这些问题,然后尝试使用多种方案解决上面的问题。方案的先后顺序不代表绝对的优劣,却或多或少地体现出我个人对它们的偏好(不同的方案总是有着不同的魅力……你应该懂的)。 321 | 322 | #### 参数类型匹配 323 | 324 | 如何确保释放事件的参数类型和监听事件的参数类型相匹配?例如,在下面的代码中: 325 | 326 | - 没有办法确保 `isReallyHungry` 参数确实存在且为 `boolean` 327 | - 如果调用了 `emit('I_AM_HUNGRY')` 却没有传递任何参数,我们不会在编译期得到任何报错 328 | 329 | ```typescript 330 | // 释放事件: 331 | emit("I_AM_HUNGRY", true); 332 | 333 | // 监听事件: 334 | on("I_AM_HUNGRY", (isReallyHungry) => { 335 | // ... 336 | }); 337 | ``` 338 | 339 | #### 事件名的正确性 & 冲突问题 340 | 341 | 如何确保监听事件传入的事件名正确? 342 | 343 | - 如果我们不小心将 `I_AM_HUNGRY` 拼成了 `I_AM_HUNGARY`,并不会得到任何编译报错 344 | - 如果释放事件经过修改之后,没有任何地方会发布 `I_AM_HUNGRY` 事件了,那么这个监听事件就失去了作用 345 | 346 | 目前的很多实践会通过手动定义并导出一些常量或枚举来让事件名常量化,但取决于具体的实现方式,它可能会带来额外的问题:不存在一种中心化的机制防止事件名冲突。 347 | 348 | #### 参数名称标注 349 | 350 | 在上面的例子中,我们发现事件提供的参数的名称是由事件的监听器定义的,这看上去是非常奇怪的。事件的定义,包括参数的名称应由释放事件一方负责,但碍于简易实现的问题没有办法做到这点。 351 | 352 | 参数名称的标注问题同样是危险的,因为一旦事件释放一方修改了参数的含义,使得它和现有的监听事件方不再一致,那么很可能导致一些出人意料且难以排查的运行时 bug。 353 | 354 | ### 解决方案 355 | 356 | ```typescript 357 | interface EventDefinitions { 358 | // 通过将事件声明收敛到同一个 interface,能够防止事件名冲突 359 | I_AM_HUNGRY: [isReallyHungry: boolean]; 360 | } 361 | 362 | declare function emit( 363 | key: T, 364 | ...args: EventDefinitions[T] 365 | ): void; 366 | 367 | declare function on( 368 | key: T, 369 | handler: (...args: EventDefinitions[T]) => void 370 | ): void; 371 | 372 | // 🎉 正确地报错,没有提供足够的参数 373 | emit("I_AM_HUNGRY"); 374 | 375 | // 🎉 输入逗号时,自动补全提示第二个参数叫「isReallyHungry」且类型为 boolean 376 | emit("I_AM_HUNGRY", true); 377 | 378 | // 🎉 输入逗号时,自动补全提示函数接收一个名为「isReallyHungry」且类型为 boolean 的参数 379 | on("I_AM_HUNGRY", (isReallyHungry) => {}); 380 | 381 | // 🎉 正确地报错,没有这样的事件名 382 | on("I_AM_HUNGARY", () => {}); 383 | ``` 384 | 385 | 上述方案的要点如下: 386 | 387 | - 使用 `interface` 的属性名作为事件名,我们可以通过 `keyof` 取出它的属性名 388 | - 使用[具名元组](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%85%B7%E5%90%8D%E5%85%83%E7%BB%84%E5%85%83%E7%B4%A0labeled-tuple-elements40)定义参数列表。当在函数参数中使用具名元组作为 [Rest Parameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters) 的类型时,TypeScript 会自动使用元素的名称作为对应参数的名称 389 | 390 | 我们目前做到的功能已经和拥有着 9.6k 个 star 的 Event Emitter 库 [mitt](https://github.com/developit/mitt) 基本一样了,但是它还是存在下列缺点: 391 | 392 | 1. 如果要修改已有事件的名字,例如将 `I_AM_HUNGRY` 改为 `WO_E_LE`,TypeScript 不会自动修改使用了这个事件的 `emit` 和 `on` 调用中传入的字符串名称,这意味着你可能需要依赖全局搜索手动修改 393 | 2. 在大型项目中,维护一个中心化的 `interface` 定义可能是困难的,或者说有时会存在一些扩展需求,即在另外的源文件中定义特殊的事件 394 | 395 | ### 解决第一个缺点 396 | 397 | 对于第一个缺点,其实这是使用联合类型作为枚举时的「通病」,要解决这种通病可以尝试直接使用枚举。 398 | 399 | ```typescript 400 | enum EventKeys { 401 | I_AM_HUNGRY, 402 | } 403 | 404 | interface EventDefinitions { 405 | [EventKeys.I_AM_HUNGRY]: [isReallyHungry: boolean]; 406 | } 407 | 408 | on(EventKeys.I_AM_HUNGRY, (isReallyHungry) => {}); 409 | ``` 410 | 411 | ### 解决第二个缺点 412 | 413 | 还记得我们在[模块扩充](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E6%A8%A1%E5%9D%97%E6%89%A9%E5%85%85module-augmentation)中的讨论吗?对于接口 `EventDefinitions`,可以在某个单独的源文件中定义一个空接口,然后在其它源文件中通过模块扩充来扩充它的定义,这样可以实现在不同的源文件中扩充同一个类型定义。 414 | 415 | 对于 `EventKeys` 来说,因为枚举定义涉及到值的定义,而模块扩充只能扩充类型定义,所以不能直接如法炮制。不过,你也许记得[常值枚举和模块扩充之间奇妙的化学反应](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%9C%A8%E6%A8%A1%E5%9D%97%E6%89%A9%E5%85%85%E4%B8%AD%E4%BD%BF%E7%94%A8)。我们可以将 `EventKeys` 声明为常值枚举来让它能够在模块扩充中使用,绕开了值定义的问题。 416 | 417 | ```typescript 418 | // registry.ts 419 | export const enum EventKeys {} 420 | export interface EventDefinitions {} 421 | 422 | // foo.ts,是 I_AM_HUNGRY 这个事件主要触发的地方,提供了事件的注册 423 | declare module "./registry" { 424 | export const enum EventKeys { 425 | I_AM_HUNGRY, 426 | } 427 | 428 | export interface EventDefinitions { 429 | [EventKeys.I_AM_HUNGRY]: [isReallyHungry: boolean]; 430 | } 431 | } 432 | 433 | emit(EventKeys.I_AM_HUNGRY, ...); 434 | export {}; 435 | 436 | // bar.ts 437 | import { EventKeys } from "./registry"; 438 | // 并不需要导入 `foo.ts`,TypeScript 知道 I_AM_HUNGRY 来自 `foo.ts` 439 | on(EventKeys.I_AM_HUNGRY, (isReallyHungry) => {}); 440 | ``` 441 | 442 | - 在 `EventKeys` 枚举中定义的事件的键仍然可以避免重复的键 443 | 444 | TypeScript 能够检查是否存在重复的键,即使它们是通过模块扩充声明的 445 | 446 | - 在 `EventDefinitions` 接口中定义的事件的参数仍然可以避免重复的键 447 | 448 | TypeScript 不允许定义另一个 `[EventKeys.I_AM_HUNGRY]: X`,其中 `X` 为 `[boolean]` 以外的类型 449 | 450 | - 还可以将上面的 `registry.ts` 通过 tsconfig.json 中的配置赋予一些特殊的路径名,例如 `@registry`,这样可以让各个源文件都能通过方便的方式引用到这个文件 451 | 452 | > [!WARNING] 453 | > 不建议将 `registry.ts` 或者任何常值枚举通过共享库的方式发布,换句话说上述技巧只推荐在一些终端应用(不会被其它包依赖)中使用。参见[前文的讨论](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E4%B8%8D%E6%8E%A8%E8%8D%90%E5%9C%A8%E5%85%B1%E4%BA%AB%E5%BA%93%E4%B8%AD%E4%BD%BF%E7%94%A8)。 454 | 455 | > [!WARNING] 456 | > 常值枚举以及关于它的模块扩充是一个极为复杂的功能,对于除了 tsc 以外的 TypeScript 编译器(例如 swc、esbuild、babel 等)来说它们很可能不会提供「正确」的输出,如果你想要在生产环境中接入这里的解决方案,请务必事先确认自己使用的编译工具链是否能输出正确的结果。参见[前文的讨论](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#%E5%9C%A8%E6%A8%A1%E5%9D%97%E6%89%A9%E5%85%85%E4%B8%AD%E4%BD%BF%E7%94%A8)。 457 | 458 | ## 类型安全的文案注册 459 | 460 | 某个 Web App 的文案定义在 `.ts` 文件中,具有下面的形式: 461 | 462 | ```typescript 463 | const i18n = { 464 | "en-US": { 465 | "page.login.login_button": "Login", 466 | "page.login.forgot_password": "Forgot Password?", 467 | "page.login.users_online": "{count} users online", 468 | }, 469 | "zh-CN": { 470 | "page.login.login_button": "登录", // 不要打错成「登陆」了哦,太多人被苹果输入法坑害了 471 | "page.login.forgot_password": "忘记密码?", 472 | "page.login.users_online": "{count} 位会员在线", 473 | }, 474 | }; 475 | ``` 476 | 477 | 请尝试为 `i18n` 对象字面量提供类型检查能力: 478 | 479 | 1. 键只能为 `'en-US'` 或 `'zh-CN'`,其它语言还没做,因为没钱 480 | 2. 每个值对应的对象的键必须一致,换句话说每个语言必须拥有相同的文案种类,防止缺漏 481 | 3. 对应的文案的翻译必须引用相同的插值,比如英文中的 `page.login.users_online` 的值里引用了一个 `{count}` 插值,那么中文里的 `page.login.users_online` 的值里也必须引用这个插值,反之亦然 482 | 483 | ```typescript 484 | type Resource = { 485 | [K in keyof T]: { 486 | [M in keyof T[K]]: T[K][M]; 487 | }; 488 | }; 489 | 490 | function defineResource(resource: Resource) { 491 | return resource; 492 | } 493 | ``` 494 | 495 | 接下来我们分别实现三个能力,最后再将它们合并到一起。不过在此之前,先来介绍一下本节会反复使用的一个工具类型:`IsEqual`。我们会使用它来检查两个类型是否相等。它有些像三目表达式,不过我们为 true 分支和两个 false 分支都提供了默认值。 496 | 497 | ```typescript 498 | type IsEqual = [A] extends [B] 499 | ? [B] extends [A] 500 | ? T 501 | : F2 502 | : F1; 503 | ``` 504 | 505 | 我们还有下面的工具类型,其中的 `I18nError` 就是一个用于承载报错信息的名义类型。 506 | 507 | ```typescript 508 | type Pass = unknown; 509 | 510 | type I18n = Record>; 511 | 512 | declare const ERROR_SYMBOL: unique symbol; 513 | interface I18nError { 514 | [ERROR_SYMBOL]: T; 515 | } 516 | ``` 517 | 518 | 本节的完整的代码可以在 [TypeScript Playground](https://www.typescriptlang.org/play?ts=5.2.2&q=483#code/C4TwDgpgBACghgZwVAvFArgOwNaYPYDumA3AFCmiRQCSAjAByapQBKEAxngE4AmAPAmBcAlpgDmAGlYdu-QSPFT5osQD5VZUjw4AbOF2idMgqAFEWLAPIsA+gGUAmgFkAQpYAyALgyZhAR3RoBBAAWwAjPB1NUWAILgAzOHZoOkZTLi5uPgAVKAgAD1jMHmRlcVUoAG9SKCgAbXMrW0dXDwBdb2yyAF9yShSEUwC4HT4AQSkXKVy0CagAMVpmKYWAJmZFirQ6sba8wohi5DqXNpqoAH560-2ikvrd89qr7Keob3nV84-aTX6oADCAAsONh3HBxOg4GIIAgcrdDvdUpgtjRBsNRudsBAQHh4lBshJzgAiQ4AWgAqnZiVAAD5QYkALyBZIBADliUTavAkFyaAxMOlMlw+MTsng8FAQhCQFA9JDodBtPFRMJgMI8MZiao+cihVliU5hEgVHKIWIoTCoMrVerNQhtaQNH1wNAAOIQYBjHQ6bJcCEIPR2zAAaRxcNyBTuyGRqOqtTqIagoig2Nx+OyHVTOLxBMTbR6dTTuczf1dgJB7GwfoDQY1ofDfHOkYOR35jD5YwRbbKYmYHq9PprxjrmrDIAjFQAZFBe3yk2hi-j47V6kmU0uCVBEGihlDRpvsvmpHNMBAAG5xKQh1RZs+XrhkWq9VHUdH7pu1EN8+9X848hBdQFfURQAAyNE1xGzCcoHibgzQVK0AHIABJKiTGde26JDQNUJ0yyoNgEEiS9qEwWIuDASI4GDOFm27e453OIiGNKIRTTQX8uCdZgW2jKBQLQrDKjQ0R4jiKBx26bpRMwcSuGkQRulA84riIkiIDIiiqNHYw+CI4ApBY+lxzw2pvCIgjoGBUEtLiHSaPrCMpHcVjZ3YxQoAANTc3tUU3FdE2TJhD23GN3xGT9VyPEM2jqVyZ0PfM2j5Wp1J0UjyPs6jaL4LydTeLjUsk85b28LieisitQWHQNHM1LyRkCOjaj4xEYwFPkKWYFcE3XEKcwzLNetXOonGC6CS2SizYQ0uzKJypycnzMa2mdVcoG6J9Nr5Hy0BGoKN0GglhreBNxqO9M81irMbKreaHNyikpHGzCPMka64qcNbtufbaX2YN890i84ivOA7+sm-EvNOjbzomzcvOm3cMSiuHvJW77io2sH0dqXG8b1DIslAsjrQgFVfGDKBc1Q9CoDehQxGwqQhFreqmAINUgWgqA6des64awpDrTwWEoHwYBZyBfRoGAEFZzgEJoBibLdOQLn5ageWaJp-E6aR2LVoZwWNuF3DBfW1duiLY7DbaAtzhtxGUvOLBcEITB8JdKgABE8Du7AIzc2NAYizFuUQQDzkD8FENhHICtqIHUbeADisD2rdPHSdipTj90+qqss45xqdGaxOzKgPDnVIeIsHYambTPdT0C4ZI+CMEw2rbWMAApzgMYi2+SbwIYR47MzHqAyQMOAeE1HRZTGierpih3OkxvYtqdhmoH9wPJ1IABKKpB89NumCHvAR4gHpyC7qXhAFZhm4gVv24gPuV1JTBKWpMebxiRgEVAAOh0HgMQohwGQNEDYMI6BgDAE1MSbwxJ3CwMwJyIBICYQwKgZgUBcEuBiDwMAGwICkAEFkKghk8xuCkKlgBahvALjYI2sAsBECCGgPQAgOICAbCL1EBAWhxJKicCwMAboGB+FcGQMIs87CdokmZKyDkgCOG4IgPg6B3C4EIKQSgtBgBvN0AKr6yjaicLwfowhxDGEUKjiwngYjAAb+oABujABj2oAQA9AD4-5Yhk2jdGEL4QIoRmAdAiLERIm+5EZGAFl5QAWPKAA0VQAFOqAH6-ZR3QiTdGPmQIAA) 中看到。 519 | 520 | ### 检查对象的键 521 | 522 | 检查对象是否仅含 `en-US` 和 `zh-CN` 两个键是平凡的,只需要让它们和 `keyof T` 进行比较即可。不过,由于 `keyof T` 会返回联合类型,为了绕过[分配式联合类型](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E5%88%86%E9%85%8D%E5%BC%8F%E6%9D%A1%E4%BB%B6%E7%B1%BB%E5%9E%8Bdistributive-conditional-types)特性,我们用方括号将比较双方包围,这样比较的就是元组类型而不是联合类型(`IsEqual` 内部已经做了这件事情)。 523 | 524 | ```typescript 525 | type CheckLanguages = IsEqual< 526 | keyof T, 527 | "en-US" | "zh-CN", 528 | Pass, // T 529 | I18nError<"Too many language definitions">, // F1 530 | I18nError<"Missing language definitions"> // F2 531 | >; 532 | ``` 533 | 534 | ### 检查是否具有相同的文案种类 535 | 536 | 这里使用一个简单的实现思路:将各个语言对应的对象的所有键(也就是文案)表示为联合类型,然后将这些联合类型合并在一起得到一个大的联合类型,最后检查每个语言对应的对象的键构成的联合类型是否等于这个联合类型。 537 | 538 | > 这里的联合类型充当了 Set 的作用,即:可自动去重的元组类型。特别地,`never` 表示「空状态」。 539 | 540 | ```typescript 541 | type GetAllTranslationKeys = { 542 | // 获得 I18n 对象里各个语言对应的对象有哪些键(用联合类型表示) 543 | [K in keyof T]: keyof T[K]; 544 | }[keyof T]; // 使用 IIMT 将对象的值合并在一起得到大的联合类型 545 | 546 | type CheckTranslationKeys< 547 | T extends I18n, 548 | // 这种用法类似于命令式编程里在函数体中声明的本地变量 549 | A extends string = GetAllTranslationKeys & string, 550 | K = keyof { 551 | // 使用映射类型中键的重映射,检查每个语言的对象里的键是否等于上面的 A 552 | // 如果等于,返回 never,这会使得这个键不会出现在构造出来的映射类型中 553 | // 否则,返回 K 本身,让它出现在映射类型里,通过第 10 行的 keyof 收集出来 554 | [K in keyof T as IsEqual]: never; 555 | } 556 | > = IsEqual< 557 | // 检查我们找到的有问题的键 K(联合类型)是否为空(never) 558 | K, 559 | never, 560 | Pass, 561 | // 当 K 不为空时,会返回这个报错类型,我们实际上可以将 K 嵌入到报错信息里 562 | I18nError<`Missing keys for language '${K & string}'`> 563 | >; 564 | ``` 565 | 566 | ### 检查文案的翻译是否引用相同的插值 567 | 568 | 给定一个字符串字面量类型,提取出它格式为 `{key}` 的插值并表示为联合类型是简单的。具体的代码如下所示,注意到我们仍然使用了联合类型去充当 Set 的作用,它一方面用来存储找到的插值,另一方面可以自动去重。 569 | 570 | ```typescript 571 | type ResolveInterpolations< 572 | T extends string, 573 | Res extends string = never 574 | > = T extends `${string}{${infer Key}}${infer Rest}` 575 | ? ResolveInterpolations 576 | : Res; 577 | ``` 578 | 579 | 假如我们通过上面的类型,得到了文案对象里面各个文案字符串使用了哪些插值,就像下面的类型: 580 | 581 | ```typescript 582 | type T = { 583 | "en-US": { 584 | "page.login.login_button": never; 585 | "page.login.forgot_password": never; 586 | "page.login.users_online": "count"; 587 | }; 588 | "zh-CN": { 589 | "page.login.login_button": never; 590 | "page.login.forgot_password": never; 591 | "page.login.users_online": "count" | "test"; 592 | }; 593 | }; 594 | ``` 595 | 596 | 假如我们现在在检查某个文案,它的键为 `L`,值为 `V`。如何结合上面的信息检查它是否使用了和其它语言的对应文案完全相同的插值呢?下面的代码实现了这一点。 597 | 598 | ```typescript 599 | // 注意第二行末尾的 keyof 哦,它实际上返回了「哪些语言的对应文案,使用的插值不一样」 600 | type CheckInterpolations = keyof { 601 | [K in keyof T as IsEqual< 602 | // 检查各个语言中的对应文案(即键同样为 `L` 的属性)使用的插值 603 | T[K][L & keyof T[K]], 604 | // 当前文案使用了哪些插值 605 | ResolveInterpolations, 606 | // 两类型相等时,返回 never 让 TypeScript 去掉这个属性 607 | never, 608 | // 否则,将 K 作为这个属性的键,也就是语言 609 | K 610 | >]: never; 611 | }; 612 | ``` 613 | 614 | 下面是完成功能的主体代码: 615 | 616 | - `U` 作为「局部变量」存储了从 `ResolveInterpolations` 得到的信息:各个语言的各个文案使用了哪些插值,用联合类型表示 617 | - `V` 作为另一个「局部变量」存储了从 `CheckInterpolations` 得到的信息:各个语言的各个文案,使用了和哪些语言的对应文案不同的插值,为 `never` 时表示「没有,都一样!」 618 | - 从第十三行开始,我们检查 `V` 的各个值是否为 `never`,也即「是否存在有问题的文案值」。注意我们这里是如何利用对应的泛型参数构造信息量充足的报错信息的。这里还使用了两层嵌套的 [IIMT](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#immediately-indexed-mapped-typeiimt),用来将可能存在的 `I18nError` 提取出来作为联合类型。 619 | - 当然,如果整个文案对象都没有错误,它会返回 `never`,通过 `IsEqual` 的判断最终返回 `unknown`。 620 | 621 | ```typescript 622 | type CheckTranslationValues< 623 | T extends I18n, 624 | U = { 625 | [K in keyof T]: { 626 | [M in keyof T[K]]: ResolveInterpolations; 627 | }; 628 | }, 629 | V = { 630 | [K in keyof T]: { 631 | [M in keyof T[K]]: CheckInterpolations; 632 | }; 633 | } 634 | > = IsEqual< 635 | never, 636 | { 637 | [K in keyof V]: { 638 | [M in keyof V[K]]: IsEqual< 639 | V[K][M], 640 | never, 641 | never, 642 | I18nError<`In definition of '${K & string}', translation with key '${M & 643 | string}' does not share the same interpolations with that of '${V[K][M] & 644 | string}'`> 645 | >; 646 | }[keyof V[K]]; 647 | }[keyof V], 648 | unknown 649 | >; 650 | ``` 651 | 652 | ### 将三种检查串在一起 653 | 654 | 为了防止三种检查相互干扰,我们通过条件类型来先后执行这三种检查。如果前面的检查失败,后面的检查就不会执行。请看下面的代码: 655 | 656 | ```typescript 657 | type DoChecks = IsEqual< 658 | Pass, 659 | CheckLanguages, 660 | IsEqual< 661 | Pass, 662 | CheckTranslationKeys, 663 | IsEqual> 664 | > 665 | >; 666 | ``` 667 | 668 | 函数 `defineResource` 的代码如下所示,注意: 669 | 670 | - 使用 `const T` 是为了让 TypeScript 推导输入对象的类型时,将内含的字符串类型推导为字面量类型 671 | - 使用第 3 行的映射类型,而不是 `Record>` 是为了[提供更强的 contextual types](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#type-parameter-inference),让 TypeScript 顺利推导出文案对象这种复杂对象结构的字面量类型。这里的做法是通过经验试出来的。 672 | - `-readonly` 是为了去掉 `const T` 为属性附加的 `readonly` 修饰符。 673 | 674 | ```typescript 675 | function defineResource( 676 | resource: { 677 | [K in keyof T]: { -readonly [M in keyof T[K]]: T[K][M] }; 678 | } & DoChecks 679 | ) { 680 | return resource; 681 | } 682 | ``` 683 | 684 | ### 效果展示 685 | 686 | 本节的完整的代码可以在 [TypeScript Playground](https://www.typescriptlang.org/play?ts=5.2.2&q=483#code/C4TwDgpgBACghgZwVAvFArgOwNaYPYDumA3AFCmiRQCSAjAByapQBKEAxngE4AmAPAmBcAlpgDmAGlYdu-QSPFT5osQD5VZUjw4AbOF2idMgqAFEWLAPIsA+gGUAmgFkAQpYAyALgyZhAR3RoBBAAWwAjPB1NUWAILgAzOHZoOkZTLi5uPgAVKAgAD1jMHmRlcVUoAG9SKCgAbXMrW0dXDwBdb2yyAF9yShSEUwC4HT4AQSkXKVy0CagAMVpmKYWAJmZFirQ6sba8wohi5DqXNpqoAH560-2ikvrd89qr7Keob3nV84-aTX6oADCAAsONh3HBxOg4GIIAgcrdDvdUpgtjRBsNRudsBAQHh4lBshJzgAiQ4AWgAqnZiVAAD5QYkALyBZIBADliUTavAkFyaAxMOlMlw+MTsng8FAQhCQFA9JDodBtPFRMJgMI8MZiao+cihVliU5hEgVHKIWIoTCoMrVerNQhtaQNH1wNAAOIQYBjHQ6bJcCEIPR2zAAaRxcNyBTuyGRqOqtTqIagoig2Nx+OyHVTOLxBMTbR6dTTuczf1dgJB7GwfoDQY1ofDfHOkYOR35jD5YwRbbKYmYHq9PprxjrmrDIAjFQAZFBe3yk2hi-j47V6kmU0uCVBEGihlDRpvsvmpHNMBAAG5xKQh1RZs+XrhkWq9VHUdH7pu1EN8+9X848hBdQFfURQAAyNE1xGzCcoHibgzQVK0AHIABJKiTGde26JDQNUJ0yyoNgEEiS9qEwWIuDASI4GDOFm27e453OIiGNKIRTTQX8uCdZgW2jKBQLQrDKjQ0R4jiKBx26bpRMwcSuGkQRulA84riIkiIDIiiqNHYw+CI4ApBY+lxzw2pvCIgjoGBUEtLiHSaPrCMpHcVjZ3YxQoAANTc3tUU3FdE2TJhD23GN3xGT9VyPEM2jqVyZ0PfM2j5Wp1J0UjyPs6jaL4LydTeLjUsk85b28LieisitQWHQNHM1LyRkCOjaj4xEYwFPkKWYFcE3XEKcwzLNetXOonGC6CS2SizYQ0uzKJypycnzMa2mdVcoG6J9Nr5Hy0BGoKN0GglhreBNxqO9M81irMbKreaHNyikpHGzCPMka64qcNbtufbaX2YN890i84ivOA7+sm-EvNOjbzomzcvOm3cMSiuHvJW77io2sH0dqXG8b1DIslAsjrQgFVfGDKBc1Q9CoDehQxGwqQhFreqmAINUgWgqA6des64awpDrTwWEoHwYBZyBfRoGAEFZzgEJoBibLdOQLn5ageWaJp-E6aR2LVoZwWNuF3DBfW1duiLY7DbaAtzhtxGUvOLBcEITB8JdKgABE8Du7AIzc2NAYizFuUQQDzkD8FENhHICtqIHUbeADisD2rdPHSdipTj90+qqss45xqdGaxOzKgPDnVIeIsHYambTPdT0C4ZI+CMEw2rbWMAApzgMYi2+SbwIYR47MzHqAyQMOAeE1HRZTGierpih3OkxvYtqdhmoH9wPJ1IABKKpB89NumCHvAR4gHpyC7qXhAFZhm4gVv24gPuV1JTBKWpMebxiRgEVAAOh0HgMQohwGQNEDYMI6BgDAE1MSbwxJ3CwMwJyIBICYQwKgZgUBcEuBiDwMAGwICkAEFkKghk8xuCkKlgBahvALjYI2sAsBECCGgPQAgOICAbCL1EBAWhxJKicCwMAboGB+FcGQMIs87CdokmZKyDkgCOG4IgPg6B3C4EIKQSgtBgBvN0AKr6yjaicLwfowhxDGEUKjiwngYjAAb+oABujABj2oAQA9AD4-5Yhk2jdGEL4QIoRmAdAiLERIm+5EZGAFl5QAWPKAA0VQAFOqAH6-ZR3QiTdGPmQIAA) 中找到。下面的视频展示了最终的效果。 687 | 688 |

689 | 690 |

691 | 692 | > 我们其实可以做得更好,可以考虑改进报错的粒度以及报错信息里的提示。 693 | 694 | ## 端到端类型安全(End-to-End Type-Safety) 695 | 696 | Web 前端界每隔一段时间就会出现一些爆款,感觉很多都有不少的水分,不过其中的 [tRPC](https://trpc.io/) 库却让人眼前一亮。这个库宣称自己为 API 调用提供了「端到端」的类型安全支持。可能就是这个库提出或者发扬了「端到端的类型安全」这个概念,以至于后续出现的很多新的服务端框架都提供了这种功能。 697 | 698 |

699 | 700 |

701 | 702 | 虽然文档中似乎没有解释这个概念具体是什么意思,不过我暂且假定它指的是这种能力:服务端源文件的路由的类型定义(包括具体的请求体类型和响应体类型)可以直接传播到客户端的源文件中。 703 | 704 | 这种功能是怎么实现的?为了探究问题的答案,本节会研究一个形式较为简单的「端到端类型安全」的实现:[ElysiaJS](https://elysiajs.com/),它是一款新式的服务端框架,主要服务于 [Bun](https://bun.sh/)(一个爆款 JavaScript 运行时)。ElysiaJS 提供了下面例子的端到端类型安全功能(这个功能需要安装它的一个名为 [Eden](https://elysiajs.com/plugins/eden/overview.html) 的插件……唔,熟悉的名字)。 705 | 706 | 先来看看服务端的源文件 `server.ts`: 707 | 708 | ```TypeScript 709 | import { Elysia, t } from "elysia"; 710 | 711 | const app = new Elysia() 712 | // 这个端点响应体是一个字符串,来自给定的 id 713 | .get("/id/:id", ({ params: { id } }) => id) 714 | // 这个端点响应体是一个 JSON 对象,来自请求体 715 | .post("/json", ({ body }) => body, { 716 | // 对请求体的类型验证 717 | body: t.Object({ 718 | name: t.String(), 719 | }), 720 | }) 721 | .listen(3000); 722 | 723 | export type App = typeof app; 724 | ``` 725 | 726 | 再来看看在客户端中,我们能得到怎么样的「端到端类型安全」。注意下面三处高亮标记的地方。 727 | 728 | ```TypeScript 729 | import { edenTreaty } from "@elysiajs/eden"; 730 | import type { App } from "./server"; 731 | 732 | const api = edenTreaty("http://0.0.0.0:8080"); 733 | 734 | // 这是 Elysia 调用它的服务端的特殊方式,下面等价于 GET /id/seele 请求 735 | const _0 = await api.id.seele.get(); 736 | // 它的返回值类型等于下面的 T0 737 | type T0 = ( 738 | | { // Elysia 成功地根据 server.ts 第 6 行的函数返回值推导出了响应体类型 739 | data: string; 740 | error: null; 741 | } 742 | | { // 客户端请求错误时的响应体,在这个例子是固定的 743 | data: null; 744 | error: EdenFetchError; 745 | } 746 | ) & { // 响应体的公共属性,在这个例子是固定的 747 | status: number; 748 | response: Response; 749 | headers: Record; 750 | }; 751 | 752 | const _1 = await api.json.post({ name: "saltyaom" }); 753 | // 它的返回值类型等于下面的 T1 754 | type T1 = ( 755 | | { // 看!它又推导出正确的响应体类型了 756 | data: { 757 | name: string; 758 | }; 759 | error: null; 760 | } 761 | | { // 客户端请求错误时的响应体,在这个例子是固定的 762 | data: null; 763 | error: EdenFetchError; 764 | } 765 | ) & { // 响应体的公共属性,在这个例子是固定的 766 | status: number; 767 | response: Response; 768 | headers: Record; 769 | }; 770 | 771 | 772 | api.json.post({ 773 | // TypeScript 正确地报错,因为 server.ts 第 10 行说 name 应该是个字符串 774 | name: 1, 775 | }); 776 | ``` 777 | 778 | 由于篇幅原因,我们这里只简单地研究它的 `get` 函数是如何实现的并解答下面的问题: 779 | 780 | > 在 `server.ts` 中的代码是 `get("/id/:id", ({ params: { id } }) => id)` 781 | 782 | - 为什么它可以推导出 `params` 中存在一个 `id` 字符串类型? 783 | - 为什么处理函数的返回值类型 `string` 可以被客户端知道? 784 | 785 | 解决了这些问题之后再阅读其它函数的实现不是一件困难的事情(我相信能坚持读到这里的你一定有足够的能力……),而且对于端到端类型安全的原理分析,如果要「认真地」写,恐怕又会需要写出一篇万字长文。 786 | 787 | 在开始研究源码之前,我们先说结论:它的原理是对函数泛型的巧妙利用,尤其是前文讨论的[各种匹配方法](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E5%B8%B8%E7%94%A8%E7%9A%84%E5%8C%B9%E9%85%8D%E6%96%B9%E6%B3%95)和[函数类型闭包](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E5%87%BD%E6%95%B0%E7%B1%BB%E5%9E%8B%E9%97%AD%E5%8C%85)。简化后的 `get` 函数源码如下所示(只需要关心高亮的代码)。 788 | 789 | - `Elysia` 本身就是一个带有泛型参数的类,它携带了包括路由信息 `Routes` 在内的类型闭包 790 | - 每次调用 `get` 函数之后得到的返回值是一个被扩充了类型信息之后的 `Elysia` 类型,具体被扩充了什么类型信息可以看下面的代码,对于 `get` 函数来说只需要扩充 `Routes` 的定义 791 | - `Routes` 定义中,当前路由的响应体类型在我们的例子中由第 28 行的 `ReturnType` 得到 792 | - 因此,`client.ts` 之所以能得到每个请求的请求体类型、响应体类型的类型信息,就是因为包括 `Routes` 在内的类型信息被通过类型闭包(`export type App = typeof app`)传递到了 `client.ts` 中 793 | - `Function` 具体是如何推导出来的?请看后文! 794 | 795 | ```TypeScript 796 | // ⚠️ 省略了很多类型参数以及其它内容,具体以源码为准! 797 | class Elysia< 798 | BasePath extends string = '', 799 | Routes extends RouteBase = {}, 800 | >{ 801 | get< 802 | const Path extends string, 803 | const Route extends MergeSchema< // 省略了 LocalSchema 的定义 804 | UnwrapRoute, 805 | ParentSchema 806 | >, 807 | const Function extends Handler 808 | >( 809 | path: Path, 810 | handler: Function 811 | ): Elysia< 812 | BasePath, 813 | // 👇 通过交叉类型将新构造的对象类型扩充到 Routes 里 814 | Routes & { 815 | // 👇 映射类型,意思是「构造一个对象类型,它具有一个键为这个的属性」 816 | [path in `${BasePath}${Path}`]: { 817 | get: { 818 | body: Route["body"]; 819 | params: Route["params"]; 820 | query: Route["query"]; 821 | headers: Route["headers"]; 822 | response: unknown extends Route["response"] 823 | ? { 200: ReturnType } // 👈 处理函数的返回值类型通过这种方式保存了下来 824 | : Route["response"] extends { 200: any } 825 | ? Route["response"] 826 | : { 200: Route["response"] }; 827 | }; 828 | }; 829 | } 830 | > { 831 | // 本函数在运行时中的实现是很简单的 832 | this.add("GET", path, handler as any, hook); 833 | // 让它通过 TypeScript 的类型检查,库作者会确保扩充的 Elysia 类型定义不会造成运行时错误 834 | return this as any; 835 | } 836 | } 837 | ``` 838 | 839 | 我们来看看 `Function` 类型依靠的 `Handler` 类型的定义: 840 | 841 | ```TypeScript 842 | export type Handler< 843 | Route extends RouteSchema = {}, 844 | Path extends string = '' 845 | > = (context: Context) => // 注意,这个类型是个函数类型 846 | Route['response'] extends { 200: unknown } 847 | ? Response | MaybePromise 848 | : Response | MaybePromise 849 | ``` 850 | 851 | 从这里可以看出来,`Context` 类型应该是一个对象类型,`id` 应该是它根据 `Path` 字符串类型推导出来的。 852 | 853 | ```TypeScript 854 | export type Context< 855 | Route extends RouteSchema = RouteSchema, 856 | Decorators extends DecoratorBase = { 857 | request: {} 858 | store: {} 859 | }, 860 | Path extends string = '' 861 | > = { 862 | // 👇 看!这里发生了和前文类型安全的路由器类似的字符串解析过程 863 | params: undefined extends Route['params'] 864 | ? Path extends `${string}/${':' | '*'}${string}` 865 | ? Record, string> 866 | : never 867 | : Route['params'] 868 | // ... 省略其它属性 869 | } & Decorators['request'] 870 | ``` 871 | 872 | 至于 `Handler` 返回的函数类型的返回值,它定义中的返回值类型是用来做类型检查的,如果我们使用了其它 Elysia 的 API,那么这些 API 可能会限制处理函数能够返回的值的类型。 873 | 874 | --- 875 | 876 | | **上一章** | **目录** | **下一章** | 877 | | :------------- | :----------: | :------: | 878 | | [第三章:类型编程](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | [第五章:探索之路](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter5.md) | 879 | -------------------------------------------------------------------------------- /chapter5.md: -------------------------------------------------------------------------------- 1 | | **上一章** | **目录** | 2 | | :------------- | :----------: | 3 | | [第四章:生产实践](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter4.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | 4 | 5 | --- 6 | 7 | # 第五章:探索之路 8 | 9 | 本章节将介绍一些和 TypeScript 相关的资料、书籍和类型库,希望能够帮助你继续在 TypeScript 的道路上探索下去。 10 | 11 | ## 资料 12 | 13 | ### [type-challenges](https://github.com/type-challenges/type-challenges) 14 | 15 | 收集了一系列难度从简单到极难的 TypeScript 类型谜题。曾经是 [antfu](https://github.com/antfu) 的个人仓库,可能就是因为这个仓库,前端界把钻研 TypeScript 各种类型技巧的行为称为「类型体操」。本文开头说的「体操家」的出处就是这里。 16 | 17 | ### [FAQs](https://github.com/microsoft/TypeScript/wiki/FAQ) 18 | 19 | TypeScript 仓库的 Wiki 里提供的 FAQ 列表,介绍了和 TypeScript 有关的一些基本概念,以及解答了许多常见的疑难杂症。 20 | 21 | ### [How the TypeScript Compiler Compiles](https://www.youtube.com/watch?v=X8k_4tZ16qU&list=PLYUbsZda9oHu-EiIdekbAzNO0-pUM5Iqj&index=5) 22 | 23 | TypeScript 前开发者 Orta Therox 的一个关于 TypeScript 编译器实现细节的简短介绍,重心放在了类型检查的部分。不过内容比较浅薄,只能提供一种宏观视角。 24 | 25 | ### [TypeScript-Compiler-Notes](https://github.com/microsoft/TypeScript-Compiler-Notes) 26 | 27 | 关于 TypeScript 源码的笔记: 28 | 29 | - 编译器,特别是类型检查部分的一些概念的介绍、原理的解释 30 | - 语言服务的部分介绍 31 | - 源码调试技巧、开发流程介绍 32 | 33 | > Fun fact:我们在使用 TypeScript 的过程中遇到的绝大多数类型报错都来自一个名为 `checker.ts` 的源文件提供的类型检查能力。截至 2023 年 10 月 15 日,这个源文件有 50463 行! 34 | 35 | 阅读 TypeScript 的源码可能不是简单的,因为它涉及了太多、太复杂的概念(请思考这个事实:TypeScript 的历史已经超过了 13 年)。一种效率较高的「理解 TypeScript 如何工作」的方式可能是: 36 | 37 | 1. 将源码克隆下来 38 | 2. 运行 `npm install` 和 `npm run build:compiler` 39 | 3. 使用[笔记里的调试配置](https://github.com/microsoft/TypeScript-Compiler-Notes/blob/main/systems/debugging/settings.md#hard-coded-file),在随便什么地方建一个 `.ts` 文件,将路径填到配置里 40 | 4. 在源码中设置断点,然后运行调试 41 | 5. 一步一步、反反复复地看你感兴趣的过程 42 | 43 | ### [TypeScript Origins: The Documentary](https://www.youtube.com/watch?v=U6s2pdxebSo) 44 | 45 | TypeScript 的纪录片,比较有看点的地方: 46 | 47 | - TypeScript 的起源,为什么会做这个东西 48 | - 微软对开源的态度是如何从 TypeScript 开始发生变化的 49 | - TypeScript 的发展过程中比较重要的一些时间节点,比如 Angular 2 的接入 50 | 51 | ## 书籍 52 | 53 | 关于 TypeScript 的书籍,我能找到的似乎都是一些内容偏向基础的书籍。它们讨论的内容并不会像本文那样深入(或者说硬核),也许是因为这些知识不太成体系,并且大多属于经验之谈(尽管有一定的源码和文档支撑)。 54 | 55 | | **Effective TypeScript** | **Learning TypeScript** | 56 | | :------------- | :---------- | 57 | |
提供了 62 种改善 TypeScript 使用技巧的方式,让你摆脱一些关于 TypeScript 的常见误用。书中还讨论了 TypeScript 的许多并没有在文档中介绍得清楚的机制,例如 Type Narrowing 和 Type Widening,帮助你理清 TypeScript 的一些设计原则和机制细节。
总的来说,这是一本在闲暇之余可以读读看的好书。
注意:此书第一版发布于 2019 年底,至今已经接近 4 年,在这个过程中 TypeScript 已经发生了很多新的改变。书中讨论的一些问题已经有了更好、更现代的解法,可以参考本文章来形成互补。 |
补充 TypeScript 基础知识的好书,涵盖了很多 TypeScript 官方文档没有涉及或讲得不多的内容,尤其是其中的:
  • 关于泛型的各种机制的介绍
  • 对 declaration files(声明文件,即 d.ts 文件)的介绍
  • 对 TypeScript 的配置项(特别是 tsconfig)的介绍
本书的发布日期非常新,于 2022 年 7 月发行第二版,可以说是目前入门 TypeScript 的首选好书。 | 58 | 59 | ## 类型库 60 | 61 | 下面是一些能够帮助我们编写类型的工具库,注意这些库提供的 API 大多数都是 TypeScript 类型,所以并不会出现在编译产物中,也不会增加包体积。除了使用上的价值外,这些类型库还有学习上的价值,当我们对 TypeScript 的某个类型或功能不了解时,可以查看这些库的源码实现,从中获取思路。 62 | 63 | ### [ts-typesafe-decorators](https://github.com/sorgloomer/ts-typesafe-decorators) 64 | 65 | 为旧的 Stage 2 装饰器提供类型检查的工具类型库,本文在生产实践中[类型安全的装饰器](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter4.md#%E7%B1%BB%E5%9E%8B%E5%AE%89%E5%85%A8%E7%9A%84%E8%A3%85%E9%A5%B0%E5%99%A8-50)一节就是受到了这个库的启发。它同样依赖于 TypeScript 5.0 或以上版本,所以又多了一个理由升级 TypeScript。 66 | 67 | ### [ts-essentials](https://github.com/ts-essentials/ts-essentials) 68 | 69 | 提供一些 TypeScript 没有自带但是很有用的工具类型。例如: 70 | 71 | - `XOR` 72 | - 构造一个只能被赋给 `Type1` 或 `Type2` 的类型,但它不会同时可以被赋给两个类型 73 | - `MarkWritable` 74 | - 取消给定对象类型中给定属性的 `readonly` 标识 75 | 76 | ### [ts-pattern](https://github.com/gvergnaud/ts-pattern) 77 | 78 | 用于对复杂联合类型进行匹配的类型库,下面的例子来自它的 README。 79 | 80 | - 注意它是如何利用[函数类型闭包](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter3.md#%E5%87%BD%E6%95%B0%E7%B1%BB%E5%9E%8B%E9%97%AD%E5%8C%85)提供丰富的类型功能的 81 | - 它的 `exhaustive()` 提供了和本文的 [Exhaustive Guard](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter2.md#exhaustive-guardexhaustive-check) 类似的功能 82 | 83 | ```typescript 84 | import { match, P } from 'ts-pattern'; 85 | 86 | type Data = 87 | | { type: 'text'; content: string } 88 | | { type: 'img'; src: string }; 89 | 90 | type Result = 91 | | { type: 'ok'; data: Data } 92 | | { type: 'error'; error: Error }; 93 | 94 | const result: Result = ...; 95 | 96 | const html = match(result) 97 | .with({ type: 'error' }, () =>

Oups! An error occured

) 98 | .with({ type: 'ok', data: { type: 'text' } }, (res) =>

{res.data.content}

) 99 | .with({ type: 'ok', data: { type: 'img', src: P.select() } }, (src) => ) 100 | .exhaustive(); 101 | ``` 102 | 103 | ### [ts-reset](https://github.com/total-typescript/ts-reset) 104 | 105 | - `fetch.json()` 和 `JSON.parse()` 会返回 `unknown` 而不是问题多多的 `any` 了 106 | 107 | - `Array.filter(Boolean)` 的返回值类型能够去掉空类型了 108 | 109 | ```typescript 110 | const input = [null, undefined, true, "seele"]; 111 | // ^? const input: (string | boolean | null | undefined)[] 112 | const result = input.filter(Boolean); 113 | // ^? const result: (string | boolean)[] 114 | ``` 115 | 116 | - 还有许多对 TypeScript 自带的类型库的改进 117 | 118 | ### [string-ts](https://github.com/gustavoguichard/string-ts) 119 | 120 | 针对字符串字面量类型的工具类型库,下面的图片来自它的 README。 121 | 122 |

123 | 124 |

125 | 126 | ### [type-fest](https://github.com/sindresorhus/type-fest) 127 | 128 | 也是一个类似 `ts-essentials` 的工具类型库,提供了许多 TypeScript 没有自带的工具类型。 129 | 130 | 具体请看它的 README。 131 | 132 | ### [hotscript](https://github.com/gvergnaud/hotscript) 133 | 134 | 使用 TypeScript 的类型系统构建的一套新的类型「语言」,内置了很多类似函数式编程的组合式 API(你可以尝试将它类比为给类型系统用的 lodash)。它提供的 API 能够让我们通过一种统一、直观的方式表达各种复杂的类型计算,不过看上去学习成本有些高。下面是来自它的 README 的一些例子: 135 | 136 | - Transforming a list 137 | 138 | ```typescript 139 | type _ = Pipe< 140 | // ^? type _ = 62 141 | [1, 2, 3, 4], 142 | [ 143 | Tuples.Map>, // [4, 5, 6, 7] 144 | Tuples.Join<".">, // "4.5.6.7" 145 | Strings.Split<".">, // ["4", "5", "6", "7"] 146 | Tuples.Map>, // ["14", "15", "16", "17"] 147 | Tuples.Map, // [14, 15, 16, 17] 148 | Tuples.Sum // 62 149 | ] 150 | >; 151 | ``` 152 | 153 | - Parsing a route path 154 | 155 | ```typescript 156 | type _ = Pipe< 157 | // ^? type _ = { id: string, index: number } 158 | "/users//posts/", 159 | [ 160 | Strings.Split<"/">, 161 | Tuples.Filter>, 162 | Tuples.Map">, Strings.Split<":">]>>, 163 | Tuples.ToUnion, 164 | Objects.FromEntries, 165 | Objects.MapValues< 166 | Match<[Match.With<"string", string>, Match.With<"number", number>]> 167 | > 168 | ] 169 | >; 170 | ``` 171 | 172 | --- 173 | 174 | | **上一章** | **目录** | 175 | | :------------- | :----------: | 176 | | [第四章:生产实践](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript/blob/main/chapter4.md) | [你可能不知道的 TypeScript](https://github.com/darkyzhou/You-Might-Not-Know-TypeScript#%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84-typescript) | 177 | --------------------------------------------------------------------------------