TypeScript 高級(jí)類(lèi)型

2022-04-21 09:21 更新

交叉類(lèi)型(Intersection Types)

交叉類(lèi)型是將多個(gè)類(lèi)型合并為一個(gè)類(lèi)型。 這讓我們可以把現(xiàn)有的多種類(lèi)型疊加到一起成為一種類(lèi)型,它包含了所需的所有類(lèi)型的特性。 例如, Person & Serializable & Loggable同時(shí)是PersonSerializableLoggable。 就是說(shuō)這個(gè)類(lèi)型的對(duì)象同時(shí)擁有了這三種類(lèi)型的成員。

我們大多是在混入(mixins)或其它不適合典型面向?qū)ο竽P偷牡胤娇吹浇徊骖?lèi)型的使用。 (在JavaScript里發(fā)生這種情況的場(chǎng)合很多?。?下面是如何創(chuàng)建混入的一個(gè)簡(jiǎn)單例子:

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();

聯(lián)合類(lèi)型

聯(lián)合類(lèi)型與交叉類(lèi)型很有關(guān)聯(lián),但是使用上卻完全不同。 偶爾你會(huì)遇到這種情況,一個(gè)代碼庫(kù)希望傳入 numberstring類(lèi)型的參數(shù)。 例如下面的函數(shù):

/**
 * 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存在一個(gè)問(wèn)題,padding參數(shù)的類(lèi)型指定成了any。 這就是說(shuō)我們可以傳入一個(gè)既不是 number也不是string類(lèi)型的參數(shù),但是TypeScript卻不報(bào)錯(cuò)。

let indentedString = padLeft("Hello world", true); // 編譯階段通過(guò),運(yùn)行時(shí)報(bào)錯(cuò)

在傳統(tǒng)的面向?qū)ο笳Z(yǔ)言里,我們可能會(huì)將這兩種類(lèi)型抽象成有層級(jí)的類(lèi)型。 這么做顯然是非常清晰的,但同時(shí)也存在了過(guò)度設(shè)計(jì)。 padLeft原始版本的好處之一是允許我們傳入原始類(lèi)型。 這樣做的話(huà)使用起來(lái)既簡(jiǎn)單又方便。 如果我們就是想使用已經(jīng)存在的函數(shù)的話(huà),這種新的方式就不適用了。

代替any, 我們可以使用聯(lián)合類(lèi)型做為padding的參數(shù):

/**
 * 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

聯(lián)合類(lèi)型表示一個(gè)值可以是幾種類(lèi)型之一。 我們用豎線(xiàn)( |)分隔每個(gè)類(lèi)型,所以number | string | boolean表示一個(gè)值可以是numberstring,或boolean。

如果一個(gè)值是聯(lián)合類(lèi)型,我們只能訪(fǎng)問(wèn)此聯(lián)合類(lèi)型的所有類(lèi)型里共有的成員。

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

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

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

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

這里的聯(lián)合類(lèi)型可能有點(diǎn)復(fù)雜,但是你很容易就習(xí)慣了。 如果一個(gè)值的類(lèi)型是 A | B,我們能夠確定的是它包含了AB中共有的成員。 這個(gè)例子里, Bird具有一個(gè)fly成員。 我們不能確定一個(gè) Bird | Fish類(lèi)型的變量是否有fly方法。 如果變量在運(yùn)行時(shí)是 Fish類(lèi)型,那么調(diào)用pet.fly()就出錯(cuò)了。

類(lèi)型保護(hù)與區(qū)分類(lèi)型

聯(lián)合類(lèi)型非常適合這樣的情形,可接收的值有不同的類(lèi)型。 當(dāng)我們想明確地知道是否拿到 Fish時(shí)會(huì)怎么做? JavaScript里常用來(lái)區(qū)分2個(gè)可能值的方法是檢查它們是否存在。 像之前提到的,我們只能訪(fǎng)問(wèn)聯(lián)合類(lèi)型的所有類(lèi)型中共有的成員。

let pet = getSmallPet();

// 每一個(gè)成員訪(fǎng)問(wèn)都會(huì)報(bào)錯(cuò)
if (pet.swim) {
    pet.swim();
}
else if (pet.fly) {
    pet.fly();
}

為了讓這段代碼工作,我們要使用類(lèi)型斷言:

let pet = getSmallPet();

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

用戶(hù)自定義的類(lèi)型保護(hù)

可以注意到我們使用了多次類(lèi)型斷言。 如果我們只要檢查過(guò)一次類(lèi)型,就能夠在后面的每個(gè)分支里清楚 pet的類(lèi)型的話(huà)就好了。

TypeScript里的類(lèi)型保護(hù)機(jī)制讓它成為了現(xiàn)實(shí)。 類(lèi)型保護(hù)就是一些表達(dá)式,它們會(huì)在運(yùn)行時(shí)檢查以確保在某個(gè)作用域里的類(lèi)型。 要定義一個(gè)類(lèi)型保護(hù),我們只要簡(jiǎn)單地定義一個(gè)函數(shù),它的返回值是一個(gè) 類(lèi)型斷言

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

在這個(gè)例子里,pet is Fish就是類(lèi)型謂詞。 謂詞是 parameterName is Type這種形式,parameterName必須是來(lái)自于當(dāng)前函數(shù)簽名里的一個(gè)參數(shù)名。

每當(dāng)使用一些變量調(diào)用isFish時(shí),TypeScript會(huì)將變量縮減為那個(gè)具體的類(lèi)型,只要這個(gè)類(lèi)型與變量的原始類(lèi)型是兼容的。

// 'swim' 和 'fly' 調(diào)用都沒(méi)有問(wèn)題了

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

注意TypeScript不僅知道在if分支里petFish類(lèi)型; 它還清楚在 else分支里,一定不是Fish類(lèi)型,一定是Bird類(lèi)型。

typeof類(lèi)型保護(hù)

現(xiàn)在我們回過(guò)頭來(lái)看看怎么使用聯(lián)合類(lèi)型書(shū)寫(xiě)padLeft代碼。 我們可以像下面這樣利用類(lèi)型斷言來(lái)寫(xiě):

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}'.`);
}

然而,必須要定義一個(gè)函數(shù)來(lái)判斷類(lèi)型是否是原始類(lèi)型,這太痛苦了。 幸運(yùn)的是,現(xiàn)在我們不必將 typeof x === "number"抽象成一個(gè)函數(shù),因?yàn)門(mén)ypeScript可以將它識(shí)別為一個(gè)類(lèi)型保護(hù)。 也就是說(shuō)我們可以直接在代碼里檢查類(lèi)型了。

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類(lèi)型保護(hù)*只有兩種形式能被識(shí)別:typeof v === "typename"typeof v !== "typename","typename"必須是"number","string","boolean""symbol"。 但是TypeScript并不會(huì)阻止你與其它字符串比較,語(yǔ)言不會(huì)把那些表達(dá)式識(shí)別為類(lèi)型保護(hù)。

instanceof類(lèi)型保護(hù)

如果你已經(jīng)閱讀了typeof類(lèi)型保護(hù)并且對(duì)JavaScript里的instanceof操作符熟悉的話(huà),你可能已經(jīng)猜到了這節(jié)要講的內(nèi)容。

instanceof類(lèi)型保護(hù)是通過(guò)構(gòu)造函數(shù)來(lái)細(xì)化類(lèi)型的一種方式。 比如,我們借鑒一下之前字符串填充的例子:

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("  ");
}

// 類(lèi)型為SpaceRepeatingPadder | StringPadder
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
    padder; // 類(lèi)型細(xì)化為'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
    padder; // 類(lèi)型細(xì)化為'StringPadder'
}

instanceof的右側(cè)要求是一個(gè)構(gòu)造函數(shù),TypeScript將細(xì)化為:

  1. 此構(gòu)造函數(shù)的prototype屬性的類(lèi)型,如果它的類(lèi)型不為any的話(huà)
  2. 構(gòu)造簽名所返回的類(lèi)型的聯(lián)合

以此順序。

類(lèi)型別名

類(lèi)型別名會(huì)給一個(gè)類(lèi)型起個(gè)新名字。 類(lèi)型別名有時(shí)和接口很像,但是可以作用于原始值,聯(lián)合類(lèi)型,元組以及其它任何你需要手寫(xiě)的類(lèi)型。

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

起別名不會(huì)新建一個(gè)類(lèi)型 - 它創(chuàng)建了一個(gè)新名字來(lái)引用那個(gè)類(lèi)型。 給原始類(lèi)型起別名通常沒(méi)什么用,盡管可以做為文檔的一種形式使用。

同接口一樣,類(lèi)型別名也可以是泛型 - 我們可以添加類(lèi)型參數(shù)并且在別名聲明的右側(cè)傳入:

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

我們也可以使用類(lèi)型別名來(lái)在屬性里引用自己:

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

然而,類(lèi)型別名不能夠出現(xiàn)在聲名語(yǔ)句的右側(cè):

type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
    name: string;
}

var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;

然而,類(lèi)型別名不能出現(xiàn)在聲明右側(cè)的任何地方。

type Yikes = Array<Yikes>; // error

接口 vs. 類(lèi)型別名

像我們提到的,類(lèi)型別名可以像接口一樣;然而,仍有一些細(xì)微差別。

其一,接口創(chuàng)建了一個(gè)新的名字,可以在其它任何地方使用。 類(lèi)型別名并不創(chuàng)建新名字—比如,錯(cuò)誤信息就不會(huì)使用別名。 在下面的示例代碼里,在編譯器中將鼠標(biāo)懸停在 interfaced上,顯示它返回的是Interface,但懸停在aliased上時(shí),顯示的卻是對(duì)象字面量類(lèi)型。

type Alias = { num: number }
interface Interface {
    num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;

另一個(gè)重要區(qū)別是類(lèi)型別名不能被extendsimplements(自己也不能extendsimplements其它類(lèi)型)。 因?yàn)?nbsp;軟件中的對(duì)象應(yīng)該對(duì)于擴(kuò)展是開(kāi)放的,但是對(duì)于修改是封閉的,你應(yīng)該盡量去使用接口代替類(lèi)型別名。

另一方面,如果你無(wú)法通過(guò)接口來(lái)描述一個(gè)類(lèi)型并且需要使用聯(lián)合類(lèi)型或元組類(lèi)型,這時(shí)通常會(huì)使用類(lèi)型別名。

字符串字面量類(lèi)型

字符串字面量類(lèi)型允許你指定字符串必須的固定值。 在實(shí)際應(yīng)用中,字符串字面量類(lèi)型可以與聯(lián)合類(lèi)型,類(lèi)型保護(hù)和類(lèi)型別名很好的配合。 通過(guò)結(jié)合使用這些特性,你可以實(shí)現(xiàn)類(lèi)似枚舉類(lèi)型的字符串。

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

你只能從三種允許的字符中選擇其一來(lái)做為參數(shù)傳遞,傳入其它值則會(huì)產(chǎn)生錯(cuò)誤。

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

字符串字面量類(lèi)型還可以用于區(qū)分函數(shù)重載:

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
    // ... code goes here ...
}

可辨識(shí)聯(lián)合(Discriminated Unions)

你可以合并字符串字面量類(lèi)型,聯(lián)合類(lèi)型,類(lèi)型保護(hù)和類(lèi)型別名來(lái)創(chuàng)建一個(gè)叫做可辨識(shí)聯(lián)合的高級(jí)模式,它也稱(chēng)做標(biāo)簽聯(lián)合代數(shù)數(shù)據(jù)類(lèi)型。 可辨識(shí)聯(lián)合在函數(shù)式編程很有用處。 一些語(yǔ)言會(huì)自動(dòng)地為你辨識(shí)聯(lián)合;而TypeScript則基于已有的JavaScript模式。 它具有4個(gè)要素:

  1. 具有普通的字符串字面量屬性—可辨識(shí)的特征。
  2. 一個(gè)類(lèi)型別名包含了那些類(lèi)型的聯(lián)合—聯(lián)合
  3. 此屬性上的類(lèi)型保護(hù)。
interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}

首先我們聲明了將要聯(lián)合的接口。 每個(gè)接口都有 kind屬性但有不同的字符器字面量類(lèi)型。 kind屬性稱(chēng)做可辨識(shí)的特征標(biāo)簽。 其它的屬性則特定于各個(gè)接口。 注意,目前各個(gè)接口間是沒(méi)有聯(lián)系的。 下面我們把它們聯(lián)合到一起:

type Shape = Square | Rectangle | Circle;

現(xiàn)在我們使用可辨識(shí)聯(lián)合:

function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

完整性檢查

當(dāng)沒(méi)有涵蓋所有可辨識(shí)聯(lián)合的變化時(shí),我們想讓編譯器可以通知我們。 比如,如果我們添加了 TriangleShape,我們同時(shí)還需要更新area:

type Shape = Square | Rectangle | Circle | Triangle;
function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
    // should error here - we didn't handle case "triangle"
}

有兩種方式可以實(shí)現(xiàn)。 首先是啟用 --strictNullChecks并且指定一個(gè)返回值類(lèi)型:

function area(s: Shape): number { // error: returns number | undefined
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

因?yàn)?code>switch沒(méi)有包涵所有情況,所以TypeScript認(rèn)為這個(gè)函數(shù)有時(shí)候會(huì)返回undefined。 如果你明確地指定了返回值類(lèi)型為 number,那么你會(huì)看到一個(gè)錯(cuò)誤,因?yàn)閷?shí)際上返回值的類(lèi)型為number | undefined。 然而,這種方法存在些微妙之處且 --strictNullChecks對(duì)舊代碼支持不好。

第二種方法使用never類(lèi)型,編譯器用它來(lái)進(jìn)行完整性檢查:

function assertNever(x: never): never {
    throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
        default: return assertNever(s); // error here if there are missing cases
    }
}

這里,assertNever檢查s是否為never類(lèi)型—即為除去所有可能情況后剩下的類(lèi)型。 如果你忘記了某個(gè)case,那么 s將具有一個(gè)趕寫(xiě)的類(lèi)型,因此你會(huì)得到一個(gè)錯(cuò)誤。 這種方式需要你定義一個(gè)額外的函數(shù)。

多態(tài)的this類(lèi)型

多態(tài)的this類(lèi)型表示的是某個(gè)包含類(lèi)或接口的子類(lèi)型。 這被稱(chēng)做 F-bounded多態(tài)性。 它能很容易的表現(xiàn)連貫接口間的繼承,比如。 在計(jì)算器的例子里,在每個(gè)操作之后都返回 this類(lèi)型:

class BasicCalculator {
    public constructor(protected value: number = 0) { }
    public currentValue(): number {
        return this.value;
    }
    public add(operand: number): this {
        this.value += operand;
        return this;
    }
    public multiply(operand: number): this {
        this.value *= operand;
        return this;
    }
    // ... other operations go here ...
}

let v = new BasicCalculator(2)
            .multiply(5)
            .add(1)
            .currentValue();

由于這個(gè)類(lèi)使用了this類(lèi)型,你可以繼承它,新的類(lèi)可以直接使用之前的方法,不需要做任何的改變。

class ScientificCalculator extends BasicCalculator {
    public constructor(value = 0) {
        super(value);
    }
    public sin() {
        this.value = Math.sin(this.value);
        return this;
    }
    // ... other operations go here ...
}

let v = new ScientificCalculator(2)
        .multiply(5)
        .sin()
        .add(1)
        .currentValue();

如果沒(méi)有this類(lèi)型,ScientificCalculator就不能夠在繼承BasicCalculator的同時(shí)還保持接口的連貫性。 multiply將會(huì)返回BasicCalculator,它并沒(méi)有sin方法。 然而,使用 this類(lèi)型,multiply會(huì)返回this,在這里就是ScientificCalculator

以上內(nèi)容是否對(duì)您有幫助:
在線(xiàn)筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)