file

TypeScript 當中有 unknown 這個型別已經是好一陣子的事情了,網路上也有很多文章在解釋它跟 any 的差別,然而那些文章大多都只是從定義層面上在探討,並沒有真的回答到「為什麼我們應該要用 unknown」的這個關鍵問題,因此這邊我想稍微寫一點心得。

一開始,我先用我自己的方式來定義一下兩者:

  • any 型別是指「這個東西同時為任何的東西」。
  • unknown 型別是指「我們不知道這個東西是什麼東西」。

注意到我的用詞,這導致了兩者決定性的差異:

  • 不管你要對 any 型別的變數進行任何操作都可以,因為它同時是任何的東西!
  • 不管你要對 unknown 型別的變數進行任何操作(除了純粹的讀寫和比較之外)都不行,因為你根本不知道它是什麼東西。

這樣聽起來,unknown 好像非常難用,所以很多新手在當他遇到「不知道是什麼東西的東西」的時候,他會直覺選擇使用 any 而非 unknown。可是從軟體架構的角度來看,這樣做卻是危險的,而唯有理解為什麼,我們才會知道 unknown 其實是個很好用的東西;我們甚至還會得到一個結論是:一個健全的 TypeScript 程式,應該要幾乎沒有使用到 any 型別才對

功能一:保證變數的值不會被不小心地操作到

我們來想像一個寫底層架構的時候常常遇到的情境。我們在寫一個輔助函數,比方說這個函數是用來比較兩個陣列是否相等用的。然而,我們並沒去對「誰會來用這個輔助函數」多作假定,所以我們只知道傳入的參數一定是陣列,但是陣列裡面有可能是任何的東西。

問題來了,當我們在宣告參數的類別的時候,我們不能只說「反正這個參數是一個陣列」,因為陣列在 TypeScript 中是一個泛型,TypeScript 要求我們在使用陣列型別的時候一定要指定泛型參數。但是我們不知道陣列裡頭有什麼,怎麼辦呢?我們可能會這樣寫這個輔助函數:

function compareArray(arr1: any[], arr2: any[]): boolean {
    // 如果連陣列大小都不一樣那肯定就不一樣,直接傳回結果
    if(arr1.length != arr2.length) return false;

    for(let i = 0; i < arr1.length; i++) {
        if(arr1[i] !== arr2[i]) return false;
    }
    return true;
}

可能有人會問,為什麼不改用泛型的宣告,例如下面這樣:

function compareArray<T>(arr1: T[], arr2: T[]) {
    ...
}

原因有兩點:一、我們並沒有規定說陣列裡面放的一定是同一種型別的東西,有可能是各種不同型別的東西一起來的;二、這種泛型的宣告會使得被比較的兩個陣列一定要打從一開始就是同一種陣列、才能夠呼叫這個函數,否則在編譯階段就會被 TypeScript 擋下來,但是實務上我們可能會真的想要在執行階段中拿一個數字陣列和一個字串陣列來作比較。

總結起來,兩點原因都是基於同樣的軟體架構哲學:我們不希望對「呼叫這個函數的程式碼」作太多的假定,而希望這個函數可以是以最靈活的方式被呼叫。

那這樣一來,看起來把參數宣告成 any[] 就是必然的了。可是我們來稍微發揮一下想像力吧:假如今天我們突然想要寫另外一個函數,它的功能是只會去比較陣列的偶數索引。因為程式碼很類似,所以接到這個任務的菜鳥工程師就直接複製貼上,然後稍微改幾個字,但是他卻手殘了:

function compareArrayAtEven(arr1: any[], arr2: any[]): boolean {
    // 如果只要比較偶數索引,那兩個陣列的大小最多可以差一
    let diff = arr1.length - arr2.length;
    if(diff > 1 || diff < -1) return false;

    for(let i = 0; i < arr1.length && i < arr2.length; i++) { // 錯了
        if(arr1[i] !== arr2[i] + 2) return false; // 錯了
    }
    return true;
}

原本他應該是要把 i++ 修改成 i += 2 才對,但是他當時剛好在跟公司新來的可愛女生聊天,一時不注意把 + 2 的事情寫到下一行去了。

此時 any 的一個大缺陷就暴露了出來:對於 TypeScript 來說,因為 arr2[i] 是一個 any,不管要幹嘛都可以,所以當然我們也可以拿它來作加法或其它操作,因此這個程式就這樣通過了編譯而沒有報出任何錯誤,以至於這個錯誤得一直等到不知道過了多久、在執行時發現比較的結果不正確、才會來檢查程式碼而發現菜鳥的疏忽。而我們知道,程式碼的錯誤越晚被發現、代價往往也越高;說不定這項錯誤會一直等到程式都已經上線了才被發現,而搞不好已經因為這個錯誤引發了許多嚴重的交易問題。

然而,好的型別利用卻可以幫助在一開始就避免這個問題。如果我們原本在寫 compareArray 的時候改用 unknown 型別,那麼菜鳥在手殘的時候就會馬上發現:

function compareArrayAtEven(arr1: unknown[], arr2: unknown[]): boolean {
    let diff = arr1.length - arr2.length;
    if(diff > 1 || diff < -1) return false;
    for(let i = 0; i < arr1.length && i < arr2.length; i++) {
        if(arr1[i] !== arr2[i] + 2) return false; // TypeScript 會在這邊報錯
    }
    return true;
}

可以參見這個 Playground 來看報錯的效果。

因為我們不能夠對 unknown 型別的東西進行任何除了讀寫和比較以外的操作,所以拿 arr2[i] 來進行加法、或者呼叫它的成員等等都是不行的,我們只能原封不動地把它跟另外一個變數做比較、或是把它的值原封不動地儲存到另一個變數之中等等。使用了 unknown,就可以避免一切這種不小心的手殘,因為這個型別可以保證變數的值絕對不會被動到——而在實務上,幾乎所有「我們不知道這個變數的型別是什麼」的底層情境中,我們確實就是希望能保證這一點。這很合理:既然我們不知道它是什麼,那就表示我們不知道它有哪些操作可用,而如此一來我們又怎麼會想要去操作它呢?

反過來,用了 any 其實在軟體架構觀點上,簡直就是在自己的程式碼裡面放了一個不定時炸彈,隨便一個不小心都可能導致對變數的值做了我們本來沒有要做的操作。

所以這就是 unknown 的第一個重要功能:在很多底層的程式碼中,我們不知道傳入的值是什麼型別,但是我們本來也就沒有要對它做任何操作、而只是要暫時存放或比較它,此時用 unknown 就能保證它絕對不會不小心被動到。

功能二:負責把關來自外界的輸入

假設我們的 TypeScript 程式有一些對外的公開 API 可以讓別的程式來呼叫,那其實我們對於它們傳進來的參數是沒有任何掌控的,尤其如果那些「別的程式」是由不同的團隊來撰寫的時候更是如此。我們當然可以在 API 文件上面寫清楚我們預期傳入的參數是什麼型別,但是他們會不會照做(或甚至文件有沒有看清楚)就又是另外一回事了。

因此,其實在內部程式碼當中,對於任何對外的 API 介面,不管我們實際上預期的參數型別為何,我們一開始都應該要把其參數宣告成 unknown 型別才比較安全 1,然後之後再來在程式碼中做該有的型別檢查以限縮參數的型別。例如下面的 API 是一個預期一個字串參數的例子:

export function splitString(arg: unknown): string[] {
    if(typeof arg == 'string') {
        // 檢查完了之後才做我們真正要做的事情,例如:
        return arg.split(',');
        // 此處 TypeScript 會正確地知道 arg 的型別為字串,
        // 因為剛才我們用 typeof 檢查過了。
        // 對於更複雜的型別檢查,可以用 instanceof 關鍵字、
        // 或是使用 TypeScript 的 TypeGuard 來達成,
        // 這是題外話,這邊先不深入討論。
    } else {
        // 否則看要怎麼進行錯誤處理;這邊的方法是傳回預設值
        return [];
        // 或者也可以丟出一個 Error 包含了我們自訂的錯誤訊息
    }
}

養成把對外 API 的參數宣告成 unknown 的好習慣,並且避免使用 TypeScript 中的 as 關鍵字來作型別斷言(取而代之地,應該使用 instanceof 或自訂的 TypeGuard 來確認型別),將可以提醒寫程式的人永遠要對傳入值做必要的檢查(否則在型別限縮之前,我們將根本不能對參數進行操作),如此一來便可以預防各種執行階段中的不可預期現象發生。反之如果是宣告成 any,那就有可能不小心對參數值做出原本我們沒有要做的事情。

any 是否永遠都不該使用?

很大的程度上來說,是的;我個人現在是「TypeScript 程式碼中應該要幾乎沒有 any 才對」主張的擁護者。對於很多我接手的程式碼,我都會先搜尋出所有使用到 any 的地方,而它們大多都可以直接換成 unknown 而不用作額外的修改——而如果換成 unknown 之後某個地方因此就出現了編譯錯誤,那幾乎在所有的情況中,那都是突顯出了程式碼具有潛在的異味(bad smell),而釐清為什麼改成 unknown 之後有編譯錯誤、往往能讓程式碼變得更加健全。

只有一種情況是我會勉強接受 any 的使用的,那就是當我們引入了一個第三方的型別,我們很清楚其規格、但是我們偏偏又沒有該型別的完整定義檔、而我們自己去寫定義檔又很浪費時間的時候。然而,這樣的使用有幾個前提是應該要遵守的:

  1. 使用了 any 型別的物件應該要充分地被封裝起來,使得使用它的程式碼都非常清楚該變數要怎麼操作。如果有很多程式碼依賴於該物件,那應該要提供一個有良好定義型別的介面來讓其它程式碼間接操作該物件,而不是讓所有程式碼直接取用它。
  2. 對於該物件傳回的值,應該馬上用型別斷言或型別檢查來確定其型別,而不是繼續讓傳回值維持 any 的狀態。

如果沒辦法做到這兩點,那最好還是花一點時間自己把該型別當中會用到的東西宣告一下,這不僅能讓程式碼更有條理,也可以避免很多潛在的手殘可能性。


  1. 至於定義檔(.d.ts 檔案)則應該要寫清楚預期的是什麼型別,這是兩回事。 

分享此頁至:
最後修改日期: 2021/05/06

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。