从集合树的角度理解ts不同类型之间的关系

前言

本文主要关注并解答如下问题:

  • ts类型继承和js的继承是一样的吗?
  • 什么是联合类型,联合类型和联合项之间继承关系是怎样的呢?
  • 什么是交叉类型,交叉类型和交叉项之间继承关系是怎样的呢?
  • 如果用集合的概念去理解ts类型?
  • 任意不同ts类型间,继承关系该如何梳理和分析?
  • ts中有哪些情况下会和里氏替换原则冲突。

ts 中的继承

在 js 代码class Dog extends Animal中,我们认为Dog类继承了Animal类,AnimalDog的父类或者超类(supertype),DogAnimal的子类或者亚类(subtype)。子类中有父类所有的方法和属性,同时子类还可以扩展自己的方法和属性。这种需要特殊关键字才构成的继承关系,我们称之为名义继承(nominal typing)

ts 作为 js 的超类,其类型继承上采用的却是另一种方案:结构继承(structural typing) ,结构型继承的特点是只要一个类型Foo包含了另一个类型Bar的所有成员,那这个类型Foo就是Bar的子类型,即使Foo类型还有其他成员也无所谓。

ts 复制代码
interface Bar {
    key1: string;
    key2: number;
}

interface Foo {
    key1: string;
    key2: number;
    key3: string; // Foo相比于Bar多了一个key3成员
}

虽然 ts 是结构继承,但它仍然可以使用extends支持名义继承,只是在 ts 中extends关键字除了有继承的作用外,还承担着产生条件类型的责任,其语法为

ts 复制代码
type ConditionalType =  SomeType extends OtherType ? TrueType : FalseType;

如果符合前面的继承条件SomeType extends OtherType,则新类型ConditionalTypeTrueType,否则是FalseType。甚至可以说 ts 通过判断两个类型的继承关系实现了条件语句。

ts 复制代码
type IsFooExtendedFromBar = Foo extends Bar ? true : false; // true

联合类型 Union Types

  • 联合类型表示所有联合项的并集。是所有联合项的父类型,通过|操作符表示,可以读作

    ts 复制代码
    // 原始类型的联合
    type StringOrNumber = string | number;
    ts 复制代码
    // 对象类型的联合
    interface Obj1 {
      name: string;
      age: number;
    }
    interface Obj2 {
      name: string;
      male: boolean;
    }
    type Obj1OrObj2 = Obj1 | Obj2;
  • 定义为联合类型的数据,在类型收敛(精确)之前,只能使用联合项共有的方法或者属性

    ts 复制代码
    // 原始类型的联合
    type StringOrNumber = string | number;
    type Keys = keyof StringOrNumber; // "toString" | "valueOf"
    let obj: StringOrNumber; // 则obj只能用字符串和数字共有的属性  "toString" 或者 "valueOf"
    ts 复制代码
    // 对象类型的联合
    interface Obj1 {
      name: string;
      age: number;
    }
    interface Obj2 {
      name: string;
      male: boolean;
    }
    type Obj1OrObj2 = Obj1 | Obj2;
    type Keys = keyof Obj1OrObj2; // "name"
    let obj: Obj1OrObj2; // obj除了可以用 name外,其实还可以使用泛型Object上定义的接口,如toString
  • 联合 类型的数据 可以被 联合项类型的数据 赋值,类型收敛之后,可以使用相关收敛类型的 key。

    ts 复制代码
    // 原始类型的联合
    type StringOrNumber = string | number;
    type keys = keyof StringOrNumber; // "toString" | "valueOf"
    let obj: StringOrNumber;
    obj = 1; // 此时obj的类型已经被收敛为 联合项类型 number
    type numberKeys = keyof typeof obj; // "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
    ts 复制代码
    // 对象类型的联合
    interface Obj1 {
      name: string;
      age: number;
    }
    interface Obj2 {
      name: string;
      male: boolean;
    }
    type Obj1OrObj2 = Obj1 | Obj2;
    type keys = keyof Obj1OrObj2; // "name"
    let obj: Obj1OrObj2 = { name: "sr", age: 10 };
    
    // 这里obj被的类型被收敛为 联合项类型 Obj1
    type Obj1Keys = keyof typeof obj; // keyof Obj1  => name | age

交叉类型 Intersection Types

交叉类型表示所有交叉项的交集。是所有交叉项的子类型,通过&操作符表示,可以读作

  • 原始类型的交叉类型,一般来说都是 never,没什么意义

    ts 复制代码
    // StringAndNumber 既是string的子类型,又是number的子类型,只有never。
    type StringAndNumber = string & number; // never
  • 对象类型的交叉类型,理解成包含所有交叉项的 key 的对象类型就可以了

    ts 复制代码
    interface Obj1 {
      name: string;
      age: number;
    }
    interface Obj2 {
      name: string;
      male: boolean;
    }
    type Obj1AndObj2 = Obj1 & Obj2;
    type keys = keyof Obj1AndObj2; //  "name" | "age" | "male"

需要特别注意的对象类型赋值

对象类型赋值,在对象联合类型下,某些情况不会进行类型收敛。字面量赋值,则只能是具体类型的数据才会赋值成功。

  • 非字面量赋值的情况下,对象符合里氏替换原则,可以被子类型数据赋值,并且会将父类型数据收敛成子类型数据。在联合类型中,则是收敛成某一联合项,如果无法收敛成某个具体的联合项,那类型将不进行收敛。

    ts 复制代码
    interface Obj1 {
      name: string;
      age: number;
    }
    interface Obj2 {
      name: string;
      male: boolean;
    }
    
    type Obj1OrObj2 = Obj1 | Obj2;
    type keys = keyof Obj1OrObj2; // "name"
    let obj: Obj1OrObj2;
    let superObj = { name: "sr" };
    let obj1 = { name: "sr", age: 10 };
    let obj2 = { name: "sr", male: true };
    let subObj = { name: "sr", age: 10, male: true };
    let narrowSubObj = { name: "sr", age: 10, male: true, other: "other" };
    
    // ok,符合里氏替换原则
    let normalObj: Obj1 = narrowSubObj;
    
    // 报错❌,因为superObj的类型 不属于 任何一个 联合项
    obj = superObj;
    
    // ok,obj会被收敛成Obj1类型,可以使用obj.age
    obj = obj1;
    
    // ok,obj会被收敛成Obj1类型,可以使用obj.male
    obj = obj2;
    
    // ok, 但此时由于subObj既是Obj1的子类型,又是Obj2的子类型,所以obj无法收敛成具体联合项,只能是Obj1OrObj2类型
    obj = subObj;
    
    // ok, 但此时由于subObj既是Obj1的子类型,又是Obj2的子类型,所以obj无法收敛成具体联合项,只能是Obj1OrObj2类型。
    obj = narrowSubObj;// 值得注意的是,只要是子类型即可,哪怕更精确了一个名为other的key,也没问题
  • 字面量类型赋值情况下,不再符合里氏替换原则,赋值的数据必须和类型要求一致。在对象联合类型中,(1)字面量和某一个联合项类型一致,类型会收敛成该联合项。(2)字面量和所有联合项的交叉类型一致,因为不能收敛成具体联合项,所以不进行收敛。其他情况的赋值则不被允许。

    ts 复制代码
    interface Obj1 {
      name: string;
      age: number;
    }
    interface Obj2 {
      name: string;
      male: boolean;
    }
    
    type Obj1OrObj2 = Obj1 | Obj2;
    type keys = keyof Obj1OrObj2; // "name"
    let obj: Obj1OrObj2;
    
    // 报错❌,这里赋值的数据必须和类型要求一致
    let normalObj: Obj1 = { name: "sr", age: 10, male: true };
    
    // ok,obj会被收敛成Obj1类型,可以使用obj.age
    obj = { name: "sr", age: 10 };
    
    // ok,obj会被收敛成Obj1类型,可以使用obj.male
    obj = { name: "sr", male: true };
    
    // ok,但此时由于subObj既是Obj1的子类型,又是Obj2的子类型,所以obj无法收敛成具体联合项,只能是Obj1OrObj2类型
    obj = { name: "sr", age: 10, male: true };
    
    // 报错❌,因为多了other类型,无法收敛成具体联合项或者他们的共有精确子类型
    obj = { name: "sr", age: 10, male: true, other: "other" };

通过集合角度描述 ts 的类型

现在有一个类型{ name: string},我们从语义的角度描述它就是限制name为string类型的所有对象。转换成集合图示它是这样的:

再来一个类型{ age: number},我们从语义的角度描述它就是限制age为number类型的所有对象。转换成集合图示它是这样的:

现在有了第三个对象类型{ name: string; age: number},它含义是 限制name为string类型 且 限制age为number的所有对象 ,通过语义转代码可以转成{ name: string}&{ age: number}。而转换成集合,就是上述两个类型的交集,即图中的两个颜色块重合的位置。

{ name: string}|{ age: number}被转化成集合图时,其实就是上图中全部有色区域。

如果把每一个键值对都描述成一种限制的话,从上述三张图其实能得出一个结论,集合内限制越多,则成员越少

接下来看下原始类型如何用集合来表示,就以number为例。

原始数据类型依然符合集合内限制越多,则成员越少 的结论。

number包含了所有字面量数字类型,所以其成员就包含所有数字类型,字面量类型1限制了子类型只有数字类型1,所以其成员就只有一个(实际上还有never,以及父元素上的所有共用的方法之类的,但是那样二维很难画出来,这里只是比对集合的大小,当做只有一个成员也没问题)。
1number的交集1 & number就是图中深色1所代表的区域,即1本身。
1number的并集1 | number就是父类型number本身。所以这个时候可以得到第二个结论集合的交集就是交叉类型即子类型,集合的并集就是联合类型即父类型

那么特殊类型object如何表示呢,我们知道它包含了除原始类型外的所有类型(实际还要排除any,unknown和void),集合图示如下:

Object{}表示的集合,就是object集合再扩展出其余非空原始类型:

在此基础上,加上nullundefinedvoid就是anyunknown代表的集合了,注意undefinedvoid是有父子关系的:

至此,除never外所有类型都通过集合表示完了。而never本身代表的含义是空联合,或者说空集合,即其内部一个子元素也没有,一个子元素都没有的集合也就意味着,其是任何集合的子集合。

如果要画二维图的话,所有类型相交的那个点就是never,这基本画不出来。而转化成代码表示的话,就是所有类型的交集即:SomeType1 & SomeType2 & SomeType3 ...

而通过层级树的结构,则恰好可以把never也囊括进去,同时也会更清晰的表明什么是top type,什么是 bottom type

类型集合继承树

我们以所有类型的父类型anyunknown作为顶点,开始绘制一棵类型集合树,每层之间是继承关系,上一层是下一层的父类型,那么never就处于所有类型的最底层,其形如下

树顶的就是top type:anyunknown

树底的就是bottom type never

里氏替换原则在类型集合继承树上的表示就是可以从下向上替换。

顺着箭头向下的方向表示类型越来越具体,限制越来越多,同时也是类型结构不断继承的过程,这里描述成集成也对。

通过这棵树再看类型的赋值和分析类型间的继承关系就会简单很多。

  • unknown

    ts 复制代码
      let stringVariable: string = 'string'
      let unknownVariable: unknown 
    
      unknownVariable = stringVariable // ok,从下向上赋值
      stringVariable = unknownVariable // 报错❌,从上向下赋值
  • never

    ts 复制代码
      let stringVariable: string = 'string'
      let anyVariable: any
      let neverVariable: never
    
      neverVariable = stringVariable // 报错❌,从上向下赋值
      neverVariable = anyVariable // 报错❌,从上向下赋值
      anyVariable = neverVariable // ok,从下向上赋值
      stringVariable = neverVariable // ok,从下向上赋值
  • void

    typescript 复制代码
      let undefinedVariable: undefined 
      let voidVariable: void
      let unknownVariable: unknown
    
      voidVariable = undefinedVariable // ok,从下向上赋值
      undefinedVariable = voidVariable  // 报错❌,从上向下赋值
      voidVariable = unknownVariable  // 报错❌,从上向下赋值

ts违背里氏替换原则情况总结

ts 是遵循里氏替换原则的,在 ts 中理解成子类实例可以赋值给父类实例。不过在下列情况中,ts会违背里氏替换原则。

逆转情况(函数逆变)

当一个函数类型被赋值时,其参数类型是逆里氏替换的,即参数值的类型得是原函数定义的父类型。函数逆变在后续函数章节会详细解释。

ts 复制代码
type SupertypeFn = (obj: { a: string; b: number }) => void;
type SubtypeFn = (obj: { a: string; b: number; c: number }) => void;
let superFn: SupertypeFn = (superObj) => {};
let subFn: SubtypeFn = (subObj) => {};
subFn = superFn; // ok,函数被赋值时,参数是逆变的。
superFn = subFn; // 报错

不可替换情况(这两种其实都是对象类型直接使用字面量赋值)

对象赋值的情况,详细内容请查看需要特别注意的对象类型赋值

  • 函数参数传入字面量类型的情况
ts 复制代码
function fn(obj: { name: string }) {}
fn({ name: "foo", key: 1 }); // 报错
  • 直接给变量赋值字面量类型的值
ts 复制代码
type UserWithEmail = { name: string; email: string };
type UserWithoutEmail = { name: string };
let userB: UserWithoutEmail = { name: "foo", email: "foo@gmail.com" }; // 报错

void的特殊情况

除了 使用function关键字且显示返回void的情况,函数体可以返回任何类型。

ts 复制代码
let arrowFn: () => void = () => {
// ok,箭头函数仍然可以使用string覆盖void
  return "";
};
const fnReturnString = () => "123"; // () => string

let implicitFn = function () {}; // () => void
implicitFn = fnReturnString; // ok,隐式推断返回值为void的函数可以使用string覆盖void

let explicitFn = function (): void {}; // () => void, 显式声明返回类型为void
explicitFn = fnReturnString; // ok,显式声明返回类型为void的函数仍然可以使用string覆盖void

后记

如果还不了解ts都有哪些类型, 及其特性。可以查看ts类型全梳理

ts应该也会搞成一个系列。ts介绍,全类型梳理,类型关系理解(本篇),类型操作,ts中的函数,模块化和类型声明文件等。

参考资料

相关推荐
惜.己15 分钟前
Jmeter中的配置原件(四)
java·前端·功能测试·jmeter·1024程序员节
EasyNTS16 分钟前
无插件H5播放器EasyPlayer.js网页web无插件播放器vue和react详细介绍
前端·javascript·vue.js
guokanglun40 分钟前
Vue.js动态组件使用
前端·javascript·vue.js
Go4doom43 分钟前
vue-cli3+qiankun迁移至rsbuild
前端
-seventy-1 小时前
Ajax 与 Vue 框架应用点——随笔谈
前端
我认不到你1 小时前
antd proFromSelect 懒加载+模糊查询
前端·javascript·react.js·typescript
集成显卡1 小时前
axios平替!用浏览器自带的fetch处理AJAX(兼容表单/JSON/文件上传)
前端·ajax·json
焚琴煮鹤的熊熊野火1 小时前
前端垂直居中的多种实现方式及应用分析
前端
我是苏苏2 小时前
C# Main函数中调用异步方法
前端·javascript·c#
转角羊儿2 小时前
uni-app文章列表制作⑧
前端·javascript·uni-app