typescript 類型系統從無知到失智

類型系統是 typescript 最吸引人的特性之一,但它的強大也讓我們又愛又恨,每個前端同學在剛從 javascript 切換到 typescript 時都會有一段手足無措的時光,爲了不讓編譯器報錯恨不得把所有變量都標註成 any 類型,而後在不斷地填坑中流下悔恨的淚水。
謹以此文記錄我在學習 typescript 類型系統使用方法的過程中遇到的一些問題,以供大家參考,避免大腦進水。
(本文默認所有讀者熟知活動所有 ES6 語法及特性)

0x00 無知

類型聲明

基礎類型

直接將類型附在變量聲明之後,並以冒號分隔。


const count: number = 3;

const name: string = 'apples';



const appleCounts: string = count + name;



assert.strictEqual(appleCounts, '3apples');

引用類型

稍微複雜一點的,如數組 & 對象,按照其內成員結構進行聲明。


const object: { a: number } = { a: 1, };

const array1: number[] = [1,];

const array2: Array<number> = [2,];

函數類型

寫法極像箭頭函數。


const someFn: (str: string) => number = function (str: string) {

    return +str;

}

typescript 擴展的輔助類型

any

啥都能放,但並不建議大量使用,如果所有變量都是 any,那 typescript 跟 javascript 又有什麼區別呢。


const variable: any = '5';

void

空類型,一般用來表示函數沒有返回值。


function someFn (): void {
    1 + 1;
}

類型並聯

當一個變量可能是多種類型時,使用 | 進行多個不同類型的分隔:


const nos: number | string = '1';

在引用類型中使用

錯誤用法

一定,一定,一定 不要用 , 作分隔符。


const object: { a: [number, string] } = { a: 1, };

正確用法


const object: { a: number | string } = { a: 1, };
const array1: (number | string)[] = [ 1, ];
const array2: Array<number | string> = [ 2, ];


type numberOstring = number | string;
const object: { a: numberOstring } = { a: 'yes', };
const array1: numberOstring[] = [ 'yes', ];
const array2: Array<numberOstring> = [ 'yes', ];

在函數入參 & 出參中聲明類型

函數的入參類型跟聲明變量的類型時差不多,直接在變量名後跟類型名稱就可以了。

返回值的類型則跟在參數的括號後面,冒號後面跟一個返回值的類型。


function someFn (arg1: string, arg2: number): boolean {
    return +arg1 > arg2;
}

箭頭函數

參數與返回值的聲明方法與普通函數無二。


setTimeout((): void => {

    console.log('six six six');

}, 50);

在類中聲明類型

實例屬性記得一定要初始化。


class SomeClass {

    a: number = 1;

    b: string;

    static a: boolean;



    constructor () {
        this.b = 'str';
    }



    method (str: string): number {

        return +str;

    }

}

類型轉換

當你在聲明一個普通的對象(其他類型也有可能,此處僅使用對象作爲例子)時,typescript 並不會自動爲你添加上對應的類型,這會造成你在賦值時觸發 TS2322: Type 'xxx' is not assignable to type 'xxx'. 錯誤,此時就需要使用顯式類型轉換來將兩邊的類型差異抹平。

類型轉換的前提是當前類型是真的可以轉換爲目標類型的,任何必選屬性的缺失,或是根本無法轉換的類型都是不允許的。

<type> 尖括號轉換

並不推薦這種方法,因爲有時編輯器會把它當成 jsx 處理,產生不必要的 warning。


const object1: object = {
    a: 1,
};
const object2: { a: number } = <{ a: number }>object1;

as 表達式轉換


const object1: object = {
    a: 1,
};
const object2: { a: number } = object1 as { a: number };

0x01 理智

自定義類型

相當於聯結多個不同類型,併爲他們創造一個假名,平時寫多個類型並聯實在是太累了的時候可以試試這個方法。

它的值可以是類型,也可以是具體的變量值。


type NumberOrString = number | string;

type Direction = 'Up' | 'Right' | 'Down' | 'Left';



const num: NumberOrString = 1;

const dir: Direction = 'Up';

枚舉

官方

不知道爲什麼,我在使用過程中總是會有這樣那樣的錯誤,導致我平時不用這種方法,但還是推薦大家用這種方法。

注意:枚舉內部賦值時如果爲數字,可以只賦值第一個,後面幾個會隨之遞增,如果爲字符串,則需要全部賦值,否則就會報錯。


enum Direction {

    Up = 1,

    Right,

    Down,

    Left

}



const dir: Direction = Direction.Up;

我的方法

缺點是沒有類型提示,不能定義枚舉的內部值,判斷的時候也必須用對應的字符串,優點是我用的時候沒有報錯(汗。


type Direction = 'Up' | 'Right' | 'Down' | 'Left';
const dir: Direction = 'Up';

接口

基礎

聲明一種類型的對象,該類型的變量都必須滿足該結構要求。


interface SomeInterface {

    str: string;

    num: number;

}



const object: SomeInterface = {

    str: 'str',

    num: 1,

};



class SomeClass implements SomeInterface {

    num = 1;



    constructor () {

        this.str = 'str';

    }

}

多重實現

同一個類可以實現多個不同的接口,但前提是該類一定要實現每個接口所要求的屬性。


interface Interface1 {

    str: string;

}



interface Interface2 {

    num: number;

}



class SomeClass implements Interface1, Interface2 {

    num = 1;



    constructor () {

        this.str = 'str';

    }

}

聲明合併

在多個不同文件,或是相同文件的不同位置聲明的同名接口,將會被合併成一個接口,名稱不變,成員變量取並集。


interface SomeInterface {

    str: string;

}



interface SomeInterface {

    num: number;

}

// 必須全部實現

const someInterface: SomeInterface = {

    str: 'str',

    num: 1,

};

函數接口

基礎使用


interface InterfaceFn {

    (str: string): boolean;

}



const fn1: InterfaceFn = (str: string): boolean => {

    return 10 < str.length;

};

當該類函數還具有成員變量和方法時


interface InterfaceFn {
    (str: string): boolean;
    standard: string;
    someFn(num: number): string;
}


// 必須進行顯式類型轉換
let fn1: InterfaceFn = function (str: string): boolean {
    return 10 < str.length;
} as InterfaceFn;


fn1.standard = 'str';


fn1.someFn = function (num: number): string {
    return `${num}`;
};

繼承

接口可以繼承類或是另一個接口,與 ES6 繼承方法語法一樣,在此不再贅述。

函數 & 接口的缺省值及可選項

當該參數爲可選項時,可以在名稱與類型表達式的冒號之間加一個問號 ? 用來表示該參數爲 __可選項__。


function someFn (arg1: number, arg2?: string): void {}



someFn(1);

當該參數在不傳的時候有 缺省值 時,可以使用 = 來爲其賦予 __缺省值__。


function someFn (arg1: number, arg2: number = 1): number {

    return arg1 + arg2;

}



someFn(1);    // 2

可選項與 缺省值 可以混搭。


function someFn (arg1: number, arg2: string = 'str', arg3?: string): void {}



someFn(1);

可選項 參數後不可跟任何 非可選項 參數。(以下代碼當場爆炸)


function someFn (arg1: number, arg2?: string, arg3: string = 'str'): void {}


someFn(1);

可選項與 缺省值 不可同時使用在同一個值上。(以下代碼當場爆炸)


function someFn (arg1: number, arg2?: string = 'str'): void {}


someFn(1);

可選項 也可用在接口的聲明中(__缺省值__ 不行,因爲接口是一種類型的聲明,並非具體實例的實現)。

泛型

泛型函數

基礎類型


function someFn<T> (arg:T): T {

    return arg;

}



const str1: string = someFn<string>('str1');

const str2: string = someFn('str2');

同時使用多個泛型


function someFn<T, U> (arg1: T, arg2: U): T | U {
    return arg1;
}


const num1: string | number = someFn<string, number>('str1', 1);
const str2: string | number = someFn('str2', 2);

箭頭函數 & 泛型類型


const someFn: <T>(arg: T) => T = <T>(arg: T): T => {
    return arg;
};


const str: string = someFn('str');

泛型接口

基礎用法

interface InterfaceFn {
    <T>(arg: T): T;
}
const someFn: InterfaceFn = <T>(arg: T): T => {
    return arg;
};
const str: string = someFn('str');
泛型字面量

const someFn: { <T>(arg: T): T; } = <T>(arg: T): T => {
    return arg;
};
const str: string = someFn('str');
接口泛型

interface InterfaceFn<T> {
    (arg: T): T;
}
const someFn: InterfaceFn<string> = <T>(arg: T): T => {
    return arg;
};
const str: string = someFn('str');

泛型類

原理與 接口泛型 一樣。


class SomeClass<T> {

    someMethod (arg: T): T {

        return arg;

    }

}

泛型約束


function someFn<T extends Date>(arg: T): number {
    return arg.getTime();
}
const date = new Date();

keyofRecordPickPartial 太過複雜,真有需求還請自行查閱文檔。

.d.ts 文件

在實際編碼過程中,我們經常會定義很多自定義的 接口 與 __類__,如果我們在聲明變量的類型時需要用到它們,就算我們的代碼中並沒有調用到它們的實例,我們也必須手動引入它們(最常見的例子是各種包裝類,他們並不會處理參數中傳入的變量,但他們會在接口上強規範參數的類型,並且會將該變量透傳給被包裝的類的對應方法上)。


// foo.ts



export default class FooClass {

    propA: number = 5;

}

// bar.ts

import FooClass from './foo.ts';



export class BarClass {

     foo?: FooClass;

}

這種使用方法在調用數量較少時尚且可以接受,但隨着業務的成長,被引用類型的數量和引用類型文件的數量同時上升,需要付出的精力便會隨其呈現出 o(n^2) 的增長趨勢。

這時我們可以選擇使用 .d.ts 文件,在你的業務目錄中創建 typings 文件夾,並在其內新建屬於你的 xxx.d.ts 文件,並在其中對引用較多的類和接口進行聲明(.d.ts 文件是用來進行接口聲明的,不要在 .d.ts 文件裏對聲明的結構進行實現)。

注意:.d.ts 文件只是用於分析類型聲明及傳參校驗,如果需要進行調用,還請直接 import 對應模塊。


// my-types.d.ts



export default class FooClass {

    propA: number;



    methodA (arg1: string): void;

}

foo.ts 文件略


// bar.ts

// 不需要再 import 了



export default class BarClass {

    foo?: FooClass;

}

其他類型的聲明方式


// my-types.d.ts

// 接口(沒有任何變化)

interface InterfaceMy {

    propA: number;

}

// 函數

function myFn (arg1: string): number;

// 類型

type myType = number | string;

0x02 反思

本來想寫一寫常見的 TS 編譯錯誤及造成這些錯誤的原因來着,後來想了想,編譯出錯了都不會查,還寫什麼 TS 啊,

0xFF 失智

以下幾點是我在使用 typescript 類型系統過程中遇到的一些智障問題與未解的疑問,歡迎大家一起討論。

爲 window 變量增添屬性的各種姿勢

錯誤的爲 window 增添屬性的姿勢:


window.someProperty = 1;

會觸發 TS2339: Property 'xxx' does not exist on type 'Window' 錯誤。

類型轉換法


(window as any).someProperty = 1;

(<any>window).someProperty = 1;

接口擴展法

利用接口可以多處聲明,由編譯器進行合併的特性進行 hack。


interface Window {

    someProperty: number;

}



window.someProperty = 1;

爲什麼對象類型最後不能跟尾逗號

下面的代碼當場爆炸(因爲 c: number, 最後的這個逗號)。


const someObject: { a: number, b: number, c: number, } = { a: 1, b: 2, c: 3, };

函數重載

當一個函數在入參不同時有較大的行爲差距時,可以使用函數重載梳理代碼結構。

注意:參數中有回調函數時,回調函數的參數數量變化並不應該導致外層函數使用重載,只應當在當前聲明函數的參數數量有變時才使用重載。

當同時聲明多個重載時,較爲準確的重載應該放在更前面。

使用說明

重載的使用方法比較智障,需要先 聲明 這個函數的不同重載方式,然後緊接着再對這個函數進行定義。

定義時的參數個數取不同重載方法中參數個數最少的數量,隨後在其後追加 ...args: any[](或者更爲準確的類型定義寫法),用於接收多餘的參數。

定義的返回值爲所有重載返回值的並集。

而後在函數體內部實現時,通過判斷參數類型,自行實現功能的分流。

問題

神奇的是 typescript 並不會校驗重載的實現是否會真的在調用某個重載時返回這個重載真正要求的類型的值,下方例子中即使無論觸發哪個重載,都會返回 number,也不會被 typescript 檢查出來。

猜想:多次聲明一次實現難道是受制於 javascript 既有的語言書寫格式?

函數重載 of 類成員方法


class SomeClass {
    someMethod (arg1: number, arg2: string, arg3: boolean): boolean;
    someMethod (arg1: number, arg2: string): string;
    someMethod (arg1: { arg1: number, arg2: string, }): number;


    someMethod (x: any, ...args: any[]): string | number | boolean {
        if ('object' === typeof x) {
            return 1;
        } else if (1 === args.length) {
            return 1;
        } else {
            return 1;
        }
    }
}

函數重載 of 函數


function someFn (arg1: number, arg2: string, arg3: boolean): boolean;
function someFn (arg1: number, arg2: string): string;
function someFn (arg1: { arg1: number, arg2: string, }): number;


function someFn (x: any, ...args: any[]): string | number | boolean {
    if ('object' === typeof x) {
        return 1;
    } else if (1 === args.length) {
        return 1;
    } else {
        return 1;
    }
}

對象 key 的類型定義(索引標記)

可以使用 typeinterfaceclass 對象 key,但是使用方法十分麻煩,而且語法還不太一樣(type 使用 ininterfaceclass 使用 :)。

注意:索引值只可以使用數字與字符串。

type 法

限定 key 的數據類型

其實就是放開了限制,讓該類型的實例上可以添加各種各樣的屬性。

這裏冒號 : 形式的不允許使用問號(可選項),但 in 形式的允許使用問號(可選項)。

但其實帶不帶結果都一樣,實例都可以爲空。


type SomeType1 = {
    [key: string]: string;
}


type SomeType2 = {
    [key in string]?: string;
}



const instance1: SomeType1 = {};
const instance2: SomeType2 = {};

限定 key 只可以使用特定值

這裏其中的 key 就成了必選項了,問號(可選項)也有效果了。


type audioTypes = 'ogg' | 'mp3' | 'wma';


type SomeType1 = {
    [key in audioTypes]: string;
}


type SomeType2 = {
    [key in audioTypes]?: string;
}



const instance5: SomeType1 = {
    'ogg': 'ogg',
    'mp3': 'mp3',
    'wma': 'wma',
};

const instance6: SomeType2 = {};

interface 法

限定 key 的數據類型

不可以用問號。


interface SomeInterface {
    [key: string]: string;
}


const instance: SomeInterface = {};

限定 key 只可以使用特定值

只能通過 extends 已定義的 type 來實現。


type audioTypes = 'ogg' | 'mp3' | 'wma';


type SomeType = {
    [key in audioTypes]: string;
}


interface SomeInterface extends SomeType {}


const instance: SomeInterface = {

    ogg: 'ogg',
    mp3: 'mp3',
    wma: 'wma',
};

class 中的使用

限定 key 的數據類型

同樣也不可以使用問號(可選值)。


class SomeClass {
    [key: string]: string;
}
const instance: SomeClass = new SomeClass();

限定 key 只可以使用特定值

通過 implements 其他的 interfacetype 實現(多重實現可以合併)。

請記得 interface 只是數據格式規範,implements 之後要記得在 class 裏寫實現


type audioTypes = 'ogg' | 'mp3' | 'wma';


type SomeType = {
    [key in audioTypes]: string;
}


interface SomeInterface {
    [key: string]: string;
}


class ClassExtended implements SomeInterface, SomeType {
    ogg = 'ogg';
    mp3 = 'mp3';
    wma = 'wma';
    [key: string]: string;
}


const instance = new ClassExtended();

如何初始化函數類變量?

  1. 初始化函數類變量時,是否需要既給左值聲明類型,也給右值聲明類型?
  2. 這樣的語句應如何斷句換行 & 換行後如何縮進?

const someFn: (input: number, target: object) => SomeClass = (input: number, target: object): SomeClass => {

    // ... do sth

};

類型聲明 & npm 包版本

我們在平時使用一些類庫時,某一生態環境下的多個包,可能會依賴同一個基礎包。同一個生態環境下的包,更新節奏或快或慢,此時便可能會存在基礎包版本不同的問題,npm 的解決方案是多版本共存,每個包引用自己對應版本的基礎包。因爲 typescript 的類型是基於文件進行定義的,內部結構完全相同的兩個同名類型,在不同的文件中聲明便成了不同的類型。

此處以 @forawesome 項目組下的 fontawesome 庫進行舉例,具體示例如下:

當我們在 vue 中使用 fortawesome 時,需要把圖標文件從對應的包中導出(如免費基礎包:@fortawesome/free-solid-svg-icons、免費公司 logo 包:@fortawesome/free-brands-svg-icons),並使用 @fortawesome/fontawesome-svg-core 模塊的 library 方法導入到 vue 的運行環境中。


import {

    faVolumeUp,
    faPlay,
    faPause,

} from '@fortawesome/free-solid-svg-icons';



import {
    faWeibo,
    faWeixin,
} from '@fortawesome/free-brands-svg-icons';



library.add(

    faVolumeUp,

    faPlay,

    faPause,

    faWeibo,

    faWeixin

);

但我再剛開始開發時只使用了基礎包,公司 logo 包是我在開發途中用到時才引入的,但這時 fortawesome 官方對整個庫進行了版本升級,具體功能並沒有什麼改變,只是 fix 了一些 bug,版本號也只升級了一個小版本。

但在編譯時 library.add 這裏報告了錯誤:


TS2345: Argument of type 'IconDefinition' is not assignable to parameter of type 'IconDefinitionOrPack'.
  Type 'IconDefinition' is not assignable to type 'IconPack'.`

經過跟進發現:

@forawesome/fontawesome-svg-corelibrary.add 的參數所要求的 IconDefinition 類型來自頂層 node_modules 安裝的公用的 @fortawesome/fontawesome-common-types 包的 index.d.ts 文件。

@fortawesome/free-brands-svg-icons 中字體的類型 IconDefinition 來自 @fortawesome/free-brands-svg-icons 自身內部 node_modules 裏安裝的高版本的 @fortawesome/fontawesome-common-typesindex.d.ts 文件。

雖然兩個類型的定義一模一樣,但因爲不是同一個文件定義的,所以是完全不同的兩種類型,因而造成了類型不匹配,無法正常編譯。

遇到這種問題時,升級對應包的版本就可以了。

複雜泛型嵌套的生成方法與可讀性

talk is cheap, show you the dunce.

節選自 vue/types/vue.d.ts,我已經看暈了,調用方想要查錯的時候到底怎麼看呢。


export interface VueConstructor<V extends Vue = Vue> {
  new <Data = object, Methods = object, Computed = object, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): CombinedVueInstance<V, Data, Methods, Computed, Record<PropNames, any>>;
  // ideally, the return type should just contains Props, not Record<keyof Props, any>. But TS requires Base constructors must all have the same return type.
  new <Data = object, Methods = object, Computed = object, Props = object>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): CombinedVueInstance<V, Data, Methods, Computed, Record<keyof Props, any>>;
  new (options?: ComponentOptions<V>): CombinedVueInstance<V, object, object, object, Record<keyof object, any>>;
  extend<Data, Methods, Computed, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;
  extend<Data, Methods, Computed, Props>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
  extend<PropNames extends string = never>(definition: FunctionalComponentOptions<Record<PropNames, any>, PropNames[]>): ExtendedVue<V, {}, {}, {}, Record<PropNames, any>>;
  extend<Props>(definition: FunctionalComponentOptions<Props, RecordPropsDefinition<Props>>): ExtendedVue<V, {}, {}, {}, Props>;
  extend(options?: ComponentOptions<V>): ExtendedVue<V, {}, {}, {}, {}>;
  nextTick(callback: () => void, context?: any[]): void;
  nextTick(): Promise<void>
  set<T>(object: object, key: string, value: T): T;
  set<T>(array: T[], key: number, value: T): T;
  delete(object: object, key: string): void;
  delete<T>(array: T[], key: number): void;
  directive(
    id: string,
    definition?: DirectiveOptions | DirectiveFunction
  ): DirectiveOptions;
  filter(id: string, definition?: Function): Function;
  component(id: string): VueConstructor;
  component<VC extends VueConstructor>(id: string, constructor: VC): VC;
  component<Data, Methods, Computed, Props>(id: string, definition: AsyncComponent<Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
  component<Data, Methods, Computed, PropNames extends string = never>(id: string, definition?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;
  component<Data, Methods, Computed, Props>(id: string, definition?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
  component<PropNames extends string>(id: string, definition: FunctionalComponentOptions<Record<PropNames, any>, PropNames[]>): ExtendedVue<V, {}, {}, {}, Record<PropNames, any>>;
  component<Props>(id: string, definition: FunctionalComponentOptions<Props, RecordPropsDefinition<Props>>): ExtendedVue<V, {}, {}, {}, Props>;
  component(id: string, definition?: ComponentOptions<V>): ExtendedVue<V, {}, {}, {}, {}>;
  use<T>(plugin: PluginObject<T> | PluginFunction<T>, options?: T): void;
  use(plugin: PluginObject<any> | PluginFunction<any>, ...options: any[]): void;
  mixin(mixin: VueConstructor | ComponentOptions<Vue>): void;
  compile(template: string): {
    render(createElement: typeof Vue.prototype.$createElement): VNode;
    staticRenderFns: (() => VNode)[];
  };
  config: VueConfiguration;
}

泛型(嵌套) + 合併聲明 + 混入


0 === san;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章