TypeScript 实践小记
本文绝大多数内容基于个人理解,并不能保证绝对性,也因此值得交流探讨,求同存异。
1 为何需要 TS
TS 的优势与必要性已经是一个较为老生常谈的问题,TS 的核心在于为 JS 提供了显式类型支持,从而让 JavaScript 更像 Java。就项目工程化本身而言,显式类型支持的优势不言而喻,JS 中的许多错误都源于对变量类型的考虑不周,引入 TS 则可以通过指定类型来保证代码行为更加“可以预料”,尽可能地将各种错误扼杀在摇篮中,加强代码的健壮性。
话虽如此,为项目(尤其是已经有了历史沉淀的老项目)引入 TS 的结果却常常是让原本不报错且正常运行的代码变得“大红大紫”,并且对原本灵活的 JS 编程处处掣肘:一方面,TS 暴露出的一些问题实际可能并不是问题(毕竟代码可能早就带着这些问题正常运行了数年,甚至代码本身就是依靠 Bug 才能运行起来);另一方面,JS 原本的魅力之一就是不甚规范但相当灵活的类型限制,而 TS 所要求的“按章办事“无疑会让人如坐针毡。因此,对于程序员而言,TS 所提供的类型检查与代码安全可能并没有那么大的吸引力——尤其是当规范与效率存在冲突时。
实际上,相比于提供类型检查,TS 真正的魅力可能更多在于基于类型检查而提供的代码建议——只消敲下 Tab 键而不是将代码逐字敲完的诱惑实在是让人无法自拔。举例而言:假设要实现一个函数,其作用是返回输入的字符串的小写形式:
1 | |
编程的快感会在敲下 str.t 时戛然而止,因为 IDE 没有给出任何有用的提示,使得程序员不得不凭借记忆将 toLowerCase 一字不落地敲出来,至于敲没敲对,甚至字符串类型是不是真的有这个方法,就只能跑一遍试试了。

此时,TS 就如救世主一般降临,通过为 str 指定 string 类型,就可以让其获得完美的代码建议——

可以看出,TS 在这里其实是实现了**“在不改变编译结果的前提下,告诉 IDE 变量的类型,从而获得完整的代码建议体验”**,并且这一功能对于非原始数据类型也同样适用——只要类型声明得足够到位。
综上所述,使用 TS 的理由无外乎两大点:
1. 严格的静态类型检查使代码更加安全;
2. 主动类型声明使代码建议更加无处不在,带来了更加愉悦的编码体验(而类型检查则保证了主动声明类型的安全性,避免指鹿为马)。
甚至可以极端地认为,TS 的本质就是带来了基于类型的更加丰富的代码建议,而类型检查等只是类型声明所带来的“副作用”而已(实际上当然不是这样)。
2 interface vs type
TS 有两种声明类型的方式,即 interface 与 type,二者的区别并不是很大。在初步接触 TS 时,很难单纯根据官方手册所提供的信息对二者进行较为清晰的区分与抉择,大多数人可能也只是较为囫囵地接受了官方“有 interface 选 interface,无 interface 再 type”的建议(If you would like a heuristic, use interface until you need to use features from type)。

与其探讨 interface 与 type 分别能做什么、不能做什么,不如从中抽丝剥茧出二者的定位区别——interface 更加强调类型之间的交互(类型合并与 extends),type 则更强调类型的独立性与确定性(不可重复声明)。或者说,interface 更像引用类型,type 更像原始类型。
interface 无论是与 Java 中一样的命名,还是所支持的 extends 语法,亦或是不支持原始类型,均在表达 TS 希望用户将其当作“类”来使用,即用于描述对象的形状,以及各个对象类型之间的关系:
1 | |
而对于 type,虽然其也能通过 & 运算实现与 extends 一样的效果,但从语义而言,其所表示的含义可能仅仅是通过运算得到一个新的类型值而已,其全称“Type Aliases(类型别名)”以及不可重复声明与更改(就像 const)的特性也正是表达这一意向。这也是为什么 Type 支持了大量的类型运算,而 interface 只有一个 extends 的原因。
1 | |
可以理解为:**type 用于声明各种各样的、JS 原生并不存在的类型,interface 则负责使用这些类型来描述各种具体的对象。**这一理解也与 TS 官方对二者的推荐大致吻合,当 interface 无法胜任,则很可能是需要定义一些新的类型(类型运算也还是为了生产新的类型),此时自然应该使用 type;否则,既然不需要定义新类型,自然也就没必要使用 type,此时应当选择语义更清晰的 interface。
总而言之,interface 与 type 的并无优劣(否则其中一个也没必要存在),优先使用 interface 也只是出于 TS 本身的设计考虑而已。实际上,type 支持更多复杂的类型操作,且几乎能完成 interface 所能完成的所有功能,故其在使用的灵活性方面要远超 interface,优先使用 type 也并非不可。更为重要的是,type 不可重复声明,从而避免了同名类型所带来的混淆,因此,在面对较为庞大的类型文件时,type 也不失为更加强大而保险的选择。
API 类型声明时使用 type 的优势:
- 更加灵活的语法(如原始类型、联合类型、枚举类型)。
- type 不可重复声明,避免多个接口使用同一类型时的重复类型声明以及混淆(使用 interface 时,如果重复声明了同名不同质的 interface,则会发生类型合并)。
此外,需要注意的是,虽然 TS 本身支持以下做法:
1 | |
但这样会让 interface A 所发生的类型合并也体现在 type B 上,使 type 不再是“确定的”,因此应尽量避免这种做法。
1 | |
3 类型声明位置
对于以下场景:
1 | |
在引入 TS 时,可以进行类型声明的位置包括:
- 为 printObject 指定类型;
- 为 printFunc 指定类型;
- 为 printFunc 的参数 obj 指定类型,返回值类型则借助类型推断(在这里为 string)。
1 | |
除了提供类型支持,类型声明本身存在语义化作用,不同的声明位置则可能体现了不同的设计思路。以 printFunc 为例,虽然最终效果对外部而言均是声明了函数的入参与出参类型,但位置 2 与位置 3 可能体现了不同的开发场景:
- 位置 2:明确一个函数的功能与输入输出后,再对函数进行实现。
- 位置 3:实现完成一个函数后,再根据实现结果告诉外界函数应该如何使用。
显然,位置 2 所体现的才是相对规范的开发流程,而位置 3 则可能更多出现在为现有代码添加类型支持的情况中。未来开发中,更推荐在函数开发前明确参数类型,并在位置 2 为其进行类型声明,以获得较为清晰的开发体验。此外,减少类型推断也有利于提升 TS 编译器的效率。
而对于 printObject,由于 printFunc 本身已指定了入参类型,所以位置 1 的类型声明看似并非必要(在调用函数时自然会检查传入变量类型是否符合要求),但实际上,是否在位置 1 进行类型声明可能也体现了不同的开发场景:
- 进行类型声明:先定义了 printObject,然后定义了用于输出该对象的函数 printFunc。
- 不进行类型声明:先定义了打印函数 printFunc,然后根据使用场景为函数传入参数 printObject。
这有点类似“先有鸡还是先有蛋”的问题了,过分追究这方面的语义化其实并没有太大意义。而从开发体验而言,不同的类型声明位置会导致不同的报错时机,保持在位置 1 进行类型声明可以让类型错误在定义变量时就暴露出来,而不是等到传入时才发现,因此,坚持为变量进行类型声明可能会更有优势一些(尤其是在使用组件并向其传递 prop 时)。
4 泛型与工具类型
泛型是几乎所有强类型语言都必不可少的特性之一。TS 的泛型使其具备了“高阶类型”的功能——根据一个不确定的类型得到一个新的类型。
1 | |
使用了泛型的类型几乎可以视作“类型处理函数”,就像 String 的 slice()、replace() 等方法一样,而这也是 TS 提供的各种工具类型所做的。此处仅列举部分工具类型,意在揭露它们的存在,为复杂类型的定义提供启发。
- Partial
:原样返回 Type,但是其所有属性都会变为可选。 - Required
:与 Partial 相反。 - Record<Keys, Type>:返回对象类型,key 值均属于 Keys,value 类型则为 Type。
- Pick<Type, Keys>:从 Type 中节选 Keys 所包含的属性,得到新的对象类型。
- Omit<Type, Keys>:从 Type 中除去 Keys 所包含的属性,得到新的对象类型。
- ……
此外,就像 lodash 一样,一些第三方库(比如 type-fest)提供了 TS 原生没有但值得有的基础类型和工具类型,从而避免自行重复定义一些较为复杂的类型。毕竟,TS 本身定位更倾向于作为开发辅助工具,对于大部分非公共项目,可能并不是很有必要对 TS 进行太过深入的钻研,“面相需求学习”往往会更加高效一些。倘若追求对 TS 达到“高精尖”的掌握,可以尝试 type-challenges 等练习题库。