TypeScript入门之复杂类型系统

96 阅读11分钟

Object类型与object类型

TypeScript的对象类型也有大写Object和小写object两种。

Object 类型(大写)

大写的Object类型代表JavaScript语言里面的广义对象。所有可以转成对象的值,都是Object类型,这囊括了几乎所有的值

广义对象:指的是一种数据结构,它可以用来表示和组织数据,并且可以包含各种属性和方法。JavaScript中的对象是一种复合数据类型,可以存储多个值(称为属性或方法),并且这些值可以是各种类型的数据,包含其他对象。 总结:广义对象的概念意味着对象可以用于表示各种事务,从简单的数据结构到复杂的实体。

let obj:Object;

obj = true
obj = 'hi'
obj = 1
obj = {name:'name'}
obj = [1,2]
obj = (a:number)=>a + 1;
obj = undefined
obj = null

上面示例中,原始类型值、对象、数组、函数都是合法的Object类型。

另一种表达空对象的简写形式,{},使用Object时可用空对象代替。

let obj:{}

obj = {name:'name'}
obj = [1,2]
obj = (a:number)=>a + 1;

上面示例中,变量obj的类型是空对象{},就代表Object类型。

object 类型(小写)

小写的object类型代表JavaScript里面的狭义对象。

狭义对象:即可以用字面量表示的对象,只包含对象、数组和函数,不包括原始类型的值。

obj1 = [1,2]
obj1 = {name:'name'}
obj1 = (a:number)=> a + 1

obj1 = 'string' // 报错 类型无法转换
obj1 = true // 报错 同上

上面示例中,object类型不包含原始类型值,只包含对象、数组和函数。

建议使用小写类型,不推荐大写类型。

注意,无论是大写的Object类型,还是小写的object类型,都只包含JavaScript内置对象原生的属性和方法,用户自定义的属性和方法都不存在于这两个类型之中。

let obj1:object = {name:'name'};

obj1.toString()
obj1.name // 报错

let obj2:Object = {name:'name'};

obj2.toString()
obj2.name // 报错

上面示例中,toString()是对象的原生方法,可以正确访问。name是自定义属性,访问就会报错。

undefined 和 null 的特殊性

基础类型系统中介绍过 undefinednull即是值,又是类型。

作为值,它们有一个特殊的地方:任何其他类型的变量都可以赋值为undefinednull

let age:number = 18

age = null // 正常
age = undefined // 正常

上面代码中,变量age的类型是number,但是赋值为nullundefined并不报错。

这并不是因为undefinednull包含在number类型里面,而是故意这样设计,任何类型的变量都可以赋值为undefinednull,以便跟 JavaScript 的行为保持一致。

JavaScript 的行为是,变量如果等于undefined就表示还没有赋值,如果等于null就表示值为空。所以,TypeScript 就允许了任何类型的变量都可以赋值为这两个值。

但是有时候,这并不是开发者想要的行为,也不利于发挥类型系统的优势。

let age:number = 18

age = undefined

age.toFixed() // 不开启strictNullChecks编译不会报错,运行时会报错

如何配置strictNullChecks

这个选项在配置文件tsconfig.json的写法如下。

{
  "compilerOptions": {
     "strictNullChecks": true,
  }
}

开启会在ts文件编译时就会报错,但是undefinednull可以赋值给any | unknown类型。

值类型

TypeScript规定,单个值也是一种类型,称为”值类型”。

let x:'https';

x = 'https'
x = 'http' // 报错

上面示例中,变量x的类型是字符串https,导致它只能赋值为这个字符串,赋值为其他字符串就会报错。

TypeScript 推断类型时,遇到const命令声明的变量,如果代码里面没有注明类型,就会推断该变量是值类型。

const a = 'https'

const b:string = 'https'

const obj = {name:'name'}

上面示例中,变量aconst命令声明的,TypeScript 就会推断它的类型是 值https,而不是string类型,常量不可修改。

注意,const命令声明的变量,如果赋值为对象,并不会推断为值类型。

// 类型推导 const obj:{name:string}
const obj = {name:'name'}

上面示例中,变量obj没有被推断为值类型,而是推断属性name的类型是string。这是因为JavaScript 里面,const变量赋值为对象时,属性值是可以改变的。

值类型可能会出现一些很奇怪的报错。

//报错 Type 'number' is not assignable to type '4'
const amount:4 = 3 + 1;

上面示例中,等号左侧的类型是数值4,等号右侧3 + 1的类型,TypeScript推测为number。由于4number的子类型,number4的父类型,父类型不能赋值给子类型,所以报错,相反可编译下方示例

let a:4 = 4;
let b:number = 3 + 1;

a = b // 报错
b = a // 正常

上面示例中,变量a属于子类型,变量b属于父类型。a不能赋值为子类型b,但是反过来是可以的。

联合类型

联合类型(union types)指的是多个类型组成的一个新类型,使用符号|表示。

联合类型A|B表示,任何一个类型只要属于AB,就属于联合类型A|B,联合类型可包含所有类型

let a:string|number;

a = 'string' // 正确
a = 1 // 正确

a = {} // 报错

上面示例中,变量a就是联合类型string|number,表示它的值既可以是字符串,也可以是数值。

联合类型的第一个成员前面,也可以加上竖杠|,这样便于多行书写。

let a:
    | string
    | number
    | undefined
    | boolean;

如果一个变量有多种类型,读取该变量时,往往需要进行”类型缩小“(type narrowing),区分该值到底属于哪一种类型,然后再进一步处理。

const formatId = (id:number|string)=>{
  id.toLocaleString() // 正常
  
  id.toUpperCase() // 报错
}

上面示例中,参数变量id可能是数值,也可能是字符串,这时直接对这个变量调用toUpperCase()方法会报错,因为这个方法只存在于字符串,不存在于数值。

解决方法就是对参数id进行类型缩小处理。

const formatId = (id:number|string)=>{
  
  id.toLocaleString()
  
  if(typeof id === 'string'){
    id.toUpperCase()
  }
}

上面示例中,函数体内部会判断一下变量id的类型,如果是字符串,就对其执行toUpperCase()方法。

“类型缩小”是 TypeScript 处理联合类型的标准方法,凡是遇到可能为多种类型的场合,都需要先缩小类型,再进行处理。实际上,联合类型本身可以看成是一种“类型放大”(type widening),处理时就需要“类型缩小”(type narrowing)。

交叉类型

交叉类型(intersection types)指的多个类型组成的一个新类型,使用符号&表示。

交叉类型A&B表示,任何一个类型必须同时属于AB,才属于交叉类型A&B,即交叉类型同时满足AB的特征。

let a:number&string;

上面示例中,变量a同时是数字和字符串,这当然是不可能的,所以 TypeScript 会认为a的类型实际是never

交叉类型的主要用途是表示对象的合成。

let obj:{name:string}&{age:number}

obj = {
  name:'小芳',
  age:18,
}

上面示例中,变量obj同时具有属性name和属性age

交叉类型常常用来为对象类型添加新属性。

type Q = {name:string}

type W = Q & {age:number}

let obj:W

上面示例中,类型W是一个交叉类型,用来在Q的基础上增加了属性age

type 命令

type命令用来定义一个类型的别名

type Age = number

let age:Age = 18;

上面示例中,type命令为number类型定义了一个别名Age。这样就能像使用number一样,使用Age作为类型。

别名可以让类型的名字变得更有意义,也能增加代码的可读性,还可以使复杂类型用起来更方便,便于以后修改变量的类型。

别名支持使用表达式,也可以在定义一个别名时,使用另一个别名,即别名允许嵌套。

type Hi = '你好!'

type He = `hello ${Hi}`  ===  He = 'hello 你好!'

上面示例中,别名He使用了模板字符串,读取另一个别名Hi

typeof 运算符

JavaScript语言中,typeof 运算符是一个一元运算符,返回一个字符串,代表操作数的类型。

console.log(typeof 'name'); // string

上面示例中,typeof运算符返回字符串name的类型是string

注意,这时typeof的操作数是一个值。

JavaScript 里面,typeof 运算符只可能返回八种结果,而且都是字符串。

typeof undefined; // "undefined"
typeof true; // "boolean"
typeof 1337; // "number"
typeof "foo"; // "string"
typeof {}; // "object"
typeof parseInt; // "function"
typeof Symbol(); // "symbol"
typeof 127n // "bigint"

上面示例是typeof运算符是JavaScript语言里面,可能返回的八种结果。

TypeScript将typeof运算符移植到了类型运算,它的操作数依然是一个值,但是返回的不是字符串,而是该值的TypeScript类型。

const a = {name:'name'}

type A = typeof a; // {name:string}

type Name = typeof a.name // string

上面示例中,typeof a表示返回变量a的TypeScript类型({name:string})。同理,typeof a.name返回的是属性name的类型number

这种用法的typeof返回的是 TypeScript 类型,所以只能用在类型运算之中(即跟类型相关的代码之中),不能用在值运算。

let a = 1;
let b:typeof a

if(typeof b === 'number'){
  b = a
}

上面示例中,用到了两个typeof,第一个是类型运算,第二个是值运算。它们是不一样的,不要混淆。

JavaScript的typeof遵守JavaScript规则,TypeScript的typeof 遵守 TypeScript规则。它们的一个重要区别在于,编译后,前者会保留,后者会被全部删除。

上例的代码编译结果如下。

let a = 1;
let b;
if (typeof b === 'number') {
    b = a;
}

上面示例中,只保留了原始代码的第二个 typeof,删除了第一个 typeof。

由于编译时不会进行 JavaScript 的值运算,所以TypeScript 规定,typeof 的参数只能是标识符,不能是需要运算的表达式。

type T = typeof Date() // 报错

上面示例会报错,原因是 typeof 的参数不能是一个值的运算式,而Date()需要运算才知道结果。

另外,typeof命令的参数不能是类型。

type Age = number;

// age only refers to a type,but is being used as a avalue here.
// 年龄仅指类型,但在这里被用作值。
type MyAge = typeof Age

上面示例中,Age是一个类型别名,用作typeof命令的参数就会报错。

typeof 是一个很重要的 TypeScript 运算符,有些场合不知道某个变量foo的类型,这时使用typeof foo就可以获得它的类型。

块级类型声明

TypeScript 支持块级类型声明,即类型可以声明在代码块(用大括号表示)里面,并且只在当前代码块有效。

if(true){
  type T = number;
  let v:T = 1;
}else{
  type T = string;
  let s:T = '123'
}

上面示例中,存在两个代码块,其中分别有一个类型T的声明。这两个声明都只在自己的代码块内部有效,在代码块外部无效。

类型的兼容

TypeScript的类型存在兼容关系,某些类型可以兼容其他类型。

type T = number|string

let a:number = 1;
let b:T = a;

上面示例中,变量ab的类型是不一样的,但是变量a赋值给变量b并不会报错。这时,我们就认为,b的类型兼容a的类型。

TypeScript为这种情况定义了一个专门术语。如果类型A的值可以赋值给类型B,那么类型A就称为类型B的子类型(subtype)。在上例中,类型number就是类型number|string的子类型。

TypeScript的一个规则是,凡是可以使用父类型的地方,都可以使用子类型,但是反过来不行。

let a:'hi' = 'hi'

let b:string = 'hello'

b = a; // 正常
a = b; // 报错

上面示例中,histring的子类型,stringhi的父类型。所以,变量a可以赋值给变量b,但是反过来就会报错。

之所以有这样的规则,是因为子类型继承了父类型的所有特征,所以可以用在父类型的场合。但是,子类型还可能有一些父类型没有的特征,所以父类型不能用在子类型的场合。

结尾

上述摘要来自阮一峰老师的TS教程,好记性不如烂笔头,自己梳理一遍增加记忆力,每日更新自己的知识库。