EnriqueMark
TS

TS泛型和extends

2026-02 繁體中文

<T>泛型物件應該是靜態語言中比較難以理解的一個概念。但就其本質而言,它就像是一個佔位符:通過告訴編譯器這裡未來會有某個型別——它既可以是已有的型別,也可以是自定義的型別。 總之這裡需要有一個型別,該型別會按照我後續的形式進行操作和定義。說白了,就是將這裡的宣告指代過去,或者用一個保留字來指代。這裡的 T 不一定非得寫成 T,寫成什麼都行;但本質上,它就是一個在這裡預定義的「型別變數」,可以指代任何型別。從而在除錯或編寫程式碼時獲得更好的提示支援。因此,泛型的作用非常大,可以讓程式碼在保持型別安全的前提下,獲得極大的通用性和靈活性。

不過雖然可以隨便寫,但是還是有一個約定俗成的習慣,大概就是下面這些:

  • T = Type(通用型別)
  • K / V = Key / Value(鍵值對場景)
  • E = Element(集合元素)
  • R = Return(返回型別)

除此之外,還有一個「泛型約束」很有意思:它可以指定該泛型必須具備某些屬性,且規定這些屬性的型別。它不強制要求其他內容,但必須存在指定的屬性才能通過校驗。也就是說,只有當該指定屬性存在時,程式才會允許繼續執行。

// T 可以是任何类型,但前提是它得有个 length 属性,且该属性的类型为 number
function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength("hello");     // ✓ string 有 length
getLength([1, 2, 3]);   // ✓ array 有 length
getLength(123);         // ✗ number 没有 length,编译报错

A extends B 可以理解為 A 需要滿足 B,或者說 A 包含 B , A 是 B 的超集,或者說 B 是 A 的子集。

如果我後面指定的是一個集合,且該集合裡面有一個屬性,那麼本質上就是說 A 既然包含了這個集合,因而也必然要有其屬性。而這個地方如果寫的是一個空集,那麼它在JS裡就是一個包含了任何非 null/undefined 值的集合。

比如 T extends {}和單獨寫個 T ,兩者在除了 null/undefined 外沒有其他區別。既然萬物皆物件,那型別本質上也是一個集合,當該集合具備某個屬性的時候,就和這裡的約束條件 B 對上了,或者說是這個集合裡面的屬性對上了,因此 A 就包含了 B。

這樣來理解的話,其實任何繼承本質上都是在構建一個包含了父類的「超集」。因為繼承的子類,必然是在具備父類所有屬性和方法的前提之下,又擁有了自己的屬性和方法,那麼這其實就是一個超集。雖然「繼承」這個詞乍一聽,好像子類應該是父類的子集,但實際上其實是反過來的,也就是父類是子類的子集。

之所以叫父類和子類,其實是從「例項」或者說是「值」的角度來看待的。

就拿 Animal 和 Dog 來說:

  1. 所有的 Dog 必然都是 Animal,但反過來說,Animal 卻不一定是 Dog,因為 Animal 本身是一個更抽象的存在。
  2. 從這個角度講,其實 Dog 是 Animal 的子集。

但是從屬性的角度來看,Dog 就擁有比 Animal 更多的屬性和方法,因此它們的關係又是反過來的。

  • 如果以「抽象程度」來劃分,那肯定是 Animal 更高層級,更抽象,因此是更「大」的。
  • 但越往「具體」的方向走,子類所擁有的具體方法和具體屬性肯定就越多,在這個方面,它更「大」。 這是兩個不同的觀察角度。

extends 的使用場景,一般是我需要在方法裡面呼叫泛型的某個屬性時,才必須要這樣指定,否則會因為調用不了而導致編譯器報錯。但如果我不是為了呼叫某個屬性,而只是單純地使用...展開,或者說白了,是對它所有的型別進行一個遍歷,那就不需要特別去指定它。

這種情況就是要麼全有,要麼全無;如果要用到具體的某一個屬性,就必須在裡面宣告。這就跟我呼叫某個變數之前要先宣告一樣,因為泛型本身可以是任何型別,編譯器又不知道它會包含哪些屬性,如果你不對它進行最小屬性的宣告,那後面這裡一定會出錯。

// 这里就不需要特别地进行 extends 声明,因为里面没有调用到具体的属性
function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

// 这里就必须得声明了,因为里面调用了其中一个泛型的属性 id,如果不声明就会报错
function merge<T extends { id: number }, U>(obj1: T, obj2: U): T & U {
  console.log(obj1.id); 
  return { ...obj1, ...obj2 };
}

比較有意思的是,這裡的 & 符號從屬性的角度來講,是一個並集,也就是最終的輸出型別,必然擁有這兩種型別的全部屬性和方法;但是如果從值的角度上來講,是一個交集,也就是說,要求最終的輸出集合必須既屬於T型別又屬於U型別。

另外還有一個豎線(|),其實這個跟 if 語句裡面的各種符號都一樣:&(與)、|(或)、非(!)。這些符號的使用邏輯都是一致的。當然,在型別判斷裡面沒有「非」這個邏輯。泛型的話,因為它本身就代表任何型別都可以,沒有非很正常。但在具體型別上,也不存在「非某個型別」。因為這意味著除了該具體型別之外的任何一種型別都可以滿足條件,那該集合內的型別就變成無限種,所以ts並沒有提供這種對型別的宣告方式。

另一方面,<>裡面的型別其實就更像宣告一個容器的內容是啥。它告訴我的是:返回的這個型別物件裡面會是怎樣的型別。

Array<number>      // 数组,里面装的是 number ,也可以写成 number[]
Promise<Response>  // Promise,最终产出的是 Response
Map<string, User>  // Map,里面装着键-值元素,键是 string,值是 User