網站搜尋

為什麼 TypeScript 無法從過濾後的陣列推斷類型?


以下是一些範例程式碼。TypeScript 推斷 validStudents 的類型為 Students[]。對於任何閱讀程式碼的人來說,應該很明顯,由於所有無效的紀錄都被過濾掉,validStudents 可以安全地被視為具有 ValidStudents[] 的類型。

interface Student {
    name: string;
    isValid: boolean;
}
type ValidStudent = Student & { isValid: true };

const students: Student[] = [
    {
        name: 'Jane Doe',
        isValid: true,
    },
    {
        name: "Robert'); DROP TABLE Students;--",
        isValid: false,
    }
];

const validStudents = students.filter(student => student.isValid);

function foo(students: ValidStudent[]) {
    console.log(students);
}

// the next line throws compile-time errors:
// Argument of type 'Student[]' is not assignable to parameter of type 'ValidStudent[]'.
//   Type 'Student' is not assignable to type 'ValidStudent'.
//     Type 'Student' is not assignable to type '{ isValid: true; }'.
//       Types of property 'isValid' are incompatible.
//         Type 'boolean' is not assignable to type 'true'.ts(2345)
foo(validStudents);

可以通過添加類型斷言來使這段代碼正常運行:

const validStudents = students.filter(student => student.isValid) as ValidStudent[];

... 但感覺有點偷工減料。(或者也許我只是比相信自己更信任編譯器!)

有沒有更好的方法來處理這個問題?

答案#1

這裡發生了幾件事情。


第一個(小)問題是,使用你的 Student 介面時,編譯器不會將檢查 isValid 屬性視為類型守衛:

const s = students[Math.random() < 0.5 ? 0 : 1];
if (s.isValid) {
    foo([s]); // error!
    //   ~
    // Type 'Student' is not assignable to type 'ValidStudent'.
}

編譯器只有在檢查屬性時,如果物件的類型是區分聯合類型且你正在檢查其區分屬性時,才能縮小物件的類型。但是 Student 介面並不是聯合類型,不論是否區分;它的 isValid 屬性是聯合類型,但 Student 本身不是。

幸運地,你可以通過將聯合類型提升到頂層,獲得幾乎等同於 Student 的區分聯合版本:

interface BaseStudent {
    name: string;
}
interface ValidStudent extends BaseStudent {
    isValid: true;
}
interface InvalidStudent extends BaseStudent {
    isValid: false;
}
type Student = ValidStudent | InvalidStudent;

現在編譯器將能夠使用控制流程分析來理解上述檢查:

if (s.isValid) {
    foo([s]); // okay
}

這個變更並非至關重要,因為修正它不會突然讓編譯器能夠推斷你的 filter() 篩選。但如果真的有可能做到這一點,你將需要使用類似判別聯合(discriminated union)的方法,而不是使用具有聯合值屬性的介面。


主要問題是 TypeScript 不會將控制流程分析的結果從函數實現內部傳播到函數被調用的作用域。

function isValidStudentSad(student: Student) {
    return student.isValid;
}

if (isValidStudentSad(s)) {
    foo([s]); // error!
    //   ~
    // Type 'Student' is not assignable to type 'ValidStudent'.
}

在 isValidStudentSad() 裡,編譯器知道 student.isValid 意味著 student 是一個 ValidStudent,但是 在 isValidStudentSad() 外,編譯器只知道它返回一個 boolean,對傳入參數的類型沒有任何影響。

處理這種缺乏推斷的一種方法是將此類 boolean 返回的函數標註為使用者定義的類型保護函數。編譯器無法推斷它,但你可以斷言它:

function isValidStudentHappy(student: Student): student is ValidStudent {
    return student.isValid;
}
if (isValidStudentHappy(s)) {
    foo([s]); // okay
}

isValidStudentHappy 的返回類型是一個型別謂詞,student 是 ValidStudent。現在編譯器將理解 isValidStudentHappy(s) 對 s 的型別有影響。

請注意,有人曾在 microsoft/TypeScript#16069 建議,也許編譯器 應該 能夠推斷 student.isValid 的返回值的型別謂詞。但這個議題已經開放很久,我沒有看到任何明顯的工作跡象,因此目前我們不能期望它會被實現。

另外請注意,你可以將箭頭函數標註為使用者定義的型別守衛……與 isValidStudentHappy 相等的寫法是:

const isValidStudentArrow = 
  (student: Student): student is Student => student.isValid;

我們快完成了。如果你將回調函數標註為 filter() 的使用者定義類型守衛,將會發生一件美妙的事情:

const validStudents = 
  students.filter((student: Student): student is ValidStudent => student.isValid);

foo(validStudents); // okay!

對 foo() 的呼叫通過了類型檢查!那是因為 TypeScript 標準庫對 Array.filter() 的型別定義被賦予了一個新的呼叫簽名,當回呼函數是一個使用者自定義的型別保護時,會縮小陣列的型別。太棒了!


所以這大概是編譯器能為你做到的最佳效果了。缺乏對類型保護函數的自動推斷意味著到頭來,你仍然是在告訴編譯器回呼函數會進行類型縮小,而這並不比你在問題中使用的類型斷言安全多少。但它確實稍微安全一些,也許有一天類型謂詞會被自動推斷出來。

程式碼的 Playground 連結