TypeScript 的誕生初衷是為 JavaScript 引入傳統的面向物件型別,以便微軟的程式設計師能夠將傳統的面向物件程式帶到 Web 上。隨著發展,TypeScript 的型別系統已演變為能夠為原生 JavaScript 程式碼建模。最終形成的系統既強大、有趣,又顯得有些雜亂。
本入門指南專為想要學習 TypeScript 的 Haskell 或 ML 程式設計師而設計。它描述了 TypeScript 的型別系統與 Haskell 型別系統的區別,並介紹了 TypeScript 型別系統中因模擬 JavaScript 程式碼而產生的獨特特性。
本指南不涵蓋面向物件程式設計。實際上,TypeScript 中的面向物件程式與其他具有面向物件特性的流行語言非常相似。
先決條件
在本入門指南中,我假設您瞭解以下內容:
- 如何編寫 JavaScript(即 JavaScript 的精華部分)。
- 類 C 語言的型別語法。
如果您需要學習 JavaScript 的精華部分,請閱讀《JavaScript: The Good Parts》。如果您懂得如何編寫按值呼叫、具有詞法作用域且包含大量可變性(除此之外沒別的)的語言,則可能不需要閱讀這本書。R4RS Scheme 就是一個很好的例子。
《C++ 程式設計語言》是學習 C 風格型別語法的好去處。與 C++ 不同,TypeScript 使用字尾型別,例如:x: string 而不是 string x。
Haskell 中不存在的概念
內建型別
JavaScript 定義了 8 種內建型別
| 型別 | 說明 |
|---|---|
Number |
IEEE 754 雙精度浮點數。 |
String |
不可變的 UTF-16 字串。 |
BigInt |
任意精度格式的整數。 |
Boolean |
true 和 false。 |
Symbol |
通常用作鍵的唯一值。 |
Null |
等同於單元型別 (unit type)。 |
Undefined |
同樣等同於單元型別。 |
Object |
類似於記錄 (record)。 |
TypeScript 具有對應於內建型別的原始型別:
numberstringbigintbooleansymbolnullundefinedobject
其他重要的 TypeScript 型別
| 型別 | 說明 |
|---|---|
unknown |
頂層型別。 |
never |
底層型別。 |
| 物件字面量 | 例如 { property: Type } |
void |
用於無返回值記錄的函式 |
T[] |
可變陣列,也寫作 Array<T> |
[T, T] |
元組,長度固定但可變 |
(t: T) => U |
函式 |
注意
-
函式語法包含引數名稱。這一點相當難適應!
tslet fst: (a: any, b: any) => any = (a, b) => a;// or more precisely:let fst: <T, U>(a: T, b: U) => T = (a, b) => a; -
物件字面量型別語法與物件字面量值語法非常接近。
tslet o: { n: number; xs: object[] } = { n: 1, xs: [] }; -
[T, T]是T[]的子型別。這與 Haskell 不同,在 Haskell 中元組與列表沒有這種關係。
裝箱型別
JavaScript 具有原始型別的裝箱等價物,其中包含程式設計師通常關聯的方法。TypeScript 透過例如原始型別 number 和裝箱型別 Number 之間的區別反映了這一點。裝箱型別很少需要用到,因為它們的方法返回的是原始型別。
ts(1).toExponential();// equivalent toNumber.prototype.toExponential.call(1);
注意,在數字字面量上呼叫方法時,需要將其放在括號內以輔助解析器。
漸進式型別系統
每當 TypeScript 無法確定表示式的型別時,它就會使用 any 型別。與 Dynamic 相比,稱 any 為一種型別有點言過其實。它只是在出現的地方關閉了型別檢查。例如,您可以將任何值推入 any[],而無需以任何方式標記該值。
tsTry// with "noImplicitAny": false in tsconfig.json, anys: any[]constanys = [];anys .push (1);anys .push ("oh no");anys .push ({anything : "goes" });
並且您可以在任何地方使用 any 型別的表示式。
tsanys.map(anys[1]); // oh no, "oh no" is not a function
any 也是具有傳染性的——如果您使用 any 型別的表示式初始化變數,該變數也具有 any 型別。
tslet sepsis = anys[0] + anys[1]; // this could mean anything
如果想在 TypeScript 產生 any 時獲得錯誤提示,請在 tsconfig.json 中使用 "noImplicitAny": true 或 "strict": true。
結構化型別系統
結構化型別系統對大多數函式式程式設計師來說是一個熟悉的概念,儘管 Haskell 和大多數 ML 語言不是結構化型別的。其基本形式非常簡單:
ts// @strict: falselet o = { x: "hi", extra: 1 }; // oklet o2: { x: string } = o; // ok
在這裡,物件字面量 { x: "hi", extra: 1 } 具有匹配的字面量型別 { x: string, extra: number }。該型別可以賦值給 { x: string },因為它擁有所有必需的屬性,且這些屬性具有可賦值的型別。多出來的屬性不會阻止賦值,它只是使其成為 { x: string } 的子型別。
命名型別只是給型別起個名字;出於可賦值性的考慮,下面的類型別名 One 和介面型別 Two 之間沒有區別。它們都具有屬性 p: string。(然而,類型別名在遞迴定義和型別引數方面與介面的表現不同。)
tsTrytypeOne = {p : string };interfaceTwo {p : string;}classThree {p = "Hello";}letx :One = {p : "hi" };lettwo :Two =x ;two = newThree ();
聯合型別
在 TypeScript 中,聯合型別是未標記的。換句話說,它們不像 Haskell 中的 data 那樣是代數資料型別(判別聯合)。然而,您通常可以使用內建標記或其他屬性來區分聯合型別中的型別。
tsTryfunctionstart (arg : string | string[] | (() => string) | {s : string }): string {// this is super common in JavaScriptif (typeofarg === "string") {returncommonCase (arg );} else if (Array .isArray (arg )) {returnarg .map (commonCase ).join (",");} else if (typeofarg === "function") {returncommonCase (arg ());} else {returncommonCase (arg .s );}functioncommonCase (s : string): string {// finally, just convert a string to another stringreturns ;}}
string、Array 和 Function 具有內建的型別謂詞,方便地將物件型別留給 else 分支。但是,確實可能生成在執行時難以區分的聯合型別。對於新程式碼,最好只構建判別聯合。
以下型別具有內建謂詞:
| 型別 | 謂詞 |
|---|---|
| string | typeof s === "string" |
| number | typeof n === "number" |
| bigint | typeof m === "bigint" |
| boolean | typeof b === "boolean" |
| symbol | typeof g === "symbol" |
| undefined | typeof undefined === "undefined" |
| function | typeof f === "function" |
| array | Array.isArray(a) |
| object | typeof o === "object" |
注意,函式和陣列在執行時是物件,但它們有自己的謂詞。
交叉型別
除了聯合型別,TypeScript 還有交叉型別。
tsTrytypeCombined = {a : number } & {b : string };typeConflicting = {a : number } & {a : string };
Combined 具有兩個屬性 a 和 b,就像將它們寫成一個物件字面量型別一樣。在衝突的情況下,交叉和聯合是遞迴的,所以 Conflicting.a: number & string。
字面量型別 (Unit types)
字面量型別是原始型別的子型別,僅包含一個原始值。例如,字串 "foo" 具有型別 "foo"。由於 JavaScript 沒有內建列舉,通常會使用一組已知的字串代替。字串字面量型別的聯合允許 TypeScript 對這種模式進行型別檢查:
tsTrydeclare functionpad (s : string,n : number,direction : "left" | "right"): string;pad ("hi", 10, "left");
在需要時,編譯器會進行擴大 (widening) —— 將字面量型別轉換為原始超型別,例如將 "foo" 轉換為 string。這在使用可變變數時會發生,這可能會阻礙某些可變變數的使用。
tsTrylets = "right";Argument of type 'string' is not assignable to parameter of type '"left" | "right"'.2345Argument of type 'string' is not assignable to parameter of type '"left" | "right"'.pad ("hi", 10,); // error: 'string' is not assignable to '"left" | "right"' s
錯誤是這樣發生的:
"right": "right"s: string,因為"right"在賦值給可變變數時擴大為string。string不能賦值給"left" | "right"
您可以透過為 s 提供型別註解來繞過這個問題,但這反過來會阻止非 "left" | "right" 型別的值賦值給 s。
tsTrylets : "left" | "right" = "right";pad ("hi", 10,s );
與 Haskell 相似的概念
上下文型別
TypeScript 有一些顯而易見可以推斷型別的地方,比如變數宣告。
tsTrylets = "I'm a string!";
但它也在其他一些如果您使用過其他 C 語法語言可能意想不到的地方進行型別推斷。
tsTrydeclare functionmap <T ,U >(f : (t :T ) =>U ,ts :T []):U [];letsns =map ((n ) =>n .toString (), [1, 2, 3]);
這裡,儘管在呼叫之前 T 和 U 尚未被推斷,但在此示例中 n: number。事實上,在 [1,2,3] 被用於推斷 T=number 後,n => n.toString() 的返回型別被用於推斷 U=string,導致 sns 具有 string[] 型別。
請注意,推斷可以以任何順序進行,但 Intellisense(智慧感知)只能從左到右工作,因此 TypeScript 更傾向於先宣告帶有陣列的 map。
tsTrydeclare functionmap <T ,U >(ts :T [],f : (t :T ) =>U ):U [];
上下文型別也透過物件字面量遞迴工作,並且適用於否則會被推斷為 string 或 number 的字面量型別。它還可以從上下文中推斷返回型別。
tsTrydeclare functionrun <T >(thunk : (t :T ) => void):T ;leti : {inference : string } =run ((o ) => {o .inference = "INSERT STATE HERE";});
o 的型別被確定為 { inference: string },因為:
- 宣告初始化程式是根據宣告的型別進行上下文型別推斷的:
{ inference: string }。 - 呼叫的返回型別使用上下文型別進行推斷,因此編譯器推斷出
T={ inference: string }。 - 箭頭函式使用上下文型別來定義它們的引數,因此編譯器推斷出
o: { inference: string }。
而且這發生在你鍵入的過程中,所以當你輸入 o. 後,你會得到屬性 inference 的自動補全,以及在真實程式中可能有的任何其他屬性。總而言之,此功能使 TypeScript 的推斷看起來有點像統一型別推斷引擎,但事實並非如此。
類型別名
類型別名只是別名,就像 Haskell 中的 type。編譯器會嘗試在原始碼中使用該別名名稱,但並不總是成功。
tsTrytypeSize = [number, number];letx :Size = [101.1, 999.9];
最接近 newtype 的等價物是標記交叉型別:
tstype FString = string & { __compileTimeOnly: any };
FString 就像一個普通字串,只不過編譯器認為它有一個實際上並不存在的名為 __compileTimeOnly 的屬性。這意味著 FString 仍然可以賦值給 string,但反之則不然。
判別聯合
最接近 data 的等價物是具有判別屬性的型別聯合,在 TypeScript 中通常稱為判別聯合。
tstype Shape =| { kind: "circle"; radius: number }| { kind: "square"; x: number }| { kind: "triangle"; x: number; y: number };
與 Haskell 不同,標籤或判別式只是每個物件型別中的一個屬性。每個變體都有一個相同的屬性,但具有不同的字面量型別。這仍然是一個普通的聯合型別;開頭的 | 是聯合型別語法的可選部分。您可以使用普通的 JavaScript 程式碼來區分聯合的成員。
tsTrytypeShape =| {kind : "circle";radius : number }| {kind : "square";x : number }| {kind : "triangle";x : number;y : number };functionarea (s :Shape ) {if (s .kind === "circle") {returnMath .PI *s .radius *s .radius ;} else if (s .kind === "square") {returns .x *s .x ;} else {return (s .x *s .y ) / 2;}}
請注意,area 的返回型別被推斷為 number,因為 TypeScript 知道該函式是完全的 (total)。如果某些變體未被覆蓋,area 的返回型別將是 number | undefined。
此外,與 Haskell 不同,公共屬性會出現在任何聯合中,因此您可以有效地對聯合的多個成員進行區分。
tsTryfunctionheight (s :Shape ) {if (s .kind === "circle") {return 2 *s .radius ;} else {// s.kind: "square" | "triangle"returns .x ;}}
型別引數
像大多數 C 系語言一樣,TypeScript 需要宣告型別引數。
tsfunction liftArray<T>(t: T): Array<T> {return [t];}
沒有強制的大小寫要求,但型別引數習慣上是單個大寫字母。型別引數也可以受限於某種型別,這在行為上有點類似於型別類約束。
tsfunction firstish<T extends { length: number }>(t1: T, t2: T): T {return t1.length > t2.length ? t1 : t2;}
TypeScript 通常可以根據引數的型別從呼叫中推斷型別引數,因此通常不需要顯式指定型別引數。
因為 TypeScript 是結構化的,所以它不像標稱系統那樣需要那麼多的型別引數。具體來說,不需要它們來使函式具有多型性。型別引數應該只用於傳播型別資訊,例如約束引數為相同的型別。
tsfunction length<T extends ArrayLike<unknown>>(t: T): number {}function length(t: ArrayLike<unknown>): number {}
在第一個 length 中,不需要 T;請注意,它只被引用了一次,所以它沒有被用來約束返回值或其他引數的型別。
高階型別
TypeScript 沒有高階型別,因此以下寫法是不合法的:
tsfunction length<T extends ArrayLike<unknown>, U>(m: T<U>) {}
Point-free 程式設計
Point-free 程式設計(大量使用柯里化和函式組合)在 JavaScript 中是可能的,但可能會很冗長。在 TypeScript 中,型別推斷對於 point-free 程式往往會失敗,因此您最終需要指定型別引數而不是值引數。結果過於冗長,通常最好避免使用 point-free 程式設計。
模組系統
JavaScript 的現代模組語法與 Haskell 的有點像,除了任何帶有 import 或 export 的檔案都被隱式視為一個模組。
tsimport { value, Type } from "npm-package";import { other, Types } from "./local-package";import * as prefix from "../lib/third-package";
您也可以匯入 commonjs 模組 —— 即使用 node.js 模組系統編寫的模組。
tsimport f = require("single-function-package");
您可以使用匯出列表匯出。
tsexport { f };function f() {return g();}function g() {} // g is not exported
或者透過單獨標記每個匯出。
tsexport function f() { return g() }function g() { }
後一種風格更常見,但兩者都是允許的,甚至在同一個檔案中也是如此。
readonly 和 const
在 JavaScript 中,預設是可變的,儘管它允許使用 const 進行變數宣告,以宣告引用是不可變的。但被引用的物件本身仍然是可變的。
jsconst a = [1, 2, 3];a.push(102); // ):a[0] = 101; // D:
TypeScript 另外提供了一個用於屬性的 readonly 修飾符。
tsinterface Rx {readonly x: number;}let rx: Rx = { x: 1 };rx.x = 12; // error
它還附帶了一個對映型別 Readonly<T>,使所有屬性變為 readonly。
tsinterface X {x: number;}let rx: Readonly<X> = { x: 1 };rx.x = 12; // error
並且它有一個專門的 ReadonlyArray<T> 型別,該型別移除了會產生副作用的方法並防止寫入陣列索引,以及針對此型別的特殊語法。
tslet a: ReadonlyArray<number> = [1, 2, 3];let b: readonly number[] = [1, 2, 3];a.push(102); // errorb[0] = 101; // error
您還可以使用 const 斷言,它作用於陣列和物件字面量。
tslet a = [1, 2, 3] as const;a.push(102); // errora[0] = 101; // error
然而,這些選項都不是預設的,因此它們在 TypeScript 程式碼中並沒有被一致地使用。
後續步驟
本文件是對您在日常程式碼中會用到的語法和型別的高階概述。從這裡開始,您應該:
- 閱讀完整手冊 從頭到尾
- 探索 Playground 示例