高级类型

—— 联合类型

Xebcnor     最近更新时间:2020-08-04 05:37:59

144

偶尔你会遇到这种情况,一个代码库希望传入numberstring类型的参数。 例如下面的函数:

/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: any) {
    if (typeof padding === "number") {
        return Array(padding + 1).join(" ") + value;
    }
    if (typeof padding === "string") {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

padLeft("Hello world", 4); // returns "    Hello world"

padLeft存在一个问题,padding参数的类型指定成了any。 这就是说我们可以传入一个既不是number也不是string类型的参数,但是TypeScript却不报错。

let indentedString = padLeft("Hello world", true); // 编译阶段通过,运行时报错

在传统的面向对象语言里,我们可能会将这两种类型抽象成有层级的类型。 这么做显然是非常清晰的,但同时也存在了过度设计。 padLeft原始版本的好处之一是允许我们传入原始类型。 这做的话使用起来既方便又不过于繁锁。 如果我们就是想使用已经存在的函数的话,这种新的方式就不适用了。

代替any, 我们可以使用联合类型做为padding的参数:

/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: string | number) {
    // ...
}

let indentedString = padLeft("Hello world", true); // errors during compilation

联合类型表示一个值可以是几种类型之一。 我们用竖线(|)分隔每个类型,所以number | string | boolean表示一个值可以是numberstring,或boolean

如果一个值是联合类型,我们只能访问此联合类型的所有类型里共有的成员。

interface Bird {
    fly();
    layEggs();
}

interface Fish {
    swim();
    layEggs();
}

function getSmallPet(): Fish | Bird {
    // ...
}

let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim();    // errors

这里的联合类型可能有点复杂,但是你很容易就习惯了。 如果一个值类型是A | B,我们只能确定它具有成员同时存在于AB里。 这个例子里,Bird具有一个fly成员。 我们不能确定一个Bird | Fish类型的变量是否有fly方法。 如果变量在运行时是Fish类型,那么调用pet.fly()就出错了。

类型保护与区分类型

联合类型非常适合这样的情形,可接收的值有不同的类型。 当我们想明确地知道是否拿到Fish时会怎么做? JavaScript里常用来区分2个可能值的方法是检查它们是否存在。 像之前提到的,我们只能访问联合类型的所有类型中共有的成员。

let pet = getSmallPet();

// 每一个成员访问都会报错
if (pet.swim) {
    pet.swim();
}
else if (pet.fly) {
    pet.fly();
}

为了让这码代码工作,我们要使用类型断言:

let pet = getSmallPet();

if ((<Fish>pet).swim) {
    (<Fish>pet).swim();
}
else {
    (<Bird>pet).fly();
}

用户自定义的类型保护

可以注意到我们使用了多次类型断言。 如果我们只要检查过一次类型,就能够在后面的每个分支里清楚pet的类型的话就好了。

TypeScript里的类型保护机制让它成为了现实。 类型保护就是一些表达式,它们会在运行时检查以确保在某个作用域里的类型。 要定义一个类型保护,我们只要简单地定义一个函数,它的返回值是一个类型断言

function isFish(pet: Fish | Bird): pet is Fish {
    return (<Fish>pet).swim !== undefined;
}

在这个例子里,pet is Fish就是类型断言。 一个断言是parameterName is Type这种形式,parameterName必须是来自于当前函数签名里的一个参数名。

每当使用一些变量调用isFish时,TypeScript会将变量缩减为那个具体的类型,只要这个类型与变量的原始类型是兼容的。

// 'swim' 和 'fly' 调用都没有问题了

if (isFish(pet)) {
    pet.swim();
}
else {
    pet.fly();
}

注意TypeScript不仅知道在if分支里petFish类型; 它还清楚在else分支里,一定不是Fish类型,一定是Bird类型。

typeof类型保护

我们还没有真正的讨论过如何使用联合类型来实现padLeft。 我们可以像下面这样利用类型断言来写:

function isNumber(x: any): x is number {
    return typeof x === "number";
}

function isString(x: any): x is string {
    return typeof x === "string";
}

function padLeft(value: string, padding: string | number) {
    if (isNumber(padding)) {
        return Array(padding + 1).join(" ") + value;
    }
    if (isString(padding)) {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

然而,必须要定义一个函数来判断类型是否是原始类型,这太痛苦了。 幸运的是,现在我们不必将typeof x === "number"抽象成一个函数,因为TypeScript可以将它识别为一个类型保护。 也就是说我们可以直接在代码里检查类型了。

function padLeft(value: string, padding: string | number) {
    if (typeof padding === "number") {
        return Array(padding + 1).join(" ") + value;
    }
    if (typeof padding === "string") {
        return padding + value;
    }
    throw new Error(`Expected string or number, got '${padding}'.`);
}

这些typeof类型保护只有2个形式能被识别:typeof v === "typename"typeof v !== "typename""typename"必须是"number""string""boolean""symbol"。 但是TypeScript并不会阻止你与其它字符串比较,或者将它们位置对换,且语言不会把它们识别为类型保护。

instanceof类型保护

如果你已经阅读了typeof类型保护并且对JavaScript里的instanceof操作符熟悉的话,你可能已经猜到了这节要讲的内容。

instanceof类型保护是通过其构造函数来细化其类型。 比如,我们借鉴一下之前字符串填充的例子:

interface Padder {
    getPaddingString(): string
}

class SpaceRepeatingPadder implements Padder {
    constructor(private numSpaces: number) { }
    getPaddingString() {
        return Array(this.numSpaces + 1).join(" ");
    }
}

class StringPadder implements Padder {
    constructor(private value: string) { }
    getPaddingString() {
        return this.value;
    }
}

function getRandomPadder() {
    return Math.random() < 0.5 ?
        new SpaceRepeatingPadder(4) :
        new StringPadder("  ");
}

// 类型为SpaceRepeatingPadder | StringPadder
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
    padder; // 类型细化为'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
    padder; // 类型细化为'StringPadder'
}

instanceof的右侧要求为一个构造函数,TypeScript将细化为:

  1. 这个函数的prototype属性,如果它的类型不为any的话
  2. 类型中构造签名所返回的类型的联合,顺序保持一至。

交叉类型

交叉类型与联合类型密切相关,但是用法却完全不同。 一个交叉类型,例如Person & Serializable & Loggable,同时是PersonSerializableLoggable。 就是说这个类型的对象同时拥有这三种类型的成员。 实际应用中,你大多会在混入中见到交叉类型。 下面是一个混入的例子:

function extend<T, U>(first: T, second: U): T & U {
    let result = <T & U>{};
    for (let id in first) {
        (<any>result)[id] = (<any>first)[id];
    }
    for (let id in second) {
        if (!result.hasOwnProperty(id)) {
            (<any>result)[id] = (<any>second)[id];
        }
    }
    return result;
}

class Person {
    constructor(public name: string) { }
}
interface Loggable {
    log(): void;
}
class ConsoleLogger implements Loggable {
    log() {
        // ...
    }
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();

类型别名

类型别名会给一个类型起个新名字。 类型别名有时和接口很像,但是可以作用于原始值,联合类型,元组以及其它任何你需要手写的类型。

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
    if (typeof n === 'string') {
        return n;
    }
    else {
        return n();
    }
}

起别名不会新建一个类型 - 它创建了一个新名字来引用那个类型。 给原始类型起别名通常没什么用,尽管可以做为文档的一种形式使用。

同接口一样,类型别名也可以是泛型 - 我们可以添加类型参数并且在别名声明的右侧传入:

type Container<T> = { value: T };

我们也可以使用类型别名来在属性里引用自己:

type Tree<T> = {
    value: T;
    left: Tree<T>;
    right: Tree<T>;
}

然而,类型别名不可能出现在声明右侧以外的地方:

type Yikes = Array<Yikes>; // 错误

接口 vs. 类型别名

像我们提到的,类型别名可以像接口一样;然而,仍有一些细微差别。

一个重要区别是类型别名不能被extendsimplements也不能去extendsimplements其它类型。 因为软件中的对象应该对于扩展是开放的,但是对于修改是封闭的,你应该尽量去使用接口代替类型别名。

另一方面,如果你无法通过接口来描述一个类型并且需要使用联合类型或元组类型,这时通常会使用类型别名。

字符串字面量类型

字符串字面量类型允许你指定字符串必须的固定值。 在实际应用中,字符串字面量类型可以与联合类型,类型保护和类型别名很好的配合。 通过结合使用这些特性,你可以实现类似枚举类型的字符串。

type Easing = "ease-in" | "ease-out" | "ease-in-out";
class UIElement {
    animate(dx: number, dy: number, easing: Easing) {
        if (easing === "ease-in") {
            // ...
        }
        else if (easing === "ease-out") {
        }
        else if (easing === "ease-in-out") {
        }
        else {
            // error! should not pass null or undefined.
        }
    }
}

let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy"); // error: "uneasy" is not allowed here

你只能从三种允许的字符中选择其一来做为参数传递,传入其它值则会产生错误。

Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

字符串字面量类型还可以用于区分函数重载:

展开阅读全文