TypeScript 的核心原則之一是型別檢查側重於值所具有的“形狀”。這有時被稱為“鴨子型別”或“結構化子型別”。在 TypeScript 中,介面(Interface)擔任了為這些型別命名的角色,是定義程式碼內部契約以及與專案外部程式碼契約的強大方式。
我們的第一個介面
瞭解介面如何工作的最簡單方法是從一個簡單的例子開始
tsTryfunctionprintLabel (labeledObj : {label : string }) {console .log (labeledObj .label );}letmyObj = {size : 10,label : "Size 10 Object" };printLabel (myObj );
型別檢查器會檢查對 printLabel 的呼叫。printLabel 函式有一個單一引數,要求傳入的物件必須包含一個名為 label 且型別為 string 的屬性。請注意,我們的物件實際上擁有比這更多的屬性,但編譯器只會檢查至少是否存在所需的屬性,且其型別是否匹配。在某些情況下,TypeScript 的要求會更嚴格,我們稍後會介紹。
我們可以再次編寫相同的示例,這次使用介面來描述“必須擁有一個型別為字串的 label 屬性”這一要求
tsTryinterfaceLabeledValue {label : string;}functionprintLabel (labeledObj :LabeledValue ) {console .log (labeledObj .label );}letmyObj = {size : 10,label : "Size 10 Object" };printLabel (myObj );
介面 LabeledValue 是一個我們現在可以用來描述上一個示例中要求的名稱。它仍然表示包含一個名為 label 且型別為 string 的屬性。請注意,我們不需要像在其他語言中那樣顯式宣告傳入 printLabel 的物件“實現了”這個介面。在這裡,只有形狀重要。如果我們傳遞給函式的物件滿足列出的要求,那麼它就是被允許的。
值得指出的是,型別檢查器並不要求這些屬性按任何特定的順序排列,它只要求介面所需的屬性必須存在且具有所需的型別。
可選屬性
並非介面的所有屬性都是必須的。有些屬性在特定條件下存在,或者可能根本不存在。這些可選屬性在建立“配置選項(option bags)”之類的模式時非常流行,例如當你向函式傳遞一個只填充了幾個屬性的物件時。
以下是這種模式的一個示例
tsTryinterfaceSquareConfig {color ?: string;width ?: number;}functioncreateSquare (config :SquareConfig ): {color : string;area : number } {letnewSquare = {color : "white",area : 100 };if (config .color ) {newSquare .color =config .color ;}if (config .width ) {newSquare .area =config .width *config .width ;}returnnewSquare ;}letmySquare =createSquare ({color : "black" });
帶有可選屬性的介面與其他介面的編寫方式類似,每個可選屬性在宣告時名稱末尾都會加上一個 ?。
可選屬性的優勢在於,你可以描述這些可能存在的屬性,同時還能阻止使用介面中未定義的屬性。例如,如果我們拼錯了 createSquare 中 color 屬性的名稱,我們會得到一條錯誤訊息提示
tsTryinterfaceSquareConfig {color ?: string;width ?: number;}functioncreateSquare (config :SquareConfig ): {color : string;area : number } {letnewSquare = {color : "white",area : 100 };if (Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?2551Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?config .) { clor // Error: Property 'clor' does not exist on type 'SquareConfig'Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?2551Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?newSquare .color =config .; clor }if (config .width ) {newSquare .area =config .width *config .width ;}returnnewSquare ;}letmySquare =createSquare ({color : "black" });
只讀屬性 (Readonly properties)
有些屬性應該只在物件首次建立時被修改。你可以透過在屬性名稱前新增 readonly 來指定這一點
tsTryinterfacePoint {readonlyx : number;readonlyy : number;}
你可以透過分配物件字面量來構造一個 Point。賦值後,x 和 y 就不能再被更改。
tsTryletp1 :Point = {x : 10,y : 20 };Cannot assign to 'x' because it is a read-only property.2540Cannot assign to 'x' because it is a read-only property.p1 .= 5; // error! x
TypeScript 自帶一個 ReadonlyArray<T> 型別,它與 Array<T> 相同,但刪除了所有會改變陣列的方法,因此你可以確保在建立陣列後不會對其進行修改
tsTryleta : number[] = [1, 2, 3, 4];letro :ReadonlyArray <number> =a ;Index signature in type 'readonly number[]' only permits reading.2542Index signature in type 'readonly number[]' only permits reading.ro [0] = 12; // error!Property 'push' does not exist on type 'readonly number[]'.2339Property 'push' does not exist on type 'readonly number[]'.ro .(5); // error! push Cannot assign to 'length' because it is a read-only property.2540Cannot assign to 'length' because it is a read-only property.ro .= 100; // error! length The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.4104The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.= a ro ; // error!
在程式碼片段的最後一行,你可以看到即使將整個 ReadonlyArray 重新賦值給一個普通陣列也是不合法的。不過,你仍然可以透過型別斷言來覆蓋它
tsTryleta : number[] = [1, 2, 3, 4];letro :ReadonlyArray <number> =a ;a =ro as number[];
readonly 與 const
記住是使用 readonly 還是 const 的最簡單方法是:詢問自己是在變數還是屬性上使用它。變數使用 const,而屬性使用 readonly。
額外屬性檢查 (Excess Property Checks)
在我們使用介面的第一個例子中,TypeScript 允許我們將 { size: number; label: string; } 傳遞給只期望 { label: string; } 的函式。我們剛剛也學習了可選屬性,以及它們在描述所謂的“配置選項”時的作用。
然而,天真地結合這兩者可能會導致錯誤潛入。例如,使用上一個 createSquare 的例子
tsTryinterfaceSquareConfig {color ?: string;width ?: number;}functioncreateSquare (config :SquareConfig ): {color : string;area : number } {return {color :config .color || "red",area :config .width ?config .width *config .width : 20,};}letObject literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?2561Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?mySquare =createSquare ({: "red", colour width : 100 });
注意傳遞給 createSquare 的引數拼寫成了 colour 而不是 color。在純 JavaScript 中,這類事情會悄無聲息地失敗。
你可能會爭辯說這個程式是正確鍵入的,因為 width 屬性是相容的,color 屬性不存在,而多餘的 colour 屬性並不重要。
然而,TypeScript 的立場是認為這段程式碼可能存在錯誤。物件字面量在賦值給其他變數或作為引數傳遞時會受到特殊處理,並進行額外屬性檢查。如果物件字面量包含任何“目標型別”中沒有的屬性,你將會得到一個錯誤
tsTryletObject literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?2561Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?mySquare =createSquare ({: "red", colour width : 100 });
繞過這些檢查其實非常簡單。最簡單的方法是使用型別斷言
tsTryletmySquare =createSquare ({width : 100,opacity : 0.5 } asSquareConfig );
不過,如果你確定物件可以包含一些以特殊方式使用的額外屬性,更好的方法可能是新增一個字串索引簽名。如果 SquareConfig 可以有上述型別的 color 和 width 屬性,但也可以有任意數量的其他屬性,那麼我們可以這樣定義它
tsTryinterfaceSquareConfig {color ?: string;width ?: number;[propName : string]: any;}
我們稍後會討論索引簽名,但這裡我們表達的是 SquareConfig 可以有任意數量的屬性,只要它們不是 color 或 width,它們的型別無關緊要。
繞過這些檢查的最後一種方法(可能有點出人意料)是將該物件賦值給另一個變數:由於 squareOptions 不會進行額外屬性檢查,編譯器不會報錯。
tsTryletsquareOptions = {colour : "red",width : 100 };letmySquare =createSquare (squareOptions );
只要 squareOptions 和 SquareConfig 之間有共同屬性,上述變通方法就有效。在這個例子中,共同屬性是 width。然而,如果變數沒有任何共同的物件屬性,它就會失敗。例如
tsTryletsquareOptions = {colour : "red" };letType '{ colour: string; }' has no properties in common with type 'SquareConfig'.2559Type '{ colour: string; }' has no properties in common with type 'SquareConfig'.mySquare =createSquare (); squareOptions
請記住,對於像上面這樣的簡單程式碼,你可能不應該試圖“繞過”這些檢查。對於具有方法和狀態的更復雜的物件字面量,你可能需要記住這些技術,但大多數額外屬性錯誤實際上確實是 Bug。這意味著如果你在處理類似“配置選項”時遇到額外屬性檢查問題,你可能需要修改你的型別宣告。在這種情況下,如果允許將帶有 color 或 colour 屬性的物件傳遞給 createSquare,你應該修正 SquareConfig 的定義來反映這一點。
函式型別 (Function Types)
介面能夠描述 JavaScript 物件可以呈現的各種形狀。除了描述帶有屬性的物件外,介面還能夠描述函式型別。
要使用介面描述函式型別,我們要為介面提供一個呼叫簽名。這就像一個僅指定了引數列表和返回型別的函式宣告。引數列表中的每個引數都需要名稱和型別。
tsTryinterfaceSearchFunc {(source : string,subString : string): boolean;}
定義好之後,我們可以像使用其他介面一樣使用這個函式型別介面。在這裡,我們展示瞭如何建立一個函式型別的變數,併為其賦值一個相同型別的函式值。
tsTryletmySearch :SearchFunc ;mySearch = function (source : string,subString : string): boolean {letresult =source .search (subString );returnresult > -1;};
為了使函式型別正確透過型別檢查,引數的名稱不需要匹配。例如,我們可以這樣編寫上面的例子
tsTryletmySearch :SearchFunc ;mySearch = function (src : string,sub : string): boolean {letresult =src .search (sub );returnresult > -1;};
函式引數是逐個檢查的,每個對應位置的引數型別都會進行相互比對。如果你完全不想指定型別,TypeScript 的上下文型別推斷可以自動推斷引數型別,因為函式值是直接賦值給 SearchFunc 型別的變數。在這裡,我們的函式表示式的返回型別也是根據它返回的值(這裡是 false 和 true)隱式推斷的。
tsTryletmySearch :SearchFunc ;mySearch = function (src ,sub ) {letresult =src .search (sub );returnresult > -1;};
如果函式表示式返回了數字或字串,型別檢查器會報錯,指出返回型別與 SearchFunc 介面中描述的返回型別不匹配。
tsTryletmySearch :SearchFunc ;Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'. Type 'string' is not assignable to type 'boolean'.2322Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'. Type 'string' is not assignable to type 'boolean'.= function ( mySearch src ,sub ) {letresult =src .search (sub );return "string";};
索引型別 (Indexable Types)
類似於我們可以使用介面來描述函式型別,我們也可以描述我們可以“索引”的型別,比如 a[10] 或 ageMap["daniel"]。索引型別具有一個索引簽名,它描述了我們可以用來索引物件的型別,以及索引時的相應返回型別。
讓我們舉個例子
tsTryinterfaceStringArray {[index : number]: string;}letmyArray :StringArray ;myArray = ["Bob", "Fred"];letmyStr : string =myArray [0];
上面我們有一個帶有索引簽名的 StringArray 介面。該索引簽名聲明當使用 number 索引 StringArray 時,它將返回一個 string。
支援四種類型的索引簽名:string、number、symbol 和模板字串。支援多種型別的索引器是可能的,但從數字索引器返回的型別必須是字串索引器返回型別的子型別。
這是因為當使用 number 進行索引時,JavaScript 實際上會在索引物件之前將其轉換為 string。這意味著使用 100(數字)索引與使用 "100"(字串)索引是一樣的,因此兩者需要保持一致。
tsTryinterfaceAnimal {name : string;}interfaceDog extendsAnimal {breed : string;}// Error: indexing with a numeric string might get you a completely separate type of Animal!interfaceNotOkay {['number' index type 'Animal' is not assignable to 'string' index type 'Dog'.2413'number' index type 'Animal' is not assignable to 'string' index type 'Dog'.x : number]:Animal ;[x : string]:Dog ;}
雖然字串索引簽名是描述“字典”模式的強大方式,但它們也強制要求所有屬性必須符合其返回型別。這是因為字串索引聲明瞭 obj.property 也可以作為 obj["property"] 使用。在下面的例子中,name 的型別與字串索引的型別不匹配,型別檢查器會報錯
tsTryinterfaceNumberDictionary {[index : string]: number;length : number; // ok, length is a numberProperty 'name' of type 'string' is not assignable to 'string' index type 'number'.2411Property 'name' of type 'string' is not assignable to 'string' index type 'number'.: string; // error, the type of 'name' is not a subtype of the indexer name }
然而,如果索引簽名是屬性型別的聯合型別,那麼不同型別的屬性是可以接受的
tsTryinterfaceNumberOrStringDictionary {[index : string]: number | string;length : number; // ok, length is a numbername : string; // ok, name is a string}
最後,你可以將索引簽名設為 readonly,以防止對其索引進行賦值
tsTryinterfaceReadonlyStringArray {readonly [index : number]: string;}letmyArray :ReadonlyStringArray = ["Alice", "Bob"];Index signature in type 'ReadonlyStringArray' only permits reading.2542Index signature in type 'ReadonlyStringArray' only permits reading.myArray [2] = "Mallory"; // error!
你不能設定 myArray[2],因為索引簽名是 readonly。
帶模板字串的索引型別
模板字串可用於指示允許特定的模式,但並非全部允許。例如,HTTP 標頭物件可能有一組已知的標頭列表,並支援任何以 x- 為字首的自定義屬性。
tsTryinterfaceHeadersResponse {"content-type": string,date : string,"content-length": string// Permit any property starting with 'x-'.[headerName : `x-${string}`]: string;}functionhandleResponse (r :HeadersResponse ) {// Handle known, and x- prefixedconsttype =r ["content-type"]constpoweredBy =r ["x-powered-by"]// Unknown keys without the prefix raise errorsconstProperty 'origin' does not exist on type 'HeadersResponse'.2339Property 'origin' does not exist on type 'HeadersResponse'.origin =r .origin }
類型別 (Class Types)
實現介面 (Implementing an interface)
在 C# 和 Java 等語言中,介面最常見的用途之一是顯式強制類滿足特定的契約,這在 TypeScript 中也是可能的。
tsTryinterfaceClockInterface {currentTime :Date ;}classClock implementsClockInterface {currentTime :Date = newDate ();constructor(h : number,m : number) {}}
你也可以在介面中描述在類中實現的方法,就像我們在下面的例子中對 setTime 所做的那樣
tsTryinterfaceClockInterface {currentTime :Date ;setTime (d :Date ): void;}classClock implementsClockInterface {currentTime :Date = newDate ();setTime (d :Date ) {this.currentTime =d ;}constructor(h : number,m : number) {}}
介面描述的是類的公共部分,而不是公共和私有部分。這禁止你使用它們來檢查類是否也為類的例項的私有部分包含了特定型別。
類靜態部分與例項部分的區別
在使用類和介面時,記住類有兩種型別是有幫助的:靜態部分的型別和例項部分的型別。你可能會注意到,如果你建立一個帶有構造簽名的介面,並嘗試建立一個實現該介面的類,你會得到一個錯誤
tsTryinterfaceClockConstructor {new (hour : number,minute : number);}classClass 'Clock' incorrectly implements interface 'ClockConstructor'. Type 'Clock' provides no match for the signature 'new (hour: number, minute: number): any'.2420Class 'Clock' incorrectly implements interface 'ClockConstructor'. Type 'Clock' provides no match for the signature 'new (hour: number, minute: number): any'.implements Clock ClockConstructor {currentTime :Date ;constructor(h : number,m : number) {}}
這是因為當類實現介面時,只檢查類的例項部分。由於建構函式位於靜態部分,它不包含在檢查中。
相反,你需要直接處理類的靜態部分。在這個例子中,我們定義了兩個介面,ClockConstructor 用於建構函式,ClockInterface 用於例項方法。然後,為了方便起見,我們定義了一個建構函式 createClock,它建立傳遞給它的型別的例項
tsTryinterfaceClockConstructor {new (hour : number,minute : number):ClockInterface ;}interfaceClockInterface {tick (): void;}functioncreateClock (ctor :ClockConstructor ,hour : number,minute : number):ClockInterface {return newctor (hour ,minute );}classDigitalClock implementsClockInterface {constructor(h : number,m : number) {}tick () {console .log ("beep beep");}}classAnalogClock implementsClockInterface {constructor(h : number,m : number) {}tick () {console .log ("tick tock");}}letdigital =createClock (DigitalClock , 12, 17);letanalog =createClock (AnalogClock , 7, 32);
因為 createClock 的第一個引數是 ClockConstructor 型別,在 createClock(AnalogClock, 7, 32) 中,它會檢查 AnalogClock 是否具有正確的建構函式簽名。
另一種簡單的方法是使用類表示式
tsTryinterfaceClockConstructor {new (hour : number,minute : number):ClockInterface ;}interfaceClockInterface {tick (): void;}constClock :ClockConstructor = classClock implementsClockInterface {constructor(h : number,m : number) {}tick () {console .log ("beep beep");}};letclock = newClock (12, 17);clock .tick ();
繼承介面 (Extending Interfaces)
像類一樣,介面可以相互繼承。這允許你將一個介面的成員複製到另一個介面中,從而在如何將介面分離為可重用元件方面為你提供了更大的靈活性。
tsTryinterfaceShape {color : string;}interfaceSquare extendsShape {sideLength : number;}letsquare = {} asSquare ;square .color = "blue";square .sideLength = 10;
一個介面可以繼承多個介面,從而建立所有介面的組合。
tsTryinterfaceShape {color : string;}interfacePenStroke {penWidth : number;}interfaceSquare extendsShape ,PenStroke {sideLength : number;}letsquare = {} asSquare ;square .color = "blue";square .sideLength = 10;square .penWidth = 5.0;
混合型別 (Hybrid Types)
正如我們之前提到的,介面可以描述現實世界 JavaScript 中存在的豐富型別。由於 JavaScript 的動態和靈活性,你偶爾會遇到一個既是上述某些型別組合的物件。
一個例子是既充當函式又充當物件,且具有額外屬性的物件
tsTryinterfaceCounter {(start : number): string;interval : number;reset (): void;}functiongetCounter ():Counter {letcounter = function (start : number) {} asCounter ;counter .interval = 123;counter .reset = function () {};returncounter ;}letc =getCounter ();c (10);c .reset ();c .interval = 5.0;
在與第三方 JavaScript 互動時,你可能需要使用上述模式來完整描述該型別的形狀。
介面繼承類 (Interfaces Extending Classes)
當介面型別繼承類型別時,它會繼承類的成員,但不繼承其實現。這就好比介面聲明瞭類的所有成員,但沒有提供實現。介面甚至繼承了基類的私有和受保護成員。這意味著當你建立一個繼承了具有私有或受保護成員的類的介面時,該介面型別只能由該類或其子類來實現。
當你擁有龐大的繼承層次結構,但只想指定你的程式碼僅適用於具有特定屬性的子類時,這非常有用。除了從基類繼承之外,這些子類不需要有其他關係。例如
tsTryclassControl {privatestate : any;}interfaceSelectableControl extendsControl {select (): void;}classButton extendsControl implementsSelectableControl {select () {}}classTextBox extendsControl {select () {}}classClass 'ImageControl' incorrectly implements interface 'SelectableControl'. Types have separate declarations of a private property 'state'.2420Class 'ImageControl' incorrectly implements interface 'SelectableControl'. Types have separate declarations of a private property 'state'.implements ImageControl SelectableControl {privatestate : any;select () {}}
在上面的例子中,SelectableControl 包含了 Control 的所有成員,包括私有的 state 屬性。因為 state 是私有成員,所以只有 Control 的後代才能實現 SelectableControl。這是因為只有 Control 的後代才會有源自同一宣告的 state 私有成員,這是私有成員相容性的要求。
在 Control 類中,可以透過 SelectableControl 的例項訪問私有的 state 成員。實際上,SelectableControl 的作用就像是一個已知具有 select 方法的 Control。Button 和 TextBox 類是 SelectableControl 的子型別(因為它們都繼承自 Control 並擁有 select 方法)。ImageControl 類有自己的私有 state 成員,而不是繼承自 Control,所以它不能實現 SelectableControl。