TypeScript进阶

一、高级类型

1.1 联合类型

联合类型(Unions)用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合。
通过“|”操作符分隔类型的语法来表示联合类型。
【理解为JavaScript 中“||”的逻辑,TypeScript倾向于表示类型】
案例:封装 string 或者 number 类型的输入值转换成 '数字 + "px" 格式的函数

function formatPX(size: unknown) {
  if (typeof size === 'number') {
    return `${size}px`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}px`;
  }
  throw Error(` 仅支持 number 或者 string`);
}

formatPX(13);
formatPX('13px');

通过这样的方式带来的问题是,调用 formatPX 时,可以传递任意的值,并且可以通过静态类型检测
但是运行时还是会抛出一个错误:

function formatPX(size: unknown) {
  if (typeof size === 'number') {
    return `${size}px`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}px`;
  }
  throw Error(` 仅支持 number 或者 string`);
}
formatPX(true);
formatPX(null);

这显然不符合我们的预期,因为 size 应该是更明确的,即可能也只可能是 number 或 string 这两种可选类型的类型。
联合类型,更明确表示可能是 number 或 string 的联合类型来注解 size 参数:

function formatPX(size: number | string) {
  if (typeof size === 'number') {
    return `${size}px`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}px`;
  }
  throw Error(` 仅支持 number 或者 string`);
}
formatPX(13); // ok
formatPX('13px'); // ok
formatPX(true); // ts(2345) 'true' 类型不能赋予 'number | string' 类型
formatPX(null); // ts(2345) 'null' 类型不能赋予 'number | string' 类型

当然也可以组合任意个、任意类型来构造更满足我们诉求的类型。
比如,添加一个 unit 参数表示可能单位,可以声明一个字符串字面类型组成的联合类型:

function formatUnit(size: number | string, unit: 'px' | 'em' | 'rem' | '%' = 'px') {
  if (typeof size === 'number') {
    return `${size}${unit}`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}${unit}`;
  }
}
-------------------------------------------------------------------------------
注:unit: 'px' | 'em' | 'rem' | '%' = 'px'含义表示为
unit: 联合类型 'px' | 'em' | 'rem' | '%' 四个选项,若没有,默认'px'
------------------------------------------------------------------------------
function formatUnit(size: number | string, unit: 'px' | 'em' | 'rem' | '%' = 'px') {
  if (typeof size === 'number') {
    return `${size}${unit}`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}${unit}`;
  }
}
formatUnit(1, 'em'); // ok
formatUnit('1px', 'rem'); // ok
formatUnit('1px', 'bem'); // ts(2345)

我们定义了 formatPX 函数的第二个参数 unit,
它的类型是由 'px'、'em'、'rem'、'%' 字符串字面类型组成的类型集合。
我们传入一个不在类型集合中的字符串字面量 'bem' ,就会提示一个 ts(2345) 错误。
我们也可以使用类型别名抽离上边的联合类型,然后再将其进一步地联合:

type ModernUnit = 'vh' | 'vw';
type Unit = 'px' | 'em' | 'rem';
type MessedUp = ModernUnit | Unit;

这里我们定义了 ModernUnit 别名表示 'vh' 和 'vw' 这两个字面量类型的组合,且定义了 Unit 别名表示 'px' 和 'em' 和 'rem' 字面量类型组合,同时又定义了 MessedUp 别名表示 ModernUnit 和 Unit 两个类型别名的组合。

我们也可以把接口类型联合起来表示更复杂的结构:

interface Bird {
  fly(): void;
  layEggs(): void;
}
interface Fish {
  swim(): void;
  layEggs(): void;
}
const getPet: () => Bird | Fish = () => {
  return {
   layEggs : () => {
     console.log('fly')
   }
  } as Bird | Fish;
};
const Pet = getPet();
Pet.layEggs(); // ok
Pet.fly(); // ts(2339) 'Fish' 没有 'fly' 属性; 'Bird | Fish' 没有 'fly' 属性

在联合类型中,我们可以直接访问各个接口成员都拥有的属性、方法,且不会提示类型错误。
如果是个别成员特有的属性、方法,我们就需要区分对待了,此时又要引入类型守卫(详见第三节)来区分不同的成员类型。
需要使用基于 in 操作符判断的类型守卫:

interface Bird {
  fly(): void;
  layEggs(): void;
}
interface Fish {
  swim(): void;
  layEggs(): void;
}
const getPet: () => Bird | Fish = () => {
  return {
   layEggs : () => {
     console.log('fly')
   },
   fly: () => {
     console.log('fly')
   }
  } as Bird | Fish;
};
const Pet = getPet();
//   if (typeof Pet.fly === 'function') { // ts(2339)
//     Pet.fly(); // ts(2339)
//   }
if ('fly' in Pet) {
  Pet.fly(); // ok
}

Pet 的类型既可能是 Bird 也可能是 Fish,
意味着Pet会通过 Fish 类型获取 fly 属性,但 Fish 类型没有 fly 属性定义,所以会提示一个 ts(2339) 错误。

1.2 交叉类型

类比JavaScript'“||”, TypeScript存在“&&” 类比类型
在 TypeScript 中,存在多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性
——交叉类型(Intersection Type)
在 TypeScript 中,我们可以使用“&”操作符来声明交叉类型:

type Useless = string & number

合并接口类型
联合类型多应用于将多个接口类型合并成一个类型,从而实现等同接口继承的效果:

type IntersectionType = { 
  id: number; 
  name: string; 
} & { age: number };
const mixed: IntersectionType = {
  id: 1,
  name: 'name',
  age: 18
}

通过交叉类型,使得 IntersectionType 同时拥有了 id、name、age 所有属性,这里我们可以试着将合并接口类型理解为求并集。
问题:如果合并的多个接口类型存在同名属性会是什么效果?
此时,我们可以根据同名属性的类型是否兼容(详见 第四节)将这个问题分开来看。
如果同名属性的类型不兼容,比如上面示例中两个接口类型同名的 name 属性类型一个是 number,另一个是 string,合并后,name 属性的类型就是 number 和 string 两个原子类型的交叉类型,即 never,如下代码所示:

type IntersectionTypeConfict = { 
  id: number; 
  name: string; 
} & { 
  age: number; 
  name: number; 
};
const mixedConflict: IntersectionTypeConfict = {
  id: 1,
  name: 2, // ts(2322) 错误,'number' 类型不能赋给 'never' 类型
  age: 2
};

此时,我们赋予 mixedConflict 任意类型的 name 属性值都会提示类型错误。而如果我们不设置 name 属性,又会提示一个缺少必选的 name 属性的错误。在这种情况下,就意味着上述代码中交叉出来的IntersectionTypeConfict 类型是一个无用类型。

若同名属性的类型兼容,比如一个是 number,另一个是 number 的子类型、数字字面量类型,合并后 name 属性的类型就是两者中的子类型:

type IntersectionTypeConfict = { 
  id: number; 
  name: 2; 
} & { 
  age: number; 
  name: number; 
};
let mixedConflict: IntersectionTypeConfict = {
  id: 1,
  name: 2, // ok
  age: 2
};
mixedConflict = {
  id: 1,
  name: 22, // '22' 类型不能赋给 '2' 类型
  age: 2
};

合并联合类型
另外,我们可以合并联合类型为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员。
【可以理解为:合并联合类型理解为求交集】

两个联合类型交叉出来的类型 IntersectionUnion 等价于 'em' | 'rem'
所以我们只能把 'em' 或者 'rem' 字符串赋值给 IntersectionUnion 类型的变量。

type UnionA = 'px' | 'em' | 'rem' | '%';
type UnionB = 'vh' | 'em' | 'rem' | 'pt';
type IntersectionUnion = UnionA & UnionB;
const intersectionA: IntersectionUnion = 'em'; // ok
const intersectionB: IntersectionUnion = 'rem'; // ok
const intersectionC: IntersectionUnion = 'px'; // ts(2322)
const intersectionD: IntersectionUnion = 'pt'; // ts(2322)

既然是求交集,如果多个联合类型中没有相同的类型成员,交叉出来的类型自然就是 never :

type UnionC = 'em' | 'rem';
type UnionD = 'px' | 'pt';
type IntersectionUnionE = UnionC & UnionD;
const intersectionE: IntersectionUnionE = 'any' as any; // ts(2322) 不能赋予 'never' 类型

因为 UnionC 和 UnionD 没有交集,交叉出来的类型 IntersectionUnionE 就是 never
因此不能把任何类型的值赋予 IntersectionUnionE 类型的变量。

1.3 联合、交叉组合

联合、交叉类型本身可以直接组合使用
但会涉及“ | ”、" & " 操作符的优先级问题。【与 JavaScript 的逻辑或 ||、逻辑与 && 运算符上表现一致 】
联合操作符 “|” 的优先级低于交叉操作符“ &”【通过 () 来调整操作符的优先级】

type UnionIntersectionA = { id: number; } 
  & { name: string; } 
  | { id: string; } 
  & { name: number; }; // 交叉操作符优先级高于联合操作符
----------------------------------------------------------
等同于
type UnionIntersectionA = ({
    id: number;
} & {
    name: string;
}) | ({
    id: string;
} & {
    name: number;
})

type UnionIntersectionB = ('px' | 'em' | 'rem' | '%') 
  | ('vh' | 'em' | 'rem' | 'pt'); // 调整优先级

进而,我们也可以把分配率、交换律等基本规则引入类型组合中,然后优化出更简洁、清晰的类型:

type UnionIntersectionC = ({ id: number; } 
& { name: string; } 
| { id: string; }) 
& { name: number;};
---------------------------------------------
等同于
type UnionIntersectionC = (({
    id: number;
} & {
    name: string;
}) | {
    id: string;
}) & {
    name: number;
}

类型缩减
如果将 string 原始类型和“string字面量类型”组合成联合类型会是什么效果:

type URStr = 'string' | string; // 类型是 string
type URNum = 2 | number; // 类型是 number
type URBoolen = true | boolean; // 类型是 boolean
enum EnumUR {
  ONE,
  TWO
}
type URE = EnumUR.ONE | EnumUR; // 类型是 EnumUR

TypeScript 对这样的场景做了缩减,它把字面量类型、枚举成员类型缩减掉,只保留原始类型、枚举类型等父类型,这是合理的“优化”。
可是这个缩减,却极大地削弱了 IDE 自动提示的能力:

type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string; // 类型缩减成 string

在上述代码中,我们希望 IDE 能自动提示显示注解的字符串字面量,但是因为类型被缩减成 string,所有的字符串字面量 black、red 等都无法自动提示出来了。
TypeScript 官方其实还提供了一个黑魔法,它可以让类型缩减被控制,只需要给父类型添加“& {}”即可。

type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {}; // 字面类型都被保留

其他字面量类型就不会被缩减掉了,在 IDE 中字符串字面量 black、red 等也就自然地可以自动提示出来了。

当联合类型的成员是接口类型,如果满足其中一个接口的属性是另外一个接口属性的子集,这个属性也会类型缩减:

type UnionInterce = {
  age: '1';
} | ({
  age: '1' | '2';
  [key: string]: string;
});

这里因为 '1' 是 '1' | '2' 的子集,所以 age 的属性变成 '1' | '2'。

利用这个特性,可以解决如下问题:
如何定义如下所示 age 属性是数字类型,而其他不确定的属性是字符串类型的数据结构的对象?

{
 age: 1, // 数字类型
 anyProperty: 'str', // 其他不确定的属性都是字符串类型
 ...
}

用到两个接口的联合类型及类型缩减,这个问题的核心在于找到一个既是 number 的子类型,这样 age 类型缩减之后的类型就是 number;同时也是 string 的子类型,这样才能满足属性和 string 索引类型的约束关系。
那么可以适用never类型来描述此类型
【never 有一个特性是它是所有类型的子类型,自然也是 number 和 string 的子类型】

 type UnionInterce = {
   age: number;
 } | ({
   age: never;
   [key: string]: string;
 });
 const O: UnionInterce = {
   age: 2,
   name: 'string'
 };

在上述代码中,age 属性的类型是由 number 和 never 类型组成的联合类型,
所以我们可以把 number 类型的值赋予 age 属性;
但是不能把其他任何类型的值(比如说字符串字面量 'string' )赋予 age。

二、泛型

2.1 什么是泛型

借用 Java 中泛型的释义回答:
泛型指的是类型参数化,即将原来某种具体的类型进行参数化。
和定义函数参数一样,我们可以给泛型定义若干个类型参数,并在调用时给泛型传入明确的类型参数。
设计泛型的目的在于有效约束类型成员之间的关系,比如函数参数和返回值、类或者接口成员和方法之间的关系。
【泛型就是定义动态的类型】

2.2 泛型类型参数

泛型最常用的场景是用来约束函数参数的类型,我们可以给函数定义若干个被调用时才会传入明确类型的参数。

比如定义一个 reflect 函数 ,可以接收一个任意类型的参数,并原封不动地返回参数的值和类型
那我们该如何描述这个函数呢?【不适用any情况下只能使用unknown】

function reflect(param: unknown) {
  return param;
}
const str = reflect('string'); // str 类型是 unknown
const num = reflect(1); // num 类型 unknown

reflect 函数虽然可以接收一个任意类型的参数并原封不动地返回参数的值,不过返回值类型不符合我们的预期。
我们希望返回值类型与入参类型一一对应(比如 number 对 number、string 对 string)

泛型正好可以满足这样的诉求,那如何定义一个泛型参数呢?
首先,我们把参数 param 的类型定义为一个(类型层面的)参数、变量,而不是一个明确的类型,等到函数调用时再传入明确的类型。
可以通过尖括号 <> 语法给函数定义一个泛型参数 P,并指定 param 参数的类型为 P :

function reflect<P>(param: P) {
  return param;
}

尖括号中的 P 表示泛型参数的定义,param 后的 P 表示参数的类型是泛型 P(即类型受 P 约束)。
也可以使用泛型显式地注解返回值的类型,虽然没有这个必要(因为返回值的类型可以基于上下文推断出来)。
比如调用如下所示的 reflect 时,我们可以通过尖括号 <> 语法给泛型参数 P 显式地传入一个明确的类型。

function reflect<P>(param: P):P {
  return param;
}

在调用函数时,我们也通过 <> 语法指定了如下所示的 string、number 类型入参,相应地,reflectStr 的类型是 string,reflectNum 的类型是 number。

const reflectStr = reflect<string>('string'); // str 类型是 string
const reflectNum = reflect<number>(1); // num 类型 number

如果调用泛型函数时受泛型约束的参数有传值,泛型参数的入参可以从参数的类型中进行推断,而无须再显式指定类型(可忽略),因此上边的示例可以简写:

const reflectStr2 = reflect('string'); // str 类型是 string
const reflectNum2 = reflect(1); // num 类型 number

泛型不仅可以约束函数整个参数的类型,还可以约束参数属性、成员的类型。
比如参数的类型可以是数组、对象:

function reflectArray<P>(param: P[]) {
  return param;
}
const reflectArr = reflectArray([1, '1']); // reflectArr 是 (string | number)[]

这里我们约束了 param 的类型是数组,数组的元素类型是泛型入参。
通过泛型,我们可以约束函数参数和返回值的类型关系。
:函数的泛型入参必须和参数/参数成员建立有效的约束关系才有实际意义。
比如在下面示例中,仅定义了一个仅约束返回值类型的泛型,是没有任何意义的。

function uselessGenerics<P>(): P {
  return void 0 as unknown as P;
}

我们可以给函数定义任何个数的泛型入参:

function reflectExtraParams<P, Q>(p1: P, p2: Q): [P, Q] {
  return [p1, p2];
}

定义了一个拥有两个泛型入参(P 和 Q)的函数 reflectExtraParams,并通过 P 和 Q 约束函数参数 p1、p2 和返回值的类型。

2.3 泛型类

在类的定义中,我们还可以使用泛型用来约束构造函数、属性、方法的类型:

class Memory<S> {
  store: S;
  constructor(store: S) {
    this.store = store;
  }
  set(store: S) {
    this.store = store;
  }
  get() {
    return this.store;
  }
}
const numMemory = new Memory<number>(1); // <number> 可忽略
const getNumMemory = numMemory.get(); // 类型是 number
numMemory.set(2); // 只能写入 number 类型
const strMemory = new Memory<string>(''); // <string>可忽略
const getStrMemory = strMemory.get(); // 类型是 string
strMemory.set('string'); // 只能写入 string 类型

首先,我们定义了一个支持读写类 Memory,并使用泛型约束了 Memory 类的构造器函数、set 和 get 方法形参的类型,最后实例化了泛型入参分别是 number 和 string 类型的两种Memory类。

泛型类泛型函数类似的地方在于,在创建类实例时,如果受泛型约束的参数传入了明确值,则泛型入参(确切地说是传入的类型)可忽略

2.4 泛型类型

在 TypeScript 中,类型本身就可以被定义为拥有不明确的类型参数的泛型,并且可以接收明确类型作为入参,从而衍生出更具体的类型:

const reflect = <P>(param: P): P => param;
const reflectFn: <P>(param: P) => P = reflect; // ok

这里我们为变量 reflectFn 显式添加了泛型类型注解,并将 reflect 函数作为值赋给了它。
我们也可以把 reflectFn 的类型注解提取为一个能被复用的类型别名或者接口:

type ReflectFuncton = <P>(param: P) => P;
interface IReflectFuncton {
  <P>(param: P): P
}
const reflectFn2: ReflectFuncton = reflect;
const reflectFn3: IReflectFuncton = reflect;

将类型入参的定义移动到类型别名或接口名称后,此时定义的一个接收具体类型入参后返回一个新类型的类型就是泛型类型。

如下示例中,我们定义了两个可以接收入参 P 的泛型类型:
(GenericReflectFunction 和IGenericReflectFunction )

type GenericReflectFunction<P> = (param: P) => P;
interface IGenericReflectFunction<P> {
  (param: P): P;
}
const reflectFn4: GenericReflectFunction<string> = reflect; // 具象化泛型
const reflectFn5: IGenericReflectFunction<number> = reflect; // 具象化泛型
const reflectFn3Return = reflectFn4('string'); // 入参和返回值都必须是 string 类型
const reflectFn4Return = reflectFn5(1); //  入参和返回值都必须是 number 类型

泛型定义中,我们甚至可以使用一些类型操作符进行运算表达,使得泛型可以根据入参的类型衍生出各异的类型:

type StringOrNumberArray<E> = E extends string | number ? E[] : E;
type StringArray = StringOrNumberArray<string>; // 类型是 string[]
type NumberArray = StringOrNumberArray<number>; // 类型是 number[]
type NeverGot = StringOrNumberArray<boolean>; // 类型是 boolean

这里定义了一个泛型,如果入参是 number | string 就会生成一个数组类型,否则就生成入参类型。
【可通过 JavaScript 三元表达式完来表达类型运算的逻辑关系】

问题:如果我们给上面这个泛型传入了一个 string | boolean 联合类型作为入参,将会得到什么类型呢?

type StrOrBoolean = string | boolean;
type StringOrNumberArray<E> = E extends string | number ? E[] : E;

type WhatIsThis = StringOrNumberArray<StrOrBoolean>; // boolean | string[]
type StrOrBooleanGot = StrOrBoolean extends string | number ? StrOrBoolean[] : StrOrBoolean; //  string | boolean

WhatIsThis类型不是 boolean | string 而是 boolean | string[]
这个就涉及到分配条件类型(Distributive Conditional Types)。
官方的释义:在条件类型判断的情况下(比如上边示例中出现的 extends)
如果入参是联合类型,则会被拆解成一个个独立的(原子)类型(成员)进行类型运算。

比如上边示例中的 string | boolean 入参,先被拆解成 string 和 boolean 这两个独立类型,再分别判断是否是 string | number 类型的子集。因为 string 是子集而 boolean 不是,所以最终我们得到的 WhatIsThis 的类型是 boolean | string[]。
能接受入参的泛型类型和函数一样,都可以对入参类型进行计算并返回新的类型,像是在做类型运算。

利用泛型,我们可以抽象封装出很多有用、复杂的类型约束。
比如在 Redux Model 中约束 State 和 Reducers 的类型定义关系,我们可以通过如下所示代码定义了一个既能接受 State 类型入参,又包含 state 和 reducers 这两个属性的接口类型泛型,并通过 State 入参约束了泛型的 state 属性和 reducers 属性下 action 索引属性的类型关系。

interface ReduxModel<State> {
  state: State,
  reducers: {
    [action: string]: (state: State, action: any) => State
  }
}

然后根据实际需要,我们传入了一个具体的 State 类型具象化 ReduxModel,并约束了一个实际的 model:

type ModelInterface = { id: number; name: string };
const model: ReduxModel<ModelInterface> = {
  state: { id: 1, name: 'test' }, //  ok 类型必须是 ModelInterface
  reducers: {
    setId: (state, action: { payload: number }) => ({
      ...state,
      id: action.payload // ok must be number
    }),
    setName: (state, action: { payload: string }) => ({
      ...state,
      name: action.payload // ok must be string
    })
  }
}

在上述示例中,model 对象的 state 属性、reducers 属性的 setId、setName 方法的第一个参数 state 的类型都受到 ReduxModel 泛型入参 ModelInterface 的约束。
:枚举类型不支持泛型。

2.5 泛型约束

前面提到了泛型就像是类型的函数,它可以抽象、封装并接收(类型)入参,而泛型的入参也拥有类似函数入参的特性。因此,我们可以把泛型入参限定在一个相对更明确的集合内,以便对入参进行约束。

比如最前边提到的原封不动返回参数的 reflect 函数,我们希望把接收参数的类型限定在几种原始类型的集合中,此时就可以使用“泛型入参名 extends 类型”语法达到这个目的:

function reflectSpecified<P extends number | string | boolean>(param: P):P {
  return param;
}
reflectSpecified('string'); // ok
reflectSpecified(1); // ok
reflectSpecified(true); // ok
reflectSpecified(null); // ts(2345) 'null' 不能赋予类型 'number | string | boolean'

在上述示例中,我们限定了泛型入参只能是 number | string | boolean 的子集。
同样,我们也可以把接口泛型入参约束在特定的范围内:

interface ReduxModelSpecified<State extends { id: number; name: string }> {
  state: State
}
type ComputedReduxModel1 = ReduxModelSpecified<{ id: number; name: string; }>; // ok
type ComputedReduxModel2 = ReduxModelSpecified<{ id: number; name: string; age: number; }>; // ok
type ComputedReduxModel3 = ReduxModelSpecified<{ id: string; name: number; }>; // ts(2344)
type ComputedReduxModel4 = ReduxModelSpecified<{ id: number;}>; // ts(2344)

在上述示例中,ReduxModelSpecified 泛型仅接收 { id: number; name: string } 接口类型的子类型作为入参。
我们还可以在多个不同的泛型入参之间设置约束关系:

interface ObjSetter {
  <O extends {}, K extends keyof O, V extends O[K]>(obj: O, key: K, value: V): V; 
}
const setValueOfObj: ObjSetter = (obj, key, value) => (obj[key] = value);
setValueOfObj({ id: 1, name: 'name' }, 'id', 2); // ok
setValueOfObj({ id: 1, name: 'name' }, 'name', 'new name'); // ok
setValueOfObj({ id: 1, name: 'name' }, 'age', 2); // ts(2345)
setValueOfObj({ id: 1, name: 'name' }, 'id', '2'); // ts(2345)

在设置对象属性值的函数类型时,它拥有 3 个泛型入参:
第 1 个是对象,第 2 个是第 1 个入参属性名集合的子集,第 3 个是指定属性类型的子类型
另外,泛型入参与函数入参还有一个相似的地方在于,它也可以给泛型入参指定默认值(默认类型),且语法和指定函数默认参数完全一致:

interface ReduxModelSpecified2<State = { id: number; name: string }> {
  state: State
}
type ComputedReduxModel5 = ReduxModelSpecified2; // ok
type ComputedReduxModel6 = ReduxModelSpecified2<{ id: number; name: string; }>; // ok
type ComputedReduxModel7 = ReduxModelSpecified; // ts(2314) 缺少一个类型参数

在上述示例中,我们定义了入参有默认类型的泛型 ReduxModelSpecified2,因此使用 ReduxModelSpecified2 时类型入参可缺省。而 ReduxModelSpecified 的入参没有默认值,所以缺省入参时会提示一个类型错误。
泛型入参的约束与默认值还可以组合使用:

interface ReduxModelMixed<State extends {} = { id: number; name: string }> {
  state: State
}

这里我们限定了泛型 ReduxModelMixed 入参 State 必须是 {} 类型的子类型,同时也指定了入参缺省时的默认类型是接口类型 { id: number; name: string; }。

三、类型守卫

3.1 什么是类型守卫

JavaScript 作为一种动态语言,意味着其中的参数、值可以是多态(多种类型)。因此,我们需要区别对待每一种状态,以此确保对参数、值的操作合法。

举一个常见的场景为例,如下我们定义了一个可以接收字符串或者字符串数组的参数 toUpperCase,并将参数转成大写格式输出的函数 convertToUpperCase。

const convertToUpperCase = (strOrArray) => {
  if (typeof strOrArray === 'string') {
    return strOrArray.toUpperCase();
  } else if (Array.isArray(strOrArray)) {
    return strOrArray.map(item => item.toUpperCase());
  }
}

分别使用了 typeof、Array.isArray 确保字符串和字符串数组类型的入参在运行时分别进入正确的分支,而不至于入参是数组类型时,调用数组类型并不存在的 toUpperCase 方法,从而抛出一个“strOrArray.toUpperCase is not a function”的错误。

在 TypeScript 中,因为受静态类型检测约束,所以在编码阶段我们必须使用类似的手段确保当前的数据类型支持相应的操作。当然,前提条件是已经显式地注解了类型的多态。

比如如果我们将上边示例中的 convertToUpperCase 函数使用 TypeScript 实现,那么就需要显示地标明 strOrArray 的类型就是 string 和 string[] 类型组成的联合类型:

const convertToUpperCase = (strOrArray: string | string[]) => {
  if (typeof strOrArray === 'string') {
    return strOrArray.toUpperCase();
  } else if (Array.isArray(strOrArray)) {
    return strOrArray.map(item => item.toUpperCase());
  }
}

在示例中,convertToUpperCase 函数的主体逻辑与 JavaScript 中的逻辑完全一致
在 TypeScript 中, typeof、Array.isArray 条件判断,除了可以保证转译为 JavaScript 运行后类型是正确的

很明显,入参 strOrArray 的类型因为 typeof 条件判断变成了 string,入参 strOrArray 的类型因为 Array.isArray 变成了 string[],所以没有提示类型错误。而这个类型变化就是 类型缩小,这里的 typeof、Array.isArray 条件判断就是类型守卫。
从示例中,我们可以看到类型守卫的作用在于触发类型缩小。实际上,它还可以用来区分类型集合中的不同成员。
类型集合一般包括联合类型和枚举类型,下面我们看看如何区分联合类型。

3.2 如何区分联合类型

首先,我们看一下如何使用类型守卫来区分联合类型的不同成员,常用的类型守卫包括switch、字面量恒等、typeof、instanceof、in 和自定义类型守卫这几种。

1) switch

我们往往会使用 switch 类型守卫来处理联合类型中成员或者成员属性可枚举的场景,即字面量值的集合:

const convert = (c: 'a' | 1) => {
  switch (c) {
    case 1:
      return c.toFixed(); // c is 1
    case 'a':
      return c.toLowerCase(); // c is 'a'
  }
}
const feat = (c: { animal: 'panda'; name: 'China' } | { feat: 'video'; name: 'Japan' }) => {
  switch (c.name) {
    case 'China':
      return c.animal; // c is "{ animal: 'panda'; name: 'China' }"
    case 'Japan':
      return c.feat; // c is "{ feat: 'video'; name: 'Japan' }"
  }
};

在上述示例中,因为 convert 函数的参数及 feat 函数参数的 name 属性都是一个可被枚举的集合,所以我们可以使用 switch 来缩小类型。
比如 c 的类型被缩小为数字 1,c 被缩小为字符串 'Japan',c 也被缩小为相应的接口类型。因此,我们对参数 c 进行相关操作时,也就不会提示类型错误了。

2) 字面量恒等

switch 适用的场景往往也可以直接使用字面量恒等比较进行替换,比如前边的 convert 函数可以改造:

const convert = (c: 'a' | 1) => {
  if (c === 1) {
    return c.toFixed(); // c is 1
  } else if (c === 'a') {
    return c.toLowerCase(); // c is 'a'
  }
}

在以上示例中,类型相应都缩小为了字面量 1 和 'a'。
【如果可枚举的值和条件分支越多,那么使用 switch 就会让代码逻辑更简洁、更清晰;反之,则推荐使用字面量恒等进行判断】

3) typeof

反过来,当联合类型的成员不可枚举,比如说是字符串、数字等原子类型组成的集合,就需要使用 typeof。
typeof 是一个比较特殊的操作符(15 讲中会再详细地介绍它),我们可以使用它对 convert 函数进行改造:

const convert = (c: 'a' | 1) => {
  if (typeof c === 'number') {
    return c.toFixed(); // c is 1
  } else if (typeof c === 'string') {
    return c.toLowerCase(); // c is 'a'
  }
}

在上述示例中,因为 typeof c 表达式的返回值类型是字面量联合类型 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function',所以通过字面量恒等判断我们的 typeof c 表达式值类型进行了缩小,进而将 c 的类型缩小为明确的 string、number 等原子类型。

4) instanceof

此外,联合类型的成员还可以是类:

class Dog {
  wang = 'wangwang';
}
class Cat {
  miao = 'miaomiao';
}
const getName = (animal: Dog | Cat) => {
  if (animal instanceof Dog) {
    return animal.wang;
  } else if (animal instanceof Cat) {
    return animal.miao;
  }
}

这里我们可以看到,animal 的类型也缩小为 Dog、Cat 了

5) in

当联合类型的成员包含接口类型(对象),并且接口之间的属性不同,如下示例中的接口类型 Dog、Cat,我们不能直接通过“ . ”操作符获取 param 的 wang、miao 属性,从而区分它是 Dog 还是 Cat。

interface Dog {
  wang: string;
}
interface Cat {
  miao: string;
}
const getName = (animal: Dog | Cat) => {
  if (typeof animal.wang == 'string') { // ts(2339)
    return animal.wang; // ts(2339)
  } else if (animal.miao) { // ts(2339)
    return animal.miao; // ts(2339)
  }
}

这里我们看到,都提示了一个 ts(2339) Dog | Cat 联合类型没有 wang、miao 属性的错误。
这个时候我们就需要使用 in 操作符来改造一下 getName 函数, 这样就不会提示类型错误了:

const getName = (animal: Dog | Cat) => {
    if ('wang' in animal) { // ok
      return animal.wang; // ok
    } else if ('miao' in animal) { // ok
      return animal.miao; // ok
    }
  }

这里我们可以看到, animal 的类型也缩小成 Dog 和 Cat 了。

6) 自定义类型守卫

这时我们将使用 类型谓词 is,比如封装一个 isDog 函数来区分 Dog 和 Cat,如下代码所示:

const isDog = function (animal: Dog | Cat): animal is Dog {
  return 'wang' in animal;
}
const getName = (animal: Dog | Cat) => {
  if (isDog(animal)) {
    return animal.wang;
  }
}

这里我们在 getName 函数条件判断中使用了 isDog 将 animal 的类型缩小为 Dog,这样就可以直接获取 wang 属性了,而不会提示一个 ts(2339) 的错误。

3.3 如何区别枚举类型

枚举类型是命名常量的集合,所以我们也需要使用类型守卫区分枚举类型的成员。
先回想一下枚举类型的若干特性,因为这将决定使用哪几种类型守卫来区分枚举既是可行的,又是安全的。
特性 1:枚举和其他任何枚举、类型都不可比较,除了数字枚举可以与数字类型比较之外;
特性 2:数字枚举极其不稳定。
结论:最佳实践时,我们永远不要拿枚举和除了自身之外的任何枚举、类型进行比较。

enum A {
  one,
  two
}
enum B {
  one,
  two
}
const cpWithNumber = (param: A) => {
  if (param === 1) { // bad
    return param;
  }
}
const cpWithOtherEnum = (param: A) => {
  if (param === B.two as unknown as A) { // ALERT bad
    return param;
  }
}
const cpWithSelf = (param: A) => {
  if (param === A.two) { // good
    return param;
  }
}

在函数 cpWithNumber 中,将类型是枚举 A 的入参 param 和数字字面量 1 进行比较,因为 A 是数字枚举,所以 param 可以和 1 进行比较,而不会提示一个 ts(2367) 条件判断恒为 false 的错误。

因为数字枚举不稳定,所以默认情况下 A.two 的值会是 1,因此判断在入参为 A.two 的时候为真。但是,如果我们给枚举 A 的成员 one 指定初始值 1,判断在入参为 A.two 的时候就为否了,因为 A.two 值变成了 2,所以这不是一个安全的实践。
在调用函数 cpWithNumber 的时候,我们使用数字类型做入参也是一种不安全的实践,原因同上。

示例中函数 cpWithOtherEnum,我们使用了双重类型断言将枚举类型 B 转换为 A,主要是为了避免提示一个 ts(2367) 错误,所以这同样也是一种不安全的实践。因为一旦 A 和 B 的结构出现了任何差异(比如给成员指定了不同的初始值、改变了成员的顺序或者个数),都会导致条件判断逻辑时真时否。

注意:有时候我们确实避免不了像示例中这样使用双重类型断言来绕过 TypeScript 静态类型检测,比如使用基于同一个 Swagger 定义自动生成的两个枚举类型。此时,我们就需要极其谨慎,而且还需要添加警示信息进行说明,比如添加的 "ALERT" 注释。
最安全的实践是使用区分枚举成员的判断方式。
【同样适用于使用其他类型守卫(例如 switch)来区分枚举成员的场景】
注:你应该还记得字面量成员枚举可等价为字面量成员类型组成的联合类型,所以类型守卫可以让字面量成员枚举发生类型缩小。比如 param 的类型是 A.two。

3.4 失效的类型守卫

失效的类型守卫指的是某些类型守卫应用在泛型函数中时不能缩小类型,即失效了。比如我们改造了一个可以接受泛型入参的 getName 函数,如下代码所示:

const getName = <T extends Dog | Cat>(animal: T) => {
  if ('wang' in animal) {
    return animal.wang; // ts(2339)
  }
  return animal.miao; // ts(2339)
};

在上述示例中,虽然我们使用了 in 类型守卫,但是它并没有让 animal 的类型如预期那样缩小为 Dog 的子类型,所以 T 类型上没有 wang 属性,从而提示一个 ts(2339) 的错误。所以 animal 也不会缩小为 Cat 的子类型,从而也会提示一个 ts(2339) 的错误。

可一旦我们把 in 操作换成自定义类型守卫 isDog 或者使用 instanceOf,animal 的类型就会缩小成了 Dog 的子类型(T & Dog),所以不会提示 ts(2339) 的错误。由此可见,in 和 instanceOf、类型谓词在泛型类型缩小上是有区别的。

const getName = <T extends Dog | Cat>(animal: T) => {
  if (isDog(animal)) { // instanceOf 亦可
    return animal.wang; // ok
  }
  return animal.miao; // ts(2339)
};

这个时候,就需要使用类型断言:

const getName = <T extends Dog | Cat>(animal: T) => {
  if (isDog(animal)) { // instanceOf 亦可
    return animal.wang; // ok
  }
  return (animal as Cat).miao; // ts(2339)
};

我们把 animal 的类型断言为 Cat,并获取了它的 miao 属性

四、类型兼容

TypeScript 中类型的兼容性都是基于结构化子类型的一般原则进行判定的。

4.1 子类型

从子类型的角度来看,所有的子类型与它的父类型都兼容,如下代码所示:

const one = 1;
let num: number = one; // ok
interface IPar {
  name: string;
}
interface IChild extends IPar {
  id: number;
}
let Par: IPar;
let Child: IChild;
Par = Child; // ok
class CPar {
  cname = '';
}
class CChild extends CPar {
  cid = 1;
}
let ParInst: CPar;
let ChildInst: CChild;
ParInst = ChildInst; // ok
let mixedNum: 1 | 2 | 3 = one; // ok

在示例中我们可以把类型是数字字面量类型的 one 赋值给数字类型的 num。
其中可以把子接口类型的变量赋值给 Par。
也可以把子类实例 ChildInst 赋值给 ParInst。
因为成员类型兼容它所属的类型集合(其实联合类型和枚举都算类型集合,这里主要说的是联合类型)
我们可以把 one 赋值给包含字面类型 1 的联合类型。
举一反三,由子类型组成的联合类型也可以兼容它们父类型组成的联合类型:

let ICPar: IPar | CPar;
let ICChild: IChild | CChild;
ICPar = ICChild; // ok

因为 IChild 是 IPar 的子类,CChild 是 CPar 的子类,所以 IChild | CChild 也是 IPar | CPar 的子类,进而 ICChild 可以赋值给 ICPar。

4.2 结构类型

类型兼容性的另一准则是结构类型,即如果两个类型的结构一致,则它们是互相兼容的。比如拥有相同类型的属性、方法的接口类型或类,则可以互相赋值:

class C1 {
  name = '1';
}
class C2 {
  name = '2';
}
interface I1 {
  name: string;
}
interface I2 {
  name: string;
}
let InstC1: C1;
let InstC2: C2;
let O1: I1;
let O2: I2;
InstC1 = InstC2; // ok
O1 = O2; // ok
InstC1 = O1; // ok
O2 = InstC2; // ok

因为类 C1、类 C2、接口类型 I1、接口类型 I2 的结构完全一致,所以我们可以把类 C2 的实例 InstC2 赋值给类 C1 的实例 Inst1,把接口类型 I2 的变量 O2 赋值给接口类型 I1 的变量 O1。

我们甚至可以把接口类型 I1 的变量 O1 赋值给类 C1 的实例,类 C2 的实例赋值给接口类型 I2 的变量 O2。

另外一个特殊的场景:两个接口类型或者类,如果其中一个类型不仅拥有另外一个类型全部的属性和方法,还包含其他的属性和方法(如同继承自另外一个类型的子类一样),那么前者是可以兼容后者的:

interface I1 {
  name: string;
}
interface I2 {
  id: number;
  name: string;
}
class C2 {
  id = 1;
  name = '1';
}
let O1: I1;
let O2: I2;
let InstC2: C2;
O1 = O2;
O1 = InstC2;

在示例中我们可以把类 C2 的实例 InstC2 和接口类型 I2 的变量 O2 赋值给接口类型 I1 的变量 O1,这是因为类 C2、接口类型 I2 和接口类型 I1 的 name 属性都是 string。不过,因为变量 O2、类 C2 都包含了额外的属性 id,所以我们不能把变量 O1 赋值给实例 InstC2、变量 O2。

这里涉及一个需要特别注意的特性:虽然包含多余属性 id 的变量 O2 可以赋值给变量 O1,但是如果我们直接将一个与变量 O2 完全一样结构的对象字面量赋值给变量 O1,则会提示一个 ts(2322) 类型不兼容的错误这就是对象字面的 freshness 特性。

也就是说一个对象字面量没有被变量接收时,它将处于一种 freshness 新鲜的状态。这时 TypeScript 会对对象字面量的赋值操作进行严格的类型检测,只有目标变量的类型与对象字面量的类型完全一致时,对象字面量才可以赋值给目标变量,否则会提示类型错误。

当然,我们也可以通过使用变量接收对象字面量或使用类型断言解除 freshness

O1 = {
  id: 2, // ts(2322)
  name: 'name'
};
let O3 = {
  id: 2,
  name: 'name'
};
O1 = O3; // ok
O1 = {
  id: 2,
  name: 'name'
} as I2; // ok

我们把包含多余属性的类型赋值给了变量 O1,没有提示类型错误。

另外,我们还需要注意类兼容性特性:实际上,在判断两个类是否兼容时,我们可以完全忽略其构造函数及静态属性和方法是否兼容,只需要比较类实例的属性和方法是否兼容即可。如果两个类包含私有、受保护的属性和方法,则仅当这些属性和方法源自同一个类,它们才兼容:

class C1 {
  name = '1';
  private id = 1;
  protected age = 30;
}
class C2 {
  name = '2';
  private id = 1;
  protected age = 30;
}
let InstC1: C1;
let InstC2: C2;
InstC1 = InstC2; // ts(2322)
InstC2 = InstC1; // ts(2322)
}
{
class CPar {
  private id = 1;
  protected age = 30;
}
class C1 extends CPar {
  constructor(inital: string) {
    super();
  }
  name = '1';
  static gender = 'man';
}
class C2 extends CPar {
  constructor(inital: number) {
    super();
  }
  name = '2';
  static gender = 'woman';
}
let InstC1: C1;
let InstC2: C2;
InstC1 = InstC2; // ok
InstC2 = InstC1; // ok

因为类 C1 和类 C2 各自包含私有和受保护的属性,且实例 InstC1 和 InstC2 不能相互赋值,所以提示了一个 ts(2322) 类型的错误。

在因为类 C1、类 C2 的私有、受保护属性都继承自同一个父类 CPar,所以检测类型兼容性时会忽略其类型不相同的构造函数和静态属性 gender,也因此实例 InstC1 和 实例 InstC2 之间可以相互赋值。

4.3 可继承和可实现

类型兼容性还决定了接口类型和类是否可以通过 extends 继承另外一个接口类型或者类,以及类是否可以通过 implements 实现接口。

interface I1 {
  name: number;
}
interface I2 extends I1 { // ts(2430)
  name: string;
}
class C1 {
  name = '1';
  private id = 1;
}
class C2 extends C1 { // ts(2415)
  name = '2';
  private id = 1;
}
class C3 implements I1 {
  name = ''; // ts(2416)
}

接口类型 I1 和接口类型 I2 包含不同类型的 name 属性不兼容,所以接口类型 I2 不能继承接口类型 I1。
同样,因为类 C1 和类 C2 不满足类兼容条件,所以类 C2 也不能继承类 C1。
因为接口类型 I1 和类 C3 包含不同类型的 name 属性,所以类 C3 不能实现接口类型 I1。

4.4 类型入参的泛型

泛型类型、泛型类的兼容性实际指的是将它们实例化为一个确切的类型后的兼容性。
可以通过指定类型入参实例化泛型,且入参只有作为实例化后的类型的一部分时才能影响类型兼容性:

interface I1<T> {
  id: number;
}
let O1: I1<string>;
let O2: I1<number>;
O1 = O2; // ol

因为接口泛型 I1 的入参 T 是无用的,且实例化类型 I1<string> 和 I1<numer> 的结构一致,即类型兼容,所以对应的变量 O2 可以给变量 O1赋值。

而对于未明确指定类型入参泛型的兼容性,例如函数泛型(实际上仅有函数泛型才可以在不需要实例化泛型的情况下赋值),TypeScript 会把 any 类型作为所有未明确指定的入参类型实例化泛型,然后再检测其兼容性:

let fun1 = <T>(p1: T): 1 => 1;
let fun2 = <T>(p2: T): number => 2;
fun2 = fun1; // ok?

实际上相当于在比较函数类型 (p1: any) => 1 和函数类型 (param: any) => number 的兼容性,那么这两个函数的类型兼容。
为什么兼容呢:函数类型兼容性。在此之前,我们先了解一下判定函数类型兼容性的基础理论知识:变型。

4.4.1 变型

TypeScript 中的变型指的是根据类型之间的子类型关系推断基于它们构造的更复杂类型之间的子类型关系。比如根据 Dog 类型是 Animal 类型子类型这样的关系,我们可以推断数组类型 Dog[] 和 Animal[] 、函数类型 () => Dog 和 () => Animal 之间的子类型关系。

在描述类型和基于类型构造的复杂类型之间的关系时,我们可以使用数学中函数的表达方式。比如 Dog 类型,我们可以使用 F(Dog) 表示构造的复杂类型;F(Animal) 表示基于 Animal 构造的复杂类型。
这里的变型描述的就是基于 Dog 和 Animal 之间的子类型关系,从而得出 F(Dog) 和 F(Animal) 之间的子类型关系的一般性质。而这个性质体现为子类型关系可能会被保持、反转、忽略,因此它可以被划分为协变、逆变、双向协变和不变这 4 个专业术语。

接下来我们分别看一下这 4 个专业术语的具体定义。

1)协变

协变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的子类型,这意味着在构造的复杂类型中保持了一致的子类型关系,下面举个简单的例子:

type isChild<Child, Par> = Child extends Par ? true : false;
interface Animal {
  name: string;
}
interface Dog extends Animal {
  woof: () => void;
}
type Covariance<T> = T;
type isCovariant = isChild<Covariance<Dog>, Covariance<Animal>>; // true

我们首先定义了一个用来判断两个类型入参 Child 和 Par 子类型关系的工具类型 isChild,如果 Child 是 Par 的子类型,那么 isChild 会返回布尔字面量类型 true,否则返回 false。

然后我们定义了 Animal 类型和它的子类型 Dog。
我们定义了泛型 Covariant 是一个复杂类型构造器,因为它原封不动返回了类型入参 T,所以对于构造出来的复杂类型 Covariant<Dog> 和 Covariant<Animal> 应该与类型入参 Dog 和 Animal 保持一致的子类型关系。
因为 Covariant<Dog> 是 Covariant<Animal> 的子类型,所以类型 isCovariant 是 true,这就是协变。
实际上接口类型的属性、数组类型、函数返回值的类型都是协变的:

type isPropAssignmentCovariant = isChild<{ type: Dog }, { type: Animal }>; // true
type isArrayElementCovariant = isChild<Dog[], Animal[]>; // true
type isReturnTypeCovariant  = isChild<() => Dog, () => Animal>; // true

isPropAssignmentCovariant、isArrayElementCovariant、isReturnTypeCovariant 类型都是 true,即接口类型 { type: Dog } 是 { type: Animal } 的子类型,数组类型 Dog[] 是 Animal[] 的子类型,函数类型 () => Dog 也是 () => Animal 的子类型。

2)逆变

逆变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的父类型,这与协变正好反过来。
实际场景中,在我们推崇的 TypeScript 严格模式下,函数参数类型是逆变的:

type Contravariance<T> = (param: T) => void;
type isNotContravariance = isChild<Contravariance<Dog>, Contravariance<Animal>>; // false;
type isContravariance = isChild<Contravariance<Animal>, Contravariance<Dog>>; // true;

我们定义了一个基于类型入参构造函数类型的构造器 Contravariance,且类型入参 T 仅约束返回的函数类型参数 param 的类型。因为 TypeScript 严格模式的设定是函数参数类型是逆变的,所以 Contravariance<Animal> 会是 Contravariance<Dog> 的子类型,isNotContravariance 是 false, isContravariance 是 true。

为了更易于理解,我们可以从安全性的角度理解函数参数是逆变的设定。

如果函数参数类型是协变而不是逆变,那么意味着函数类型 (param: Dog) => void 和 (param: Animal) => void 是兼容的,这与 Dog 和 Animal 的兼容一致,所以我们可以用 (param: Dog) => void 代替 (param: Animal) => void 遍历 Animal[] 类型数组。

但是,这样是不安全的,因为它不能确保 Animal[] 数组中的成员都是 Dog(可能混入 Animal 类型的其他子类型,比如 Cat),这就会导致 (param: Dog) => void 类型的函数可能接收到 Cat 类型的入参。

const visitDog = (animal: Dog) => {
  animal.woof();
};
let animals: Animal[] = [{ name: 'Cat', miao: () => void 0, }];
animals.forEach(visitDog); // ts(2345)

在示例中,如果函数参数类型是协变的,就可以通过静态类型检测,而不会提示一个 ts(2345) 类型的错误。这样定义的 visitDog 函数在运行时就能接收到 Dog 类型之外的入参,并调用不存在的 woof 方法,从而在运行时抛出错误。

正是因为函数参数是逆变的,所以使用 visitDog 函数遍历 Animal[] 类型数组时,在提示了类型错误,因此也就不出现 visitDog 接收到一只 cat 的情况。

3)双向协变

双向协变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的子类型,也是父类型,既是协变也是逆变。

对应到实际的场景,在 TypeScript 非严格模式下,函数参数类型就是双向协变的。如前边提到函数只有在参数是逆变的情况下才安全,且本课程一直在强调使用严格模式,所以双向协变并不是一个安全或者有用的特性,因此我们不大可能遇到这样的实际场景。

但在某些资料中有提到,如果函数参数类型是双向协变,那么它是有用的,并进行了举例论证 :

interface Event {
  timestamp: number;
}
interface MouseEvent extends Event {
  x: number;
  y: number;
}
function addEventListener(handler: (n: Event) => void) {}
addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // ts(2769)

在示例中,我们定义了接口 MouseEvent 是定义的接口 Event 的子类型,定义了函数的 handler 参数是函数类型。如果参数类型是双向协变的,那么我们就可以在把参数类型是 Event 子类型(比如说 MouseEvent 的函数)作为入参传给 addEventListener。

这种方式确实方便了很多,但是并不安全,原因见前边 Dog 和 Cat 的示例。而且在严格模式下,参数类型是逆变而不是双向协变的,所以提示了一个 ts(2769) 的错误。

由此可以得出,真正有用且安全的做法是使用泛型,如下所示:

function addEventListener<E extends Event>(handler: (n: E) => void) {}
addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // ok

在示例中的因为我们重新定义了带约束条件泛型入参的 addEventListener,它可以传递任何参数类型是 Event 子类型的函数作为入参,所以在传入参数类型是 MouseEvent 的箭头函数作为入参时,则不会提示类型错误。

4)不变

不变即只要是不完全一样的类型,它们一定是不兼容的。也就是说即便 Dog 是 Animal 的子类型,如果 F(Dog) 不是 F(Animal) 的子类型,那么 F(Animal) 也不是 F(Dog) 的子类型。

对应到实际场景,出于类型安全层面的考虑,在特定情况下我们可能希望数组是不变的(实际上是协变),见示例:

interface Cat extends Animal {
  miao: () => void; 
}
const cat: Cat = {
  name: 'Cat',
  miao: () => void 0,
};
const dog: Dog = {
  name: 'Dog',
  woof: () => void 0,
};
let dogs: Dog[] = [dog];
animals = dogs; // ok
animals.push(cat); // ok
dogs.forEach(visitDog); // 类型 ok,但运行时会抛出错误

在示例中我们定义了一个 Animal 的另外一个子类 Cat。我们分别定义了对象 cat 和对象 dog,并定义了 Dog[] 类型的数组 dogs。

因为数组是协变的,所以我们可以把 dogs 数组赋值给 animals 数组,并且把 cat 对象塞到 animals 数组中。那么问题就来了,因为 animals 和 dogs 指向的是同一个数组,所以实际上我们是把 cat 塞到了 dogs 数组中。

然后,我们在使用了 visitDog 函数遍历 dogs 数组。虽然它可以通过静态类型检测,但是运行时 visitDog 遍历数组将接收一个混入的 cat 对象并抛出错误,因为 visitDog 中调用了 cat 上没有 woof 的方法。

因此,对于可变的数组而言,不变似乎是更安全、合理的设定。不过,在 TypeScript 中可变、不变的数组都是协变的,这是需要我们注意的一个陷阱。

介绍完变型相关的术语以及对应的实际场景,我们已经了解了函数参数类型是逆变的,返回值类型是协变的,所以前面的函数类型 (p1: any) => 1 和 (param: any) => number 为什么兼容的问题已经给出答案了。因为返回值类型 1 是 number 的子类型,且返回值类型是协变的,所以 (p1: any) => 1 是 (param: any) => number 的子类型,即是兼容的。

4.4.2 函数类型兼容性

因为函数类型的兼容性、子类型关系有着更复杂的考量(它还需要结合参数和返回值的类型进行确定),所以下面我们详细介绍一下函数类型兼容性的一般规则。

1)返回值

前边我们已经讲过返回值类型是协变的,所以在参数类型兼容的情况下,函数的子类型关系与返回值子类型关系一致。也就是说返回值类型兼容,则函数兼容。

2)参数类型

前边我们也讲过参数类型是逆变的,所以在参数个数相同、返回值类型兼容的情况下,函数子类型关系与参数子类型关系是反过来的(逆变)。

3)参数个数

在索引位置相同的参数和返回值类型兼容的前提下,函数兼容性取决于参数个数,参数个数少的兼容个数多:

let lessParams = (one: number) => void 0;
let moreParams = (one: number, two: string) => void 0;
lessParams = moreParams; // ts(2322)
moreParams = lessParams; // ok

在示例中,lessParams 参数个数少于 moreParams,lessParams 和 moreParams 兼容,并可以赋值给 moreParams。

注意:如果你觉得参数个数少的函数兼容参数个数多的函数不好理解,那么可以试着从安全性角度理解(是参数少的函数赋值给参数多的函数安全,还是参数多的函数赋值给参数少的函数安全),这里限于篇幅有限就不展开了(你可以作为思考题)。

4)可选和剩余参数

可选参数可以兼容剩余参数、不可选参数:

let optionalParams = (one?: number, tow?: number) => void 0;
let requiredParams = (one: number, tow: number) => void 0;
let restParams = (...args: number[]) => void 0;
requiredParams = optionalParams; // ok
restParams = optionalParams; // ok
optionalParams = restParams; // ts(2322)
optionalParams = requiredParams; // ts(2322)
restParams = requiredParams; // ok
requiredParams = restParams; // ok

我们可以把可选参数 optionalParams 赋值给不可选参数 requiredParams、剩余参数 restParams ,反过来则提示了一个 ts(2322) 的错误。
不可选参数 requiredParams 和剩余参数 restParams 是互相兼容的;从安全性的角度理解是安全的,所以可以赋值。

最让人费解的是,把不可选参数 requiredParams 赋值给剩余参数 restParams 其实是不安全的(但是符合类型检测),我们需要从方便性上理解这个设定。

正是基于这个设定,我们才可以将剩余参数类型函数定义为其他所有参数类型函数的父类型,并用来约束其他类型函数的类型范围,比如说在泛型中约束函数类型入参的范围。

type GetFun<F extends (...args: number[]) => any> = Parameters<F>;
type GetRequiredParams = GetFun<typeof requiredParams>;
type GetRestParams = GetFun<typeof restParams>;
type GetEmptyParams = GetFun<() => void>;

我们使用剩余参数函数类型 (...args: number[]) => any 约束了入参 F 的类型,而传入的函数类型入参都是这个剩余参数函数类型的子类型。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,312评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,578评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,337评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,134评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,161评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,303评论 1 280
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,761评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,421评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,609评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,450评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,504评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,194评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,760评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,836评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,066评论 1 257
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,612评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,178评论 2 341

推荐阅读更多精彩内容