TS 之 Union, Enum, Tuple 运用自如指南

3,794 阅读5分钟

前言

学习TypeScript有两个难点。一个是了解ts的语法。目前ts的语法特性很多,一些看起来复杂的问题,如果你了解了语法就会发现其实很简单,你只是不知道解决问题的对应语法特性而已。第二是了解类型特性。ts中的enum、union、tuple等类型提供了不同角度的类型声明方式。这些类型中有些是彼此之间相似程度比较高,有些也可以相互转化。了解这些类型越全面,对我们解决typescript中一些难题很有帮助。

TypeScript Basic Types

Tuple和Enum属于TypeScriptBasic Types中的一个。因为特性比较特殊,因此值得深入了解。

  • Boolean

  • Number

  • String

  • Array

  • Tuple

  • Enum

  • Unknown

  • Any

  • Void

  • Null and Undefined

  • Never

  • Object

Tuple

定义元组的方式很简单。

// Declare a tuple type
let x: [string, number];
// Initialize it
x = ["hello", 10]; // OK
// Initialize it incorrectly
x = [10, "hello"]; // Error

type NestedTuple = [string, Array<number>, [boolean, {name: string}]]
// 可以定义复杂的tuple

Tuple -> Union

Tuple转成Union很多见,简单写就是

type Tuple = [string, number, boolean]
type Union = Tuple[number] // string | number | boolean

抽成utility type则会是

type Tuple2Union<Tuple extends readonly unknown[]> = Tuple[number]

type Tuple = [string, number, boolean]
type Union = Tuple2Union<Tuple>

反过来 Union到Tuple基本上不会用到,所以忽略。

转换的具体场景比较多,一般在迭代的时候会用到这个技巧。 例如:我们想要通过tuple定义的类型map出一个对象。

type Tuple = ["blue", "yellow", "red"]
// 想要转换出{ blue: string, yellow: string, red: string }的一个结构体
type MappedTuple = {
    [k in Tuple[number]]: string
}

题外话 始终注意type中extends的对象。要分清楚哪个extends哪个

type Includes<T extends readonly any[], P> = P extends T[number] ? true: false
// correct!
type Includes<T extends readonly any[], P> = T[number] extends P ? true: false
// incorrect!

type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Wamuu'> // expected to be `false`

Enum

枚举是一种为一组数值提供更友好名称的方法。

enum Color {
  Red = 1,
  Green,
  Blue,
}
let c: Color = Color.Green;

enum的key得是string,其value要么是string,要么是number 这两个有一些区别,分开讨论。

1.数字枚举

enum Color {
  Red = 1,
  Green,
  Blue,
}

2.字符串枚举

enum CardinalDirection {
  North = 'N',
  East = 'E',
  South = 'S',
  West = 'W',
}

3.混合枚举

enum BooleanLikeHeterogeneousEnum {
  No = 0,
  Yes = "YES",
}

简而言之不要这么用。

特性1 不仅是type,还可以作为值来用

enum Color {
  Red = 1,
  Green,
  Blue,
}
上面👆定义的Color枚举 compile过后值为
{
    1: "Red",
    2: "Green",
    3: "Blue",
    Blue: 3,
    Green: 2,
    Red: 1,
}
let colorName: string = Color[2];
console.log(colorName); // 'Green'

enum CardinalDirection {
  North = 'N',
  East = 'E',
  South = 'S',
  West = 'W',
}
上面👆定义的CardinalDirection枚举 compile过后值为
{
    East: "E",
    North: "N",
    South: "S",
    West: "W",
}
注意数字枚举和字符串枚举最终值是不一样的。
enum完全可以作为object来用!

特性2 loose type-checking

数字枚举 会有loose type-checking的问题。举个例子

const color1: Color = 4 // Ok
const color2: Color.Red = 5 // Ok
const color3: Color ='6' // Error

我们期待的是上面三个表达式都会报错,但实际情况是只有最后一个会报type error错误。 造成这个问题的原因见 这里 。因此建议在使用enum的时候 尽量使用字符串枚举或者是坚决避免写如下的代码。

enum Color {
  Red = 1,
  Green,
  Blue,
}

const value1: Color = 3
// 不要这么写!
const value2: Color = Color.Blue
// 正确写法

function foo(arg: Color) {
  if (arg === 1) {
    // 不要这么写!
  }
  if (arg === Color.Red) {
    // 正确写法
  }
}

不过有意思的是当我们需要通过Color枚举创建一个ColorMap的时候,type-checking是正常的。

const ColorMap: {
 [key in Color]: string;
} = {
  1: 'red color',
  2: 'green color',
  3: 'blue color',
  4: 'x' // Error!
}

这里下标为4时typescript会报错。

const enumdeclare const

可以使用 const enum 的方式创建枚举。

const enum Direction {
  Up,
  Down,
  Left,
  Right,
}

let directions = [
  Direction.Up,
  Direction.Down,
  Direction.Left,
  Direction.Right,
];
// let directions = [
//     0 /* Up */,
//     1 /* Down */,
//     2 /* Left */,
//     3 /* Right */,
// ];
const value = Direction[Direction.Up] // 报错!

编译过后的版本如上,和不加const的区别是加了const编译过后Direction枚举不会作为值存在。所有以值的方式使用的地方都会转成对应的枚举值。这时候不能再将枚举当作object来使用了。

declare enum 的方式定义的枚举不能作为值来使用。

declare enum Direction {
  Up,
  Down,
  Left,
  Right,
}
let directions = [
  Direction.Up,
  Direction.Down,
  Direction.Left,
  Direction.Right,
];

上面的这种方式,typescript虽然不会显式的报错,但是typescript编译会失败。简而言之也不要这么使用枚举。

Enum -> Union

由于Enum有key,value两套东西,这里分别做个介绍。

EnumKey组成Union

经常有需要获取enum的key的union的场景。enum由于是有值的,因此转Union的过程和将对象的key转成union的场景一致。

enum CardinalDirection {
  North = 'N',
  East = 'E',
  South = 'S',
  West = 'W',
}
const DirectionObj = {
  North: 'N',
  East: 'E',
  South: 'S',
  West: 'W',
}

type Type1 = keyof typeof CardinalDirection // "North" | "East" | "South" | "West"
type Type2 = keyof typeof DirectionObj // "North" | "East" | "South" | "West"

数字枚举也一样,转的过程中不会出现数字枚举

enum Direction {
  Up,
  Down,
  Left,
  Right,
}
type Type = keyof typeof Direction // "Up" | "Down" | "Left" | "Right"

// 不会出现 0 | 1 | 2 | 3 | "Up" | "Down" | "Left" | "Right"
// 至于为啥会觉得可能出现这种type,你可以再往上看看数字枚举和字符串枚举的区别

EnumValue 组成Union

如何通过下面这个枚举获得 "N" | "E" | "S" | "W"这Union呢?

enum CardinalDirection {
  North = 'N',
  East = 'E',
  South = 'S',
  West = 'W',
}

方法也是有的,使用typescript的模版字符串。

type ValueUnion = `${CardinalDirection}`
// "N" | "E" | "S" | "W"

Union

union也很好理解,就是多个类型的“或”

function printId(id: number | string) {
  console.log("Your ID is: " + id);
}
// OK
printId(101);
// OK
printId("202");
// Error
printId({ myID: 22342 });
type UnionType = number | {} | string | '123' | 2312
// number | {} | string

下面定义的两个type有很大的区别。

type Union = Array<string | number> // (string | number)[]
type Tuple = [string, number]

在这里元组长度是固定的,为2且第一个元素是string,第二个元素是number。 但是Union类型不限制长度。并且每个元素都既可能是string又可能是number。两个表达的含义就不一样了。

Union在conditional types中使用

经常会以 xxx extends UnionType的形式出现,ts会帮我们做是否包含的判断。例如

type Nullish = null | undefined
type isNullish<T> = T extends Nullish ? true : false

type isNull = isNullish<null> // true
type isNull2 = isNullish<number> // false

// 我们可以再玩一些有意思的
type isNull3 = isNullish<null|undefined> // 这里依然是true

Union在mappd types中使用

Union经常会在mapped types中会用到。 首先下面的P的类型为"x" | "y"

type Point = { x: number; y: number };
type P = keyof Point; // "x" | "y"
type Union = "x" | "y"
type Obj = {
  [k in Union]: string
} // { x: string; y: string };

因此下面五种type map之后都是一样的

type Union = "x" | "y"
type Point = { x: number; y: number };
type Tuple = ["x", "y"]
enum EnumMapKey {
  x,
  y,
}
enum EnumMapValue {
  First = 'x',
  Second = 'y',
}

// 第一种
type Obj1 = {
  [k in Union]: string
}
// 第二种
type Obj2 = {
  [k in keyof Point]: string
}
// 第三种
type Obj3 = {
  [k in Tuple[number]]: string
}
// 第四种
type Obj4 = {
  [k in keyof typeof EnumMapKey]: string
}
// 第五种
type Obj5 = {
  [k in `${EnumMapValue}`]: string
}
或者是
type Obj5 = {
  [k in EnumMapValue]: string
}
// 效果是一样的。

Union在Template literal types中的使用

除了上面在将枚举中的value组成Union时用到Template literal types之外,它还可以和Union结合发挥强大力量。

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type ThreeDigits = `${Digit}${Digit}${Digit}`;
// "000" | "001" | "002" | "003" | "004" | "005" | "006" | "007" | "008" | "009" | "010" | "011" | "012" | "013" | "014" | "015" | "016" | "017" | "018" | "019" | "020" | "021" | "022" | ... 976 more ... | "999"

我们可以实现Union的排列组合了。不过值得注意的是转换之后的类型就转为了string,不再是number了。 如果你执行如下的操作,你会得到一个tserror。

type UniType = string | number | boolean | [number]
type Template = `${UniType}`

// Type 'UniType' is not assignable to type 'string | number | bigint | boolean | null | undefined'.

Union的成员必须得是简单类型。

小测试

假设我们有一个函数foo,接受两个参数,但是这个函数比较特殊,它要么接受一个string类型以及一个{name: string}类型,要么接受两个number类型的参数。那么如何定义这个函数类型。

foo('xx', {name: 'hello'}) // correct
foo(3232, 232) // correct
foo('xx', 123) // error!

一共有两种方案,答案如下

function foo(...args: [string, {name: string}] | [number, number]) {
}

foo('xx', {name: 'hello'})
foo(3232, 232)
foo('xx', 123) // error!

function bar(arg1: string, arg2: {name: string}): void
function bar(arg1: number, arg2: number): void
function bar(arg1: string | number, arg2: {name: string} | number) {

}

bar('xx', {name: 'hello'}) // correct
bar(3232, 232) // correct
bar('xx', 123) // error!

// 很明显第一种简单。