TypeScript:老手也容易迷惑的地方
首先需要说明下,因为 TypeScript 的类型系统最终是服务于 JavaScript 的,所以任何 js 写出来的代码,ts 都必须能声明出对应的类型约束,这就导致 ts 可能会出现非常复杂的类型声明。而一般的强类型语言则没这样的问题,因为在一开始设计之初,那些无法用类型系统声明出来的接口压根就不允许创建。
并且,TypeScript 是结构化类型,有别于名义类型,任何类型是否契合取决于它的结构是否契合,而名义类型则是必须有严格的类型对应。
下面很多内容都是基于上面两点去思考的,下面进入正题。
一、关于枚举
除了正常枚举的声明,在面对不同场景时,枚举声明也会有一些不同。
- 动态枚举
允许在枚举中初始化动态的数值,但字符串不行
// 动态数值
enum A {
Error = Math.random(),
Yes = 3 * 9
// 当带有字符串时则不可以
enum A {
Error = Math.random(), // Error:含字符串值成员的枚举中不允许使用计算值
Yes = 'Yes',
}
- 加 const 前缀
// 枚举加 const 前缀,将会在编译器直接用字符串代替变量引用
const enum NoYes { No='No', Yes='Yes' }
// 编译前:
const a = NoYes.NO
// 编译后:
const a = 'No'
- 作为对象
// 因为 ts 是结构性类型系统,所以枚举也可以作为对象传入,但总感觉怪怪的
enum NoYes {
No = 'No',
Yes = 'Yes',
function func(obj: { No: string }) {
return obj.No;
func(NoYes); // 编译通过
- 数字和字符串枚举的检查宽松度不同
// 数字枚举的宽松检查
enum NoYes { No, Yes }
function func(noYes: NoYes) {}
func(33); // 并不会报类型错误!
// 字符串枚举却报错
enum NoYes { No='No', Yes='Yes' }
function func(noYes: NoYes) {}
func('NO'); // Error: 类型“"NO"”的参数不能赋给类型“NoYes”的参数
之所以允许数字随意赋值给枚举,我猜也是因为允许动态数值枚举的关系。
二、重载为什么不能分开写
ts 中的函数重载:
function foo(p: string);
function foo(p: number);
function foo(p: string | number) { ... };
java中的重载:
public class Overloading {
public int foo(){
System.out.println("test1");
return 1;
public void foo(int a){
System.out.println("test2");
不支持分开写重载的原因是:
- 传统的重载是在编译时将重载函数拆分命名(func 拆分为 func1、func2),再在调用处修改命名,从而达到通过参数区分调用的效果。而 JavaScript 在运行时可以随时修改类型,如果依然采用传统重载的编译规则,可能会导致不可预期的问题。
- ts 与 js 可交互性受影响,如果像传统重载那样,将函数拆分,在 js 脚本里调用 ts 中的重载方法将会有问题。
所以比起传统的重载,ts 的重载更像一个类型注释。
三、为什么要有 any
设想一个场景,一个函数需要接受一个数组,数组内数据可以是任意类型,到这如果想用泛型是没法完美解决的,所以还得引入any,而unkown是后来引入来代替 any 的。但在一般的强类型语言通常不具备那么多灵活性,比如数组只允许一种类型,那就可以通过泛型来解决。
JSON.parse 的类型声明也是 any,因为当初还没有 unkown,不然应该返回 unkown 更合理。
四、图灵完备的类型系统
TypeScript 为了不减弱 JavaScript 的灵活性,同时又能提供足够的类型约束,就带来了图灵完备的类型系统。
下面用类型来实现一个自动声明 N 个长度的数字元组,过程需要用到递归
type ToArr<N, Arr extends number[] = []>
= Arr['length'] extends N // 判断数组长度是否达到
? Arr // 长度够则直接返回
: ToArr<N, [...Arr, number]>; // 长度不够则递归
type Arr1 = ToArr<3>; // [number, number, number]
更进一步,甚至可以基于上面的 ToArr 再实现加法:
type Add<A extends number, B extends number> = [...ToArr<A>, ...ToArr<B>]['length'];
type Res = Add<3, 4>; // 7
甚至有人用 ts 的类型系统实现了象棋规则。
五、readonly 和 as const
readonly 和 as const 都能将类型声明为 仅可读 ,而 as const 还能将类型 转换成常量 。下面再看看两者的一些细节。
interface Foo {
readonly a: {
b: number,
const f: Foo = {
a: { b: 1 },
f.a = { b: 2 }; // Error: 无法分配到 "a" ,因为它是只读属性
f.a.b = 2; // 这里则没问题
上面可以看出,readonly 只对当前对象有效,对其属性无效。但 readonly 对数组却能做到完全不可修改。
const arr: readonly number[] = [2];
arr.push(1); // Error: 类型“readonly number[]”上不存在属性“push”
as const 将数组转化成元组,把一个可变长度的数组声明变成一个固定长度的数组:
const args = [8, 5]; // number[]
const func = (x: number, y: number) => {};
const angle = func(...args); // 这里会提示错误,因为ts不确定args是否有两个数
const args = [8, 5] as const; // 加上as const,将args转换成[number, number]即可
args.push(2) // Error: 类型“readonly [8, 5]”上不存在属性“push”
六、类型约束重置
在回调函数中已收窄的类型约束将被重置,因为该回调可能会在异步代码后调用,里面通过闭包访问的变量有被更改的风险,所以约束重置是合理的。
具体看下面例子:
type MyType = {
prop?: number | string,
function func(arg: MyType) {
if (typeof arg.prop === 'string') {
const a = arg.prop; // string
[].forEach(() => {
// 如果这里是异步回调,下面重写变量将会导致这里的arg改变,所以约束重置是合理的
const b = arg.prop; // string | number | undefined
console.log(b);
(() => {
// 立即执行函数则不会重置类型约束
const d = arg.prop; // string
console.log(d);
})();
// 重写变量
arg = {};
这也是为了应对 js 的灵活性而需要的严谨。
七、协变和逆变
- 协变:子类型兼容父类型,即 Array<Father>.push(Son),这是可以成立的,因为 Son 是 Father的子类型,继承了所有 Father 的属性,所以对其兼容;
- 逆变:父类型兼容子类型,与上面相反,具体看下面例子;
declare let animalFn: (x: Animal) => void;
function walkdog(fn: (x: Dog) => void) {}
walkdog(animalFn); // OK
这里 animalFn 的参数声明需要的是 Dog,但实际传入的是 Animal,上面的本质就是 (x: Dog) => void = (x: Animal) => void ,所以参数是将 Animal 赋值给了 Dog,所以是 Animal 对 Dog 兼容,即逆变,如果将这个场景反过来,反而会出错。所以函数的参数是逆变,返回值是协变。
但 ts 的函数类型其实是双向协变的,但这并不安全,具体看下面例子:
declare let animalFn: (x: Animal) => void
declare let dogFn: (x: Dog) => void
animalFn = dogFn // OK,但这不安全
dogFn = animalFn // OK
// 虽然在 ts 里像上面那样双向赋值(双向协变)是可以通过的,但这是不安全的
const animalSpeak = (fn: AnimalFn) => {
fn(animal);
animalSpeak((x: Dog) => {
x.汪汪() // 这里运行会报错,因为传入的 Animal,不具备 dog.汪汪 方法
上面 animalSpeak 的调用,实际是将 Animal 作为参数赋值给了 Dog,这不满足函数参数逆变的原则,但在 ts 中却是可以通过编译的。
为什么 ts 允许函数双向协变: 因为 ts 是结构化语言,如果 Array(Dog) 可以赋值给 Array(Animal),那么就意味着 Array(Dog).push 可以赋值给 Array(Animal).push ,从而导致设计上就允许了双向协变,这是 ts 设计者为了维持结构化类型兼容的一种取舍。但毕竟双向协变是不安全的,所以在 2.6 版本后,开启严格模式,函数参数协变将会报错。关于双向协变具体可以看下面的例子:
interface Animal { eat: '' }
interface Dog extends Animal { wang: '' }
let animalArr: Animal[] = [];
let dogArr: Dog[] = [];