複合類型
最直觀的創造新的複合類型的方式,就是直接將多個類型組合在一起。比如平面上的點都有 X 和 Y 兩個座標,各自都屬於 number 類型。因此可以說,平面上的點是由兩個 number 類型組合成的新類型。
通常來說,將多個類型直接組合在一起形成新的類型,這樣的類型最終的取值範圍,就是全部成員類型所有可能的組合值的集合。
元組
假如我們需要一個函數來計算兩個點之間的距離,可以這樣實現:
function distance(x1: number, y1: number, x2: number, y2: number): number {
return Math.sqrt((x1 - x1) ** 2 + (y1 - y2) ** 2)
}
上述實現能夠正常工作,但並不算完美。x1
在沒有對應的 Y 座標一起出現的情況下,是沒有任何實際含義的。同時在應用的其他地方,我們很可能也會遇到很多針對座標點的其他操作,因此相對於將 X 座標和 Y 座標獨立地進行表示和傳遞,我們可以將兩者組合在一起,成爲一個新的元組類型。
元組能夠幫助我們將單獨的 X 和 Y 座標組合在一起作爲“點”對待,從而令代碼更方便閱讀和書寫。
type Point = [number, number]
function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1[0] - point2[0]) ** 2 + (point1[1] - point2[1]) ** 2);
}
DIY 元組
大部分語言都提供了元組作爲內置語法,這裏假設在標準庫裏沒有元組的情況下,如何自己實現包含兩個元素的元組類型:
class Pair<T1, T2> {
m0: T1;
m1: T2;
constructor(m0: T1, m1: T2) {
this.m0 = m0;
this.m1 = m1;
}
}
type Point = Pair<number, number>;
function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1.m0 - point2.m0) ** 2 + (point1.m1 - point2.m1) ** 2);
}
Record 類型
將座標點定義爲數字對,是可以正常工作的。但是我們也因此失去了在代碼中包含更多含義的機會。在前面的例子中,我們假定第一個數字是 X 座標,第二個數字是 Y 座標。但最好是藉助類型系統,在代碼中編入更精確的含義。從而徹底消除將 X 錯認爲是 Y 或者將 Y 錯認爲是 X 的機會。
可以藉助 Record 類型來實現:
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function distance(point1: Point, point2: Point): number {
return Math.sqrt(
(point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2);
}
首要的原則是,最好優先使用含義清晰的 Record 類型,它包含的元素是有明確的命名的。而不是直接將元組傳來傳去。元組並不會爲自己的元素提供名稱,只是靠數字索引訪問,因而會存在很大的誤解的可能性。當然另一方面,元組是內置的,而 Record 類型通常需要額外進行定義。但大多數情況下,這樣的額外工作是值得的。
維持不可變性
類的成員函數和成員變量可以被定義爲 public
(能夠被公開訪問),也可以被定義爲 private
(只允許內部訪問)。在 TypeScript 中,成員默認都是公開的。
通常情況下我們定義 Record 類型,如果其成員變量是獨立的,比如之前的 Point,X 座標和 Y 座標都可以獨立的進行修改,不會影響到對方。且它們的值可以在不引起問題的情況下變化。像這樣的成員被定義成公開的一般不會出現問題。
但是也存在另外一些情況。比如下面這個由 dollar
值和 cents
值組成的 Currency 類型:
- dollar 值必須是一個大於或者等於 0 的整數
- cent 值也必須是一個大於或者等於 0 的整數
- cent 值不能大於 99,每 100 cents 都必須轉換成 1 dollar
如果我們允許 dollars
和 cents
變量被公開訪問,就有可能導致出現不規範的對象:
class Currency {
dollars: number;
cents: number;
constructor(dollars: number, cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();
dollars = dollars + Math.floor(cents / 100);
cents = cents % 100;
if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();
this.dollars = dollars;
this.cents = cents;
}
}
let amount: Currency = new Currency(5, 50);
amount.cents = 300; // 由於屬性是公開的,外部代碼可以直接修改。從而產生非法對象
上述情況可以通過將成員變量定義爲 private
來避免。同時爲了維護方便,一般還需要提供公開的方法對私有的屬性進行修改。這些方法通常會包含一定的驗證規則,確保修改後的對象狀態是合法的。
class Currency {
private dollars: number = 0;
private cents: number = 0;
constructor(dollars: number, cents: number) {
this.assignDollars(dollars);
this.assignCents(cents);
}
getDollars(): number {
return this.dollars;
}
assignDollars(dollars: number) {
if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();
this.dollars = dollars;
}
getCents(): number {
return this.cents;
}
assignCents(cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();
this.assignDollars(this.dollars + Math.floor(cents / 100));
this.cents = cents % 100;
}
}
外部代碼只能通過 assignDollars()
和 assignCents()
兩個公開的方法,對私有的屬性 dollars
和 cents
進行修改。同時這兩個方法也會確保對象的狀態一直符合我們定義的規則。
另外一種觀點是,可以將屬性定義成不可變(只讀)的。這樣屬性就可以直接被外部訪問,因爲只讀屬性會阻止自身被修改。從而對象狀態保持合法。
class Currency {
readonly dollars: number;
readonly cents: number;
constructor(dollars: number, cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0)
throw new Error();
dollars = dollars + Math.floor(cents / 100);
cents = cents % 100;
if (!Number.isSafeInteger(dollars) || dollars < 0)
throw new Error();
this.dollars = dollars;
this.cents = cents;
}
}
不可變對象還有一個優勢,從不同的線程對這類數據併發地訪問是保證安全的。可變性會導致數據競爭。
但其劣勢在於,每次我們需要一個新的值,就必須創建一個新的實例,無法通過修改現有對象得到。而創建新對象有時候是很昂貴的操作。
最終的目的在於,阻止外部代碼直接修改屬性,以至於跳過驗證規則。可以將屬性變爲私有,對屬性的訪問完全通過包含驗證規則的公開方法;也可以將屬性聲明爲不可變的,在構造對象時執行驗證。
either-or 類型
either-or 是另外一種基礎的將類型組合在一起的方式,即某個值有可能是多個類型所有合法取值中的任何一個。比如 Rust 語言中的 Result<T, E>
,可能是成功的值 Ok(T)
,也可能是失敗值 Err(E)
。
枚舉
先從一個簡單的例子開始,通過類型系統編碼週一到週日。我們可以用 0-6 的數字來表示一週的七天,0 表示一週裏的第一天。但這樣表示並不理想,因爲不同的工程師可能對這些數字有不同的理解。有些國家第一天是週日,有些國家第一天是週一。
function isWeekend(dayOfWeek: number): boolean {
return dayOfWeek == 5 || dayOfWeek == 6;
} // 歐洲國家判斷是否是週末
function isWeekday(dayOfWeek: number): boolean {
return dayOfWeek >= 1 && dayOfWeek <= 5;
} // 美國判斷是否是工作日
上述兩個函數是衝突的。若 0 表示週日,則 isWeekend()
是不正確的;若 0 表示週一,則 isWeekday()
是不正確的。
其他的方案是定義一系列常量用來表示一週七天。
const Sunday: number = 0;
const Monday: number = 1;
const Tuesday: number = 2;
const Wednesday: number = 3;
const Thursday: number = 4;
const Friday: number = 5;
const Saturday: number = 0;
function isWeekend(dayOfWeek: number): boolean {
return dayOfWeek == Saturday || dayOfWeek == Sunday;
}
function isWeekday(dayOfWeek: number): boolean {
return dayOfWeek >= Monday && dayOfWeek <= Friday;
}
現在的實現看上去好了一些,但仍有問題。單看函數的簽名,無法清楚的知道 number
類型的參數的期待值具體是什麼。假如一個新接手代碼的人剛看到 dayOfWeek: number
,他可能不會意識到存在 Sunday
這類常量在某個模塊的某處。因而他們會傾向於自己解釋此處的數字。甚至一些人會傳入非法的數字參數比如 -1
或 10
。
更好的方案是藉助枚舉類型。
enum DayOfWeek {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
function isWeekend(dayOfWeek: DayOfWeek): boolean {
return dayOfWeek == DayOfWeek.Saturday
|| dayOfWeek == DayOfWeek.Sunday;
}
function isWeekday(dayOfWeek: DayOfWeek): boolean {
return dayOfWeek >= DayOfWeek.Monday
&& dayOfWeek <= DayOfWeek.Friday;
}
Optional 類型
假設我們需要將一個用戶輸入的 string
值轉換爲 DayOfWeek
,若該 string
值是合法的,則返回對應的 DayOfWeek
;若該 string
值非法,則顯式地返回 undefined
。
在 TypeScript 中,可以通過 |
類型操作符來實現,|
允許我們組合多個類型。
function parseDayOfWeek(input: string): DayOfWeek | undefined {
switch (input.toLowerCase()) {
case "sunday": return DayOfWeek.Sunday;
case "monday": return DayOfWeek.Monday;
case "tuesday": return DayOfWeek.Tuesday;
case "Wednesday": return DayOfWeek.Wednesday;
case "thursday": return DayOfWeek.Thursday;
case "friday": return DayOfWeek.Friday;
case "saturday": return DayOfWeek.Saturday;
default: return undefined;
}
}
function useInput(input: string) {
let result: DayOfWeek | undefined = parseDayOfWeek(input);
if (result === undefined) {
console.log(`Failed to parse "${input}"`);
} else {
let dayOfWeek: DayOfWeek = result;
/* Use dayOfWeek */
}
}
上述 parseDayOfWeek()
函數返回一個 DayOfWeek
或者 undefined
。useInput()
函數在調用 parseDayOfWeek()
後再對返回值進行解包操作,輸出錯誤信息或者得到合法值。
Optional 類型:也常被叫做 Maybe 類型,表示一個可能存在的 T 類型值。一個 Optional 類型的實例,可能會包含一個 T 類型的任意值;也可能是一個特殊值,用來表示 T 類型的值不存在。
DIY Optional
class Optional<T> {
private value: T | undefined;
private assigned: boolean;
constructor(value?: T) {
if (value) {
this.value = value;
this.assigned = true;
} else {
this.value = undefined;
this.assigned = false;
}
}
hasValue(): boolean {
return this.assigned;
}
getValue(): T {
if (!this.assigned) throw Error();
return <T>this.value;
}
}
Optional 類型的優勢在於,直接使用 null
空類型非常容易出錯。因爲判斷一個變量什麼時候能夠爲空或者不能爲空是非常困難的,我們必須在所有代碼中添加非空檢查,否則就會有引用指向空值的風險,進一步導致運行時錯誤。
Optional 背後的邏輯在於,將 null
值從合法的取值範圍中解耦出來。Optional 明確了哪些變量有可能爲空值。類型系統知曉 Optional 類型(比如 DayOfWeek | undefined
,可能爲空)和對應的非空類型(DayOfWeek
)是不一樣的。兩者是不兼容的類型,因而我們不會將 Optional 類型及其非空類型相混淆,在需要非空類型的地方錯誤地使用有可能爲空值的 Optional。一旦需要取出 Optional 中包含的值,就必須顯式地進行解包操作,對空值進行檢查。
Result or error
現在嘗試擴展前面的 DayOfWeek
例子。當 DayOfWeek
值無法正常識別時,我們不是簡單地返回 undefined
,而是輸出包含更多內容的錯誤信息。
常見的一個反模式就是同時返回 DayOfWeek
和錯誤碼。
enum InputError {
OK,
NoInput,
Invalid
}
class Result {
error: InputError;
value: DayOfWeek;
constructor(error: InputError, value: DayOfWeek) {
this.error = error;
this.value = value
}
}
function parseDayOfWeek(input: string): Result {
if (input == "")
return new Result(InputError.NoInput, DayOfWeek.Sunday);
switch (input.toLowerCase()) {
case "sunday":
return new Result(InputError.OK, DayOfWeek.Sunday);
case "monday":
return new Result(InputError.OK, DayOfWeek.Monday);
case "tuesday":
return new Result(InputError.OK, DayOfWeek.Tuesday);
case "wednesday":
return new Result(InputError.OK, DayOfWeek.Wednesday);
case "thursday":
return new Result(InputError.OK, DayOfWeek.Thursday);
case "friday":
return new Result(InputError.OK, DayOfWeek.Friday);
case "saturday":
return new Result(InputError.OK, DayOfWeek.Saturday);
default:
return new Result(InputError.Invalid, DayOfWeek.Sunday);
}
}
上述實現並不是理想的,原因在於,一旦我們忘記了檢查錯誤代碼,沒有任何機制阻止我們繼續使用 DayOfWeek
值。即便錯誤代碼表明有問題出現,我們仍然可以忽視該錯誤並直接取用 DayOfWeek
。
將類型看作值的集合,則上述 Result
類型實際上是 InputError
和 DayOfWeek
所有可能值的組合。
我們應該實現一種 either-or 類型,返回值要麼是錯誤類型,要麼是合法的值。
DIY Either
Either
類型包含了 TLeft
和 TRight
另外兩種類型。TLeft
用來存儲錯誤類型,TRight
保存合法的值。
class Either<TLeft, TRight> {
private readonly value: TLeft | TRight;
private readonly left: boolean;
private constructor(value: TLeft | TRight, left: boolean) {
this.value = value;
this.left = left;
}
isLeft(): boolean {
return this.left;
}
getLeft(): TLeft {
if (!this.isLeft()) throw new Error();
return <TLeft>this.value;
}
isRight(): boolean {
return !this.left;
}
getRight(): TRight {
if (!this.isRight()) throw new Error();
return <TRight>this.value;
}
static makeLeft<TLeft, TRight>(value: TLeft) {
return new Either<TLeft, TRight>(value, true);
}
static makeRight<TLeft, TRight>(value: TRight) {
return new Either<TLeft, TRight>(value, false);
}
}
藉助上面的 Either
實現,我們可以將 parseDayOfWeek()
更新爲返回 Either<InputError, DayOfWeek>
。若函數返回 InputError
,則結果中就不會包含 DayOfWeek
;若函數返回 DayOfWeek
,就可以肯定沒有錯誤發生。
當然,我們需要顯式地將結果(或 Error)從 Either
中解包出來。
enum InputError {
NoInput,
Invalid
}
type Result = Either<InputError, DayOfWeek>
function parseDayOfWeek(input: string): Result {
if (input == "")
return Either.makeLeft(InputError.NoInput)
switch (input.toLowerCase()) {
case "sunday":
return Either.makeRight(DayOfWeek.Sunday);
case "monday":
return Either.makeRight(DayOfWeek.Monday);
case "tuesday":
return Either.makeRight(DayOfWeek.Tuesday);
case "wednesday":
return Either.makeRight(DayOfWeek.Wednesday);
case "thursday":
return Either.makeRight(DayOfWeek.Thursday);
case "friday":
return Either.makeRight(DayOfWeek.Friday);
case "saturday":
return Either.makeRight(DayOfWeek.Saturday);
default:
return Either.makeLeft(InputError.Invalid);
}
}
當錯誤本身並不是“異常的”(大部分情況下,處理用戶輸入的時候),或者調用某個會返回錯誤碼的系統 API,我們並不想直接拋出異常,但仍舊需要傳遞正確值或者錯誤碼這類信息。這些時候,最好將這類信息編碼到 either value or error 中。