
在寫各種演算法的時候,我們常常會在陣列裡面存放一些物件,然後用陣列的數值索引來當作這些物件的 id。隨著演算法的各種操作,這些代表著 id 的索引值也常常會被儲存在別的物件當中、當作指向原本物件的指標(當然,直接儲存物件的參照也是可以,但是這樣會不利於序列化和 GC)。這本來沒什麼大不了的,但是有一天,當我看著我宣告的類別裡面有很多欄位的型別都是 number、可是它們卻具有本質上不同的意義時,我突然意識到這樣的設計是有缺陷的。
如果一個欄位宣告的型別就只是 number 的話,除非命名得很好、或是我額外註解而且謹記在心,要不然我並不會知道其實那個欄位應該是代表著索引值 id 的。說不定,我還會不小心拿那些「數值」來進行運算,即使那樣的運算在絕大多數的情況下是毫無意義的。就算我知道它是代表 id 好了,我也可能會記錯它是對應於那一類型的物件,以至於我或許會不小心拿了 A 類別的 id 去檢索 B 類別的物件, 一樣導致沒有意義的結果。
當然,如果今天我是在寫別的程式語言的話,我可能還不會這麼在意,但是我在說的可是 TypeScript、是當今型別系統最變態的語言,沒有之一。它幾乎什麼東西都有辦法區分開來,沒理由這邊的問題就解決不了。
於是我就在思考,有沒有可能建立一個 number 的擴充型別,使得它具備 number 大部分的性質(例如可以拿來當作陣列的索引值、也可以比較大小),但是沒有辦法被運算、也不能用在其它類別的物件上?我不知道過去是否也有人思考過類似的問題,畢竟這個概念我也不知道要怎麼搜尋關鍵字,但總之我最後想出了一個如下的手法。
假想我有一個物件類別叫 Item 好了。我用如下的寫法:
class Item { /* 具體內容不重要 */ }
type ItemId = number & { _ItemId: undefined };
這樣一來,ItemId 型別當然也可以當作 number 使用,但是反之卻不然(除非我們明確轉型)。舉例來說:
const items: Item[] = [new Item()];
const id: ItemId = 0 as ItemId; // 必須明確轉換,不然會報錯
const item = items[id]; // ItemId 衍生自 number,可以當作索引
然後,就像我想要的一樣,TypeScript 會接受我拿 ItemId 來比較大小(這在一些演算法中會需要);但如果我拿 ItemId 來作運算的話,結果就不再是 ItemId,而退化成單純的 number 了,於是指派就會出錯:
declare const id1: ItemId, id2: ItemId;
if (id1 < id2) console.log("smaller"); // 可以比大小
const id3: ItemId = id1 + id2 // 會報錯:不能指派 number 給 ItemId
如此一來,我就可以很放心 ItemId 型態並不會被拿來運算。而如果今天我有一個這樣的介面:
interface Store {
selectedId: ItemId;
}
const store: Store = {
selectedId: 0, // 會報錯,不能指派隨便的數值
};
我就可以很放心這個 selectedId 欄位不會被指派成不相關的值,一定只有型別為 ItemId 的數值才能被填入。類似地,我也可以放心地用 ItemId 當作鍵:
const map = new Map<ItemId, Item>();
console.log(map.has(0)); // 會報錯,不能用隨便的數值來查找
然後,今天如果我想要再創造一種 id 類別,我只要依樣畫葫蘆就好了:
type ObjectId = number & { _ObjectId: undefined };
declare const objId: ObjectId;
store.selectedId = objId; // 會報錯:ObjectId 跟 ItemId 不相容
到這邊都還不錯。但是回到稍早的 items 陣列上,我們注意到它只是一個普通的陣列而已,也就是說它並不會阻擋例如 items[0] 這樣傳入普通數值的查找。我們是否有辦法類似地打造一個專用的陣列型別,是只能接受 ItemId 的、但是仍舊具備大部分的陣列功能(例如內建的 map() 方法等等)呢?注意到我們可能不希望這個陣列具備全部的陣列方法,例如 shift() 方法就不合理:那類的方法會完全打亂 id 與物件之間的配對關係。所以我們的專屬陣列型態,基本上只需要那些 ReadonlyArray 的方法即可。
此時就可以來施展一點 TypeScript 咒語了:
type IdArray<K extends number, V> =
Record<K, V | undefined> &
Omit<ReadonlyArray<V | undefined>, number>;
type ItemArray = IdArray<ItemId, Item>;
其中,IdArray 的第一個部份表示它可以用 K 型別來進行索引(但是我們要考慮到某些索引值可能會對應 undefined),第二部份則是說它具備了 ReadonlyArray 的所有方法、然而卻不能用單純的數值來作索引。如此一來,ItemArray 型別就一樣具有陣列的便利性:
declare const arr: ItemArray;
for (let i = 0 as ItemId; i < arr.length; i++) { // i++ 是可以接受的
console.log(arr[i]);
}
for (const item of arr) { // arr 具有 [Symbol.iterator] 方法
console.log(item);
}
arr.forEach(item => console.log(item)); // 唯讀陣列的方法都可以用
但是同時又兼具了 ItemId 的型別安全性:
console.log(arr[3]); // 會報錯,不能用隨便的數值來查找
const first = arr.shift(); // 會報錯,不能使用會打亂陣列的方法
以上的範例都可以參見這個 Playground 的效果。
附錄
假如有需要很多種 id 的話,也可以利用如下的工具類別:
type IdNumber<name extends string> = number & { [k in `_${name}`]: undefined };
如此一來,就可以簡化產生新 id 類別的語法了:
type Id1 = IdNumber<"Id1">;
type Id2 = IdNumber<"Id2">;
...
另外值得補充一提的是,這篇講的東西除了型別狂熱之外,另外一個很不錯的附加好處就是、可以更容易掌握特定型別的數字出現在專案中的位置。真的用下去是會上癮的喔~請小心服用 ?
留言