面向Type编程 -- Typescript类型和类型操作(三)

356 阅读17分钟

认识Typescript常用高级类型

简述

前面我们讨论了Typescript常用类型操作符typeof、keyof、in、extends、infer等。合理的使用这些类型操作符,我们创建很多实用的类型和类型工具。总结归纳:

  • typeof提供了对象转类型的方法和途径
  • keyof提供了获取类型属性键值的能力
  • in提供遍历操作能力
  • extends提供了范围限制和条件判断能力
  • infer结合extends提供了声明特定位置待推断类型的能力

总而言之,合理结合使用泛型函数、类型和操作符,使我们具备了类型编程的能力。下面我们首先详细的讨论一下常用的一些高级类型以及泛型函数,进而探讨一下类型编程技巧,最后再一起认识和解析一些常用的类型函数和工具。希望通过讨论,大家能够熟练的掌握Typescript类型和类型操作符,具备类型编程能力。

一、字面量类型

什么是字面量类型

字面量也叫直接量。计算机科学中,字面量(literal)是用于表达源代码中一个固定值的表示法(notation)。字面量类型是固定值表示的类型。通常我们使用的string,number,boolean等类型属于集合类型,例如string是所有字符串集合。字面量类型不同于集合类型,它只有一个类型实例,即其固定值,所以字面量类型也叫单位类型(Unit Type)\

字面量类型分类

  • 字符串字面量类型(String Literal Types)
  • 数字字面量类型(Number Literal Types)
  • 布尔字面量类型(Boolean Literal Types)
  • 枚举字面量类型(Enum Literal Types)

字符串字面量类型(String Literal Types)

  • 实例声明

使用一个字符串字面量作为一个类型,例如

使用一个字符串字面量作为一个类型,例如

let foo: 'Hello'
foo = 'Hello' // ok
foo = 'Bar'; // Error: 'Bar' 不能赋值给类型 'Hello'
  • 类型检测

可以使用typeof检验一下变量等类型

type CF = typeof foo // type CF = "Hello"

const foo1 = 'World'
type CF1 = typeof foo1 // type CF1 = "World"
  • 字面量联合类型

单纯的字面量类型并不是很实用,更多的场景是联合类型的形式出现,用于有限的、有特定关联的固定值类型。例如四季名称、星期、方向名称、色子的六个面等等。类型'Hello'不同于其实例值'Hello',也不同于string类型的实例‘Hello’

type CardinalDirection = 'North' | 'East' | 'South' | 'West';

function move(distance: number, direction: CardinalDirection) {
  // ...
}

move(1, 'North'); // ok
move(1, 'Nurth'); // Error
  • 字面量类型与keyof

前面讲过,结合keyof可以获取类型属性键值的字面量类型的联合类型

interface Person {
    name: string;
    age: number;
    location: string;
}

type K1 = keyof Person; // "name" | "age" | "location"
  • 推断类型陷阱。

虽然这里使用了const声明变量,但是变量test的类型是{someProp:string},所以属性someProp被推断为string类型。string类型的实例'foo'无法分配给字面量类型'foo'

function iTakeFoo(foo: 'foo') {}
const test = {
  someProp: 'foo'
};

iTakeFoo(test.someProp) // 类型“string”的参数不能赋给类型“"foo"”的参数

可以使用类型断言解决这个问题。下面声明常量test的时,someProp属性使用了类型断言。因此test的声明类型被推断为{someProp:'foo'},此处的'foo'是字面量类型,满足函数iTakeFoo的参数类型要求

function iTakeFoo(foo: 'foo') {}

const test = {
  someProp: 'foo' as 'foo'
};

iTakeFoo(test.someProp); // ok

数字字面量类型(Number Literal Types)

数字字面量类型,大致跟上面的字符串字面量类型相同,可以把具体固定的值当类型使用。同时,也要注意推断类型陷阱的问题。

let zeroOrOne: 0 | 1;

zeroOrOne = 0;
// OK

zeroOrOne = 1;
// OK

zeroOrOne = 2;
// Error: Type '2' is not assignable to type '0 | 1'

function getAge(age:28){

}

const person = {
    age: 28
}

getAge(person.age) // 类型“number”的参数不能赋给类型“28”的参数

type TN = typeof zeroOrOne // TN = 0 | 1
  • 应用实例

下面这个例子应用场景很常见,这是一个处理端口号的函数,返回值是数字字面量类型组成的联合类型。

function getPort(scheme: "http" | "https"): 80 | 443 {
  switch (scheme) {
    case "http":
      return 80;
    case "https":
      return 443;
  }
}

const httpPort = getPort("http"); // Type 80 | 443
  • 函数重载的影响

但是结合Typescript的函数重载一起使用,返回值就明确多了。

function getPort(scheme: "http"): 80;
function getPort(scheme: "https"): 443;
function getPort(scheme: "http" | "https"): 80 | 443 {
  switch (scheme) {
    case "http":
      return 80;
    case "https":
      return 443;
  }
}

const httpPort = getPort("http"); // Type 80
const httpsPort = getPort("https"); // Type 443

枚举字面量类型(Enum Literal Types)

枚举类型同样也可以用作字面量类型。继续上面的例子,我们先声明一个包含两个端口号的枚举常量

const enum HttpPort {
  Http = 80,
  Https = 443
}

同样利用函数重载,不过这次我们创建一个getScheme函数

function getScheme(port: HttpPort.Http): "http";
function getScheme(port: HttpPort.Https): "https";
function getScheme(port: HttpPort): "http" | "https" {
  switch (port) {
    case HttpPort.Http:
      return "http";
    case HttpPort.Https:
      return "https";
  }
}

const scheme = getScheme(HttpPort.Http);
// Type "http"
  • 枚举常量类型编译规则

枚举常量没有运行时表现形式(除非你设置了preserveConstEnums编译选项),编译器将会直接编译成相应的值,而不是变量。看下面编译结果,HttpPort.Http和HttpPort.Https编译成了80和443

这也是提高代码性能的小技巧\

function getScheme(port) {
  switch (port) {
    case 80:
      return "http";
    case 443:
      return "https";
  }
}
var scheme = getScheme(80);

二、never

  • never类型定义

never是Typescript类型中的底部类型。底部类型是没有值的类型,也称为零类型或者空类型。底部类型是所有类型的子类,用符号表示是(⊥)。

  • never应用场景

通常下面两种情况会用到never类型:

1. 用于表示不会有返回值的函数的返回类型:例如,永远循环的函数,始终抛出异常信号的函数等
2. 类型变量受永不可能为真的条件限制,由于类型保护机制,变量类型收窄为never类型
  • 不会有返回值的函数

这是一无限循环函数,没有任何的终止循环语句,函数执行永远不会结束,因此不会有任何的返回值

const sing = function() {
  while (true) {
    console.log("Never gonna give you up");
    console.log("Never gonna let you down");
    console.log("Never gonna run around and desert you");
    console.log("Never gonna make you cry");
    console.log("Never gonna say goodbye");
    console.log("Never gonna tell a lie and hurt you");
  }
}
  • 始终抛出异常的函数

下面是一个始终抛出异常的函数

const failwith = (message: string) => {
  throw new Error(message);
}
  • 永不可能为真的逻辑判断下类型收窄

在一个永远无法为真的逻辑判断中,类型会收窄为never

function controlFlowAnalysisWithNever(
  value: string | number
) {
  if (typeof value === "string") {
    value; // Type string
  } else if (typeof value === "number") {
    value; // Type number
  } else {
    value; // Type never
  }
}
  • never类型的特点

因为never是底部类型,是所有其它类型的子类型,所以它具备以下特征:

1、never是任意类型的子类型,并且可以分配给任意类型

let n: never
let a: string = n
let b: number = n
let c: boolean = n
let d: 'd' = n
let e: never = n
let f: () => void = n
let g: unknown = n
let h: any = n
let i: symbol = n

2、never没有子类型,并且除了never本身,没有类型可以分配给never类型

let n: never
let n1:never = n // ok
n = 's' // 不能将类型“string”分配给类型“never”
n = 1 // 不能将类型“number”分配给类型“never”
n = false // 不能将类型“boolean”分配给类型“never”
n = (...arg: any) => void; // 不能将类型“(...arg: any) => any”分配给类型“never”
n = new Array() // 不能将类型“any[]”分配给类型“never”
n = Symbol() // 不能将类型“symbol”分配给类型“never”
n = {} // 不能将类型“{}”分配给类型“never”

3、在一个没有返回值标注的函数表达式或箭头函数中, 如果函数没有 return 语句, 或者仅有表达式类型为 never 的 return 语句, 并且函数的终止点无法被执行到 (按照控制流分析), 则推导出的函数返回值类型是 never

// Return type: never
const failwith1 = function(message: string) {
  throw new Error(message);
}

// Return type: never
const failwith2 = (message: string) => {
  throw new Error(message);
}

4、这种规则并不适用于函数声明,这么做的原因为了向后兼容。因此,最合理的方式是明确的声明返回类型

// Return type: void
function failwith3(message: string) {
  throw new Error(message);
}

5、在一个明确指定了 never 返回值类型的函数中, 所有 return 语句 (如果有) 表达式的值必须为 never 类型, 且函数不应能执行到终止点

// 报错, 返回“never”的函数不能具有可访问的终结点
function typeWithNever(arg: string | number): never {
    let rst: never
    let rst1: string
    let rst2: number
    if (typeof arg === 'string') {
        return rst1 // 报错,不能将类型“string”分配给类型“never”
    } else if (typeof arg === 'number') {
        return rst2 // 报错, 不能将类型“number”分配给类型“never”
    }
}

使函数无法执行到终止点的方式有很多种,比如,创建永远都无法执行的条件分支;增加无限循环语句;抛出异常等 另外,每个分支返回类型必须是never,包括永远无法执行的分支

function typeWithNever(arg: string | number): never {
    let rst: never
    if (typeof arg === 'string') {
        return rst 
    } else if (typeof arg === 'number') {
        return rst 
    } else {
        return rst
    }
}

// or

function typeWithNever(arg: string | number): never {
    let rst: never
    let rst1: string
    let rst2: number
    if (typeof arg === 'string') {
        return rst
    } else if (typeof arg === 'number') {
        return rst
    } 

    while(true){

    }
}

// or

function typeWithNever(arg: string | number): never {
    let rst: never
    let rst1: string
    let rst2: number
    if (typeof arg === 'string') {
        return rst
    } else if (typeof arg === 'number') {
        return rst
    } 

    throw('get never')
}
  • 任意类型和never的交叉类型都是never

never类型是底部类型,是任意类型的子类型,所以任意类型和never的交叉类型都是never。

type T1 = number & never;   // never
type T2 = string & never;   // never
type T3 = 'a' & never;   // never
type T4 = 1 & never;   // never
type T5 = true & never;   // never
type T6 = any & never;   // never
type T7 = unknown & never;   // never
type T8 = never & never;   // never
  • 任意类型和never的联合类型都是其本身

可以分配给never的只有never,而且never可以分配给其他任何类型。所以联合never是没有意义的。就行+0跟没有添结果是一样的

type T1 = number | never;   // number
type T2 = string | never;   // string
type T3 = 'a' | never;   // 'a'
type T4 = 1 | never;   // 1
type T5 = true | never;   // true
type T6 = any | never;   // any
type T7 = unknown | never;   // unknown
type T8 = never | never;   // never
  • 应用场景

利用这个特性可以做很多事情,比如筛选。先把利用条件类型需要过滤的类型转换成never,然后利用联合类型过滤掉never

type Filter<T, U> = T extends U ? T : never

type R = Filter<'a' | 2 | false | 'b', string> // R = 'a'|'b'

按照分布式条件类型机制,Filter<'a' | 2 | false | 'b', string>将被分解成如下代码

type R = 
| ('a' extemds string ? 'a':never) 
| (2 extends string ? 2:never) 
| (false extends string ? false:never) 
| ('b' extends string ? 'b':never)

进一步将被解析,最终得到 'a' | 'b'

type R = 'a' | never | never | 'b' 
type R = 'a' | 'b'

三、unknown类型

unknown是所有类型的父类型,任何类型都是unknown的子类型。跟never类型相对的,unknown是顶部类型(Top Type),符号是(⊤)

  • unknown类型特点

因为unkonwn类型是顶部类型,是所有其他类型的父类型,所以它具备了以下特征:

1、任何类型的实例都可以分配给unknown

let a: unknown
a = Symbol('deep dark fantasy')
a = {}
a = false
a = '114514'
a = 1919n

2、unknown只能分配给unknown,不能分配给其他任何类型

let a: unknown
let b: string = a // 不能将类型“unknown”分配给类型“string”
let c: number = a // 不能将类型“unknown”分配给类型“number”
let d: boolean = a // 不能将类型“unknown”分配给类型“boolean”
let e: ()=>void = a // 不能将类型“unknown”分配给类型“() => void”
let f: symbol= a // 不能将类型“unknown”分配给类型“symbol”
let g: unknown = a // ok

3、任意类型和unknown的交叉类型都是其本身

type T = string & unknown // string
type T1 = number & unknown // number
type T2 = boolean & unknown // boolean
type T3 = symbol & unknown // symbol
type T4 = never & unknown // never
type T5 = any & unknown // any
type T6 = 'a' & unknown // 'a'
type T7 = 1 & unknown // 1
type T8 = false & unknown // false
type T9 = string[] & unknown // string[]

4、任意类型和unknown的联合类型都是unknown

type T = string | unknown // unknown
type T1 = number | unknown // unknown
type T2 = boolean | unknown // unknown
type T3 = symbol | unknown // unknown
type T4 = never | unknown // unknown
type T5 = any | unknown // unknown
type T6 = 'a' | unknown // unknown
type T7 = 1 | unknown // unknown
type T8 = false | unknown // unknown
type T9 = string[] | unknown // unknown

四、交叉类型(Intersection Types)

  • 定义

交叉类型用&操作符把几个类型的成员合并,形成一个拥有这几个类型所有成员的新类型。

  • 声明交叉类型
type I = A & B & C & D

需要注意的是,不能从字面上理解交叉类型,它不是几个类型的交集,而是具备所有类型成员的新类型。可以把操作符&理解成 and,A & B 表示同时包含 A 和 B 的所有成员,我们可以直接使用它,而不需要判断是否存在该属性\

  • 实例
interface IPerson {
    name: string;
    age: number;
}

interface IStudent {
    grade: number;
}

type IIT = IPerson & IStudent

/** IIT = {
    name: string;
    age: number;
    grade: number;
}*/

let user: IIT = {
    name: 'Joi',
    age: 12,
    grade: 6
}
  • 类型成员冲突

如果交叉类型的有相同的成员名称,原则上会继续交叉合并。下面通过实例验证一下

interface A {
    name: string;
    age: number;
    child: {
        name:string;
        age:number;
    }
}

interface B {
    name: string;
    age: number;
    child:{
        male:boolean
    }
}

type C = A & B

let c:C = {
    name:'zhangsan',
    age:28,
    child:{
        name:'lisi',
        age:6
    }
}

上面例子中,故意缺省male成员,下面是vs code编辑提示的错误信息。很显然交叉类型同名成员会继续进行交叉合并,最终child是两个类型的交叉类型。

  • 不存在的类型

需要注意,交叉类型的结果可能是不存在的类型,会推断为never类型。例如,基本类型string和number的交叉类型,显然是不存在既是string又是number类型的值,最终交叉类型会被推断为never类型。

举例说明,当把字符串类型实例“zhangsan”赋值给name属性,就会报“不能将类型“string”分配给类型“never”错误”

interface A {
    name: string;
    age: number;
}

interface B {
    name: number;
    age: number;
}

type C = A & B

let c: C = {
    name: 'zhangsan', // 不能将类型“string”分配给类型“never”
    age: 12
}

如果有兴趣,思考一下,下面这个问题。如果同名类型成员有readonly和?修饰符,又会发生什么呢?

interface IPerson {
    name: string;
    age: number;
}

interface IStudent {
    readonly name?: string;
    grade: number;
}

type IIT = IPerson & IStudent

let user: IIT = {
    age: 12,
    grade: 6
}

user.name = 'lisi'
  • 不同基本类型之间的交叉类型,或者不同字面量类型之间的交叉类型都是never

因为不存在同时属于两个基本类型的变量,所以不同基本类型交叉类型是不存在的类型never;不存在一个字面量属于两个不同的字面量类型,所以不同的字面量类型的交叉类型也是never

type T1 = 'A' & 'B'
type T2 = 1 & 2
type T3 = false & true
type T4 = string & boolean
type T5 = string & number
type T6 = string & symbol
type T7 = number & boolean
type T8 = number & symbol
type T9 = boolean & symbol

联合类型(Union Types)

  • 定义

联合类型(Union Types)可以通过管道(|)将变量设置多种类型。

  • 声明联合类型
type U = A | B

操作符“|”可以理解为“或”,类型A或者类型B,可以用 A | B表示。

  • 实例

看下面例子,我们声明了一个变量val,它的类型我们定义为string | number。这意味val可以赋值string类型的值,也可以赋值number类型的值

let val:string|number 
val = 12 
console.log("数字为 "+ val) 
let = "Hello" 
console.log("字符串为 " + val)
  • 应用场景

前面的讨论中,曾经频繁使用字面量联合类型。比如keyof操作符可以获取类型成员名称组成的联合类型;进而还可以用in操作符循环操作,映射成新的类型

type Person = {
    name:string;
    age:number;
    male:boolean;
}

type PsersonMap = {
    [P in kyeof Person]?:Person[P]]
}

/**
 * type Person = {
    name?:string;
    age?:number;
    male?:boolean;
}
*/
  • 辨析联合类型

当类型中含有字面量成员时,我们可以用该类型成员来辨析联合类型

下面例子,Square 和 Rectangle有共同成员kind,因此kind存在于他们的联合类型Shape中

interface Square {
  kind: 'square';
  size: number;
}

interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number;
}

type Shape = Square | Rectangle

应用中,通过一些操作符,例如==、===、!=、!==判断kind的值,实现相应的逻辑操作。Typescript能根据操作符和相应的判断条件推断出使用的具体类型,类型会相应的收窄为Square或者Rectangle。

function area(s: Shape) {
  if (s.kind === 'square') {
    // 现在 TypeScript 知道 s 的类型是 Square
    // 所以你现在能安全使用它
    return s.size * s.size;
  } else {
    // 不是一个 square ?因此 TypeScript 将会推算出 s 一定是 Rectangle
    return s.width * s.height;
  }
}

exhaustive check

这里再添加一个类型Circle,如果tsconfig.json配置中设置了noImplicitReturns为true,为了进一步明确类型,area函数中判断条件,需要调整如下格式。但是还是会提示警告“并非所有代码路径都返回值”。

// ....


interface Circle {
    kind: 'circle';
    radius: number;
}

type Shape11 = Square | Rectangle | Circle


// 报错, 并非所有代码路径都返回值
function area(s: Shape) {
  if (s.kind === 'square') {
    return s.size * s.size;
  } else if(s.kind === 'rectangle') {
    return s.width * s.height;
  }
}

前面讲过,永远不可能执行的条件判断中,类型会被收窄为never。可以利用这个机制,来保证逻辑中没有未实现的条件判断,确保使用安全。下面例子,没有实现Circle的条件判断,因此else分支中类型被收窄为Circle,而不是never,所以会提示错误:不能将类型“Circle”分配给类型“never”

function area(s: Shape) {
  if (s.kind === 'square') {
    return s.size * s.size;
  } else if(s.kind === 'rectangle') {
    return s.width * s.height;
  } else{
      const _exhaustiveCheck: never = s // 报错, 不能将类型“Circle”分配给类型“never”
      return _exhaustiveCheck
  }
}

switch

你可以通过 switch 来实现以上例子,效果跟if..else..判断是一样的

function area(s: Shape) {
  switch (s.kind) {
    case 'square':
      return s.size * s.size;
    case 'rectangle':
      return s.width * s.height;
    case 'circle':
      return Math.PI * s.radius ** 2;
    default:
      const _exhaustiveCheck: never = s;
  }
}

元组(Tuple Types)

数组是一个变长的,元素类型都相同的列表。与之相对应的,元组是一个描述固定长度,不同类型元素的数组。

  • 声明元组

元组的创建格式:

const tuple_name:[type1, type2, type3,...typen] = [value1,value2,value3,…value n]

一个声明并初始化的元组实例

const tp: [string, number, boolean] = ['a', 1, true]
  • 元组长度是固定的 比如给tp[3]赋值undefined,或提示错误:长度为 "3" 的元组类型 "[string, number, boolean]" 在索引 "3" 处没有元素
tp[3] = undefined
  • 元组索引位置上的类型顺序是固定的 比如给tp赋值调整一下顺序,会提示:不能将类型“number”分配给类型“string”  和 不能将类型“string”分配给类型“number”
const tp: [string, number, boolean] = [1, 'a', true]
  • 访问和更新元组

可以像访问数组那样,通过索引下标访问和更新元组中对应的元素

let n1 = tp[0] // 'a'
let n2 = tp[1] // 1

tp[0] = 'b'
tp[1] = 2

let n1 = tp[0] // 'b'
let n2 = tp[1] // 2
  • readonly修饰符限制元组

如果明确声明的元组是不可修改,可以采用readonly把元组声明为只读元组

const tp2: readonly [string, number, boolean] = ['a', 1, true]

tp2[0] = 'b' // 报错,无法分配到 "0" ,因为它是只读属性
  • 设置元组元素可选

类似与声明函数参数,可以通过?修饰符来定义元组的可选元素。例如,下面例子,把元素全部定义为可选,可以先初始化一个空的元组,后面再赋值。

const tp3: [string?, number?, boolean?] = []

tp3[0] = 'a'
tp3[1] = 1
tp3[2] = true
  • 元组具备数组的所有特征

元组本质是固定长度,不同元素类型的数组。所以元组具备数组所有方法和属性。如length、map、forEach、push、pop等。下面用keyof检测一下,可以直观的看到相关属性

type tpKeys = keyof typeof tp

/** type tpKeys = number | "0" | "1" | "2" | "length" | "toString" | "toLocaleString" | "pop" | "push" | "concat" | "join" | "reverse" | "shift" | "slice" | "sort" | "splice" | "unshift" | "indexOf" |......*/
  • 元组同样支持解构和rest语法

解构实例

var a =[10,"axihe"]
var [b,c] = a
console.log( b )
console.log( c )

rest语法实例

const tp4: [number, ...string[]] = [1, 'a', 'b', 'c']
// 说好的固定长度呢?

type TP = [string, number, boolean]
const tp5: [number, ...TP] = [1, 'a', 2, true]

关于Typescript的一些常用高级类型,先介绍到这里。后面我们将讨论类型编程函数-泛型,以及常用类型编程的技巧和工具