TS类型辨析

最近读完了《Typescript编程》一书,对这门语言产生了浓厚的兴趣,同时也对于其中较难理解的内容有一些自己的思考。因此决定将这些思考记录下来,同时分享给大家,这是我作为一名萌新前端工程师的第一篇文章,难免会出现一些理解和叙述上的错误,希望大家能够将发现的问题反馈给我,很荣幸能和各位前端爱好者讨论问题、共同进步,那么废话不多说下面开始正文。

本文并不是TS的语法教程,因此并不会详细介绍TS的各种语法,感兴趣的读者可以自行查阅

Typescrtipt概述


​ 在介绍更深层次的内容之前,有必要首先介绍一下Typescript这门语言。

Typescript是微软开发的一门开源的编程语言,通过在Javascript的基础上添加静态类型定义构建而成。TypeScript通过TypeScript

编译器或Babel(JavaScript转译工具,可以将目标JS编译成任意的版本)转译为任意版本的JS代码并运行在各个浏览器和操作系统上。

​ 从上面的描述中,我们可以提取到至少三个十分有用的信息:

  1. Typescript是开源的
  2. Typescript可以在javascript的基础上添加静态类型
  3. Typescript需要通过编译工具转译为javascript才可以运行在浏览器当中

​ Javascript对于前端的小伙伴自然不会陌生,Javascript的动态类型让我们在编写代码时如鱼得水,但在维护时却痛苦万分。正所谓开发一时爽,维护火葬场。

​ 由于Javascript的动态类型特性,在编写Javascript代码时,编译器并不会在乎某个函数有没有得到他该得到的参数。编译器甚至不会关心它有没有得到参数,只要语法正确,都Tnnd给我跑。这也就意味着,我们无法在代码编写时发现一些隐患,只有在程序运行崩溃时才会拍着脑门惊恐万分。再加上Javascript中存在大量的异步代码,我们无法在调用栈中准确的找到问题,使得后期的维护工作难上加难。然而Typescript的出现改变了这一局面。

​ Typescript的出现将是革命性的,尽管Javascript在web领域仍具有压倒性的地位(Typescript的核心仍然是Javascript),但Typescript的光辉已经难以被掩盖。如果阅读过[VUE3][https://github.com/vuejs/vue]源码或者UI组件库[Vant][https://github.com/vant-ui/vant]源码的小伙伴一定知道,这些著名开源项目的最新版本已经全部使用Typescript进行开发了。这足以说明Typescript的先进性。

Typescript的"强与弱"


​ Typescript的强与弱,其实就是说TS既是一门静态类型语言,也是一门动态类型语言。而准确来说,Typescript是一门渐进式类型语言。Typescript并没有摒弃Javascript的语言特性,相反,它完全兼容Javascript,这也就意味着将Javascript的代码迁移到Typescript的版本将会是一件较为轻松的事情。(其实也没那么轻松)下面以一个例子来说明为什么Typescript是渐进式的:

let animal; // TS现在不知道animal是什么类型,因为你没有说TS也懒得问
let animal: string; // 你说animal应该是string类型的,TS知道了,不是string类型的值都不准给它
let animal = 'cat'; // 即使你什么也没有说,但是值'cat'已经告诉 TS, animal应该是string类型了

​ TS的渐进性就体现在从创建变量变量赋值的过程中,逐步判断变量的类型,一旦类型确定,将无法改变。

TS将JS的类型推断机制从运行时提前到了编译时,因此以下的代码在JS当中不会报错,在TS当中就无法运行:

let a = 1;
a = '1'; // Type Error 试图将string类型值赋值给number类型变量

​ 也就是这种机制,让TS变得既灵活又安全。

Typescript类型概述


​ TS和JS类型一样分为基本类型和引用类型。基本类型变量在栈中存储,引用类型变量在堆中存储。

类型名称典型值描述
any*any是所有类型的父类型,也就是说任何类型的值都可以赋值给any类型的变量,尽量避免使用any
unknow*unknow类型可以被视为安全的any类型
booleantrue、false不过多赘述
number任意合法数字不过多赘述
bigint比number类型大得多不过多赘述
string‘abc’不过多赘述
对象{}TS的对象类型表示的是对象的结构而非名称
  • any


​ any类型是所有类型的父类型,当TS类型检查器无法确定变量类型时,都会将其视为any。也可以说,any类型是所有类型的基础类型(兜底类型)。尽量避免使用any类型,除非你真的需要做一些看起来十分诡异的操作,例如将一个数字和一个数组相加:

let a: any = 666;
let b: any = ['dsada'];
let c = a + b;

​ 正常情况下计算数字和数组的和显然是不合理的,但如果你真的需要这么做,为了告诉TS类型检查器你真的知道自己在做什么,就需要显示的注解变量的类型为any,否则将会报错。

  • unknow


​ unknow与any类似,也可以表示任何值,但区别在于,TS不会把任何值推导为unknow类型,且unkown在进行运算操作时,必须向类型检查器指明是什么类型(细化类型),可以使用typeof运算符或instanceof进行细化。

let a: unknown = 10;
let b = a === 123;//boolean
let c = a + 10;// 运算符“+”不能应用于类型“unknown”和“10”
if(typeof a === 'number') {
    let c = a + 10;// 编译通过
}
let d = a + 10;//运算符“+”不能应用于类型“unknown”和“10”,即使在细化后仍会报错

​ 从上述代码中我们可以发现:

  1. unknow类型的值可以比较。这其实很好理解,因为在js中,=== 运算符的左右操作数可以是任意类型的。
  2. TS编译器不能推导unknow类型变量为确定类型,必须在运算时向TS证明它的类型。
  3. 类型细化并不会改变变量原本的类型,(在这里我认为细化也有作用域的概念,细化只是在语义上证明变量是某种类型,而非改变变量的类型,类似于类型断言)
  • 对象类型


​ 注意,在Js中我们认为Object是所有类型的原型(除了使用Object.create函数创建的对象),在TS中也是如此。但你可能注意到了本节的标题是对象类型 而非 object类型。因为在TS中,这二者是有区别的:

let a: object = {
    b: 123
};

a.b; // 类型“unknown”上不存在属性“b”

​ 看到上述代码你可能会很疑惑,a对象上不是有b属性吗,为什么ts不允许访问?解答这个问题之前我们得先注意上述表格中对于对象类型的描述TS的对象类型表示的是对象的结构而非名称。这样就很好理解了,a: object 告诉该对象应该是什么结构了吗?显然没有。

​ 实际上,object仅比any的范围窄一些(既表示任意null以外的对象),但TS类型检查希望得到明确的类型(通常说希望类型的范围足够窄)。因此上述代码需要进行如下改造:

let a: {b: number} = {b: 123};
a.b;

​ 这样就没有问题了,因为a的结构说明了它有b属性。

​ 这里需要补充一点说明,既const关键字,请看如下代码:

let a = 1233; // a: number
const b = 1233; // b: 1233,const 关键字会进一步收窄类型,此时b的类型是1233而不是number

​ 在JS中,对象是引用类型,在TS中也是一样,因此对对象使用const关键字并不会将类型变窄,这一点其实和JS中对基本类型和引用类型使用const时的区别是相同的,只不过TS中对类型也是这样:

let a = {
    b: 123 // TS自动推断出 b: number
}

const a = {
    b: 123 // b: number
}

子类型与父类型


子类型与父类型是TS类型中十分重要且必须理解的概念,给定一个定义,如果存在A与B两个类型,假设A是B的子类型,则任何需要B类型的地方都可以放心的使用A类型

  • Array 是 Object 的子类型

  • Tuple 是 Array 的子类型

  • 所有类型都是 any 的子类型 (任何需要any的地方都可以使用其他任意类型)

  • never 是所有类型的子类型 (never类型变量可以赋值给任意类型)

  • 如果 Bird 类拓展自Animal类型,则Bird是Animal类型的子类型

​ 这里存在一个容易被误解的地方,在面向对象语言当中,若A类型继承自B类型,即

class A extends B{}

​ 这意味着 A 所包含的属性和方法在数量上一定大于或等于B,既B 是 A 所包含内容的子集真子集。这是不是和我们上面给出的定义刚好相反?

​ 其实在结构上的确如此,但类型集合和类有一个重要区别,既类是属性和行为的集合,而类型集合是类型的集合。这样说起来可能不太好理解,下面我们直接用例子进行说明:

​ 对于类型集合而言

type A = number | string | boolean;
type B = number | string;

let a: A = true;
let b = a;// Type Error 因为B < A 因此将A类型变量赋值给B类型时,A类型的值可能是B中没有的。 

​ 对于类而言

class A {
	a: number;
}

class B extends A {
	b: number;
}

let b: B = {
    a: 123,
    b:123
}

let a: A = b; // 显然A类型中的内容B类型中都有

​ 从类型的角度而言,因为A中的属性更少,因此A中没有的属性都可以视为any。也就是说A的类型可以视为:

[key: any]: any 表示任意数量键和值都为any类型的属性

A = {
    a: number;
    [key: any]: any
}

B = {
   a: number;
   b: number;
   [key: any]: any
}

显然 

{
  b: number;
  [key: any]: any;
}{
  [key: any]: any
}
的子类型

对象型变


​ 型变是泛型编程中的常见概念,对象型变指的是变量接收到非预期类型值时的规则,既允许接收怎样的非预期类型(预期类型和非预期类型需要满足父子类型关系才有型变的概念)。

​ 多数情况下很容易判断 A 是不是 B 的子类型,例如对于 number | string(number类型与string类型的并集,表示为number或string。详见TS交集与并集类型) 类型来说,number肯定是它的子类型。但是对于参数化类型而言就显得不是那么清晰明了了。

什么情况下Array<A>Array<B>的子类型?

​ 为了能够简洁的表述类型之间的关系,我借助数学中常用的><两个符号来表示类型之间的关系

  • A > B 表示A类型是B类型的父类型或同一类型

  • A < B 表示A类型是B类型的子类型或同一类型

    下面我们借用《Typescript编程》中的一个示例来说明什么是型变。假设我们现在有两个数据结构,一个是新用户,一个是已经存在的用户。

type ExistingUser = {
    id: number;
    name: string;
}

type NewUser = {
    name: string;
}

​ 现在我们有一个需求,需要删除某一个用户,你可能会这样做:

/*
 *参数user的类型为{id: number | undefind, name: string} 而传入的类型为 {id: number, name: string}
*/
function deleteUser(user: {id?: number, name: string}): void {
	delete user.id;	
};

let existingUser: ExistingUser = {
    id: 12345,
    name: "User"
};

deleteUser(existingUser);

​ 传入的参数是形参的子类型,因此编译器并不会觉得有什么问题。但如果删除之后我错误的读取了existingUser.id会怎么样?答案是什么也不会发生,因为TS并不知道id已经被用户删除了,仍然认为existingUser.id还是number类型,但是在运行时,就会出现问题了。

​ 显然这是不安全的,单TS在设计上并不只注重了安全性,TS的类型系统希望在捕获问题和易于使用上做到平衡,让我们无需深入研究语言理论就可以理解出错的原因。上述例子属于特殊情况,由于破坏性更新(对原有的数据结构进行破坏,例如删除和增加新的属性)在实际中很少见,所以TS放宽了要求,允许我们在使用预期类型的地方使用它的子类型。在预期子类型的地方使用它的父类型显然是不合理的,因为父类型可能存在子类型中不存在的类型。这里就不在举例说明。

TS的行为是这样的:对预期的结构,还可以使用属性的类型 < 预期类型的结构。但不允许传入预期类型的超类型。在类型上,我们说TS对结构(对象和类)的属性进行了协变。也就是说A对象如果可以赋值给B对象,则A对象的所有属性都必须是B对象对应属性的子类型。

​ 不过,协变其实只是型变的四种方式之一:

  1. 不变: 只能是T类型
  2. 协变:可以是T的子类型
  3. 逆变:可以是T的父类型
  4. 双边: 既可以是父类型也可以是子类型

函数型变


​ TS中每个复杂类型的成员,包括对象、类、数组、函数的返回值都会进行协变。但有一个例外:函数的参数类型进行逆变。

​ 如果函数A的参数数量小于函数B的参数数量,而且满足下述条件,那么函数 A 是函数 B 的子类型。

  1. 函数 A 的this未指定,或是 > 函数 B 的this类型。
  2. 函数 A 的各个参数的类型 > 函数 B 相应参数的类型。
  3. 函数 A 的返回类型 < 函数 B 的返回类型

​ 为什么函数如此特殊,我们不妨用几个例子来证明一下,我们继续借用《Typescript编程中的例子来进行说明》:

​ 现在有以下三个类的实现:

class Animal {}

class Dog extends Animal {
   run(){};
}

class Cat extends Dog {
    sleep(){};
}

​ 下面,我们定义一个函数,它的参数也是一个函数:

function clone(f: (d: Dog) => Dog): void {
    ...
}

​ 接下来,我们考虑什么样的函数可以传给clone,先比较返回值类型:

function dogToDog(d: Dog): Dog{}

​ 显然,所有都一样,可以传。

function dogToCat(d: Dog): Cat{}

​ 也可以传,因为返回值Cat是Dog的子类。Dog有的他都有。

function dogToAnimal(d: Dog): Animal{} 

​ 显然不行,Animal是Dog的父类,Dog有的Animal没有。

​ 下面我们比较参数类型:

function animalToDog(b: Animal): Dog{}

clone(animalToDog) // OK

function catToDog(b: Cat): Dog(){}

clone(catToDog) // Error

​ 为什么传入函数的参数不能是它的子类型呢?假设 catToDogclone是这样实现的:

function catToDog(b: Cat): Dog(){
    b.sleep();
    return new Dog
}

function clone(f: (d: Dog) => Dog): void {
    let parent = new Dog;
    let babyDog = f(parent);
    babyDog.run();
}

​ 如果我们将 catToDog传给clone,如果clone中传给catToDog的实参是一个Dog类型(),显然b.sleep无法调用,因为Dog类型中并没有这个方法。

​ 从例子中我们很容易可以发现不合理的地方,但我们必须总结为什么会这样。现在我们假设不知道这些函数的具体实现。我们将形参函数的参数类型看做是一种中间界限,当我们编写 clone 函数时,肯定不会对 f函数传入其参数的父类型。

​ 下面我们称实参函数的参数类型为 A ,形参函数的参数类型为 B。

1. 当A < B,既 A 是 B 的子类型,那么在clone函数中传给该函数B类型显然不正确,因为我们无法在预期子类型的地方使用其父类型

2.当A > B,既 A 是 B 的父类型,那么在clone函数中传给该函数任意类型都必须是A的子类型,B也是A的子类型,因此无需担心将B传给它

可赋值性


​ TS在回答 A 能否赋值给 B 时总是遵循以下规则:

  1. A < B
  2. A 是 any

​ 规则2是一个例外,之前我们说过,any是任意类型的父类型,因此按照规则1来说,any类型的值只能赋值给any类型变量。但为了与JS代码互操作,放宽了这个限制,但也仅仅只有any可以这样。因为JS代码在TS看来很多情况下都具有隐式的any类型。因此才会出现以下代码。

type ExistingUser = {
    id: number;
    name: string;
}

type NewUser = {
    name: string;
}

let a: NewUser = {
    name: '213',
}

let b: ExistingUser = a as any;  // 并不会报错,因为我们断言a是any类型

类型拓宽


​ 其实在TS当中任意值都可以被视为一个类型

const a = 1; // a 的类型不是number 而是1
let b: 1 = 1;// b 的类型也是 1 而不是 number 

​ TS帮我们自动拓宽了类型,减少了不必要的报错。

let a = 1;// a 的类型本来应该是 1,但TS类型推断帮我们将 字面量类型 1 推广到了 number,因此后续对a赋值number类型并不会报错。

结语


​ 本文主要对TS的类型系统进行了辨析。子类型和父类型的概念是TS类型系统中的核心概念,理解这两个概念对于充分利用TS先进的类型系统十分必要。由于篇幅有限,TS类型系统中的许多实现细节未能提及,感兴趣的小伙伴可以自行阅读《Typescript编程》找寻答案。

  • 6
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值