TypeScript 的官方文件早已更新,但我能找到的中文文件都還停留在比較老的版本。所以對其中新增以及修訂較多的一些章節進行了翻譯整理。
本篇翻譯整理自 TypeScript Handbook 中 「Template Literal Types」 章節。
本文並不嚴格按照原文翻譯,對部分內容也做了解釋補充。
模板字面量型別(Template Literal Types)
模板字面量型別以字串字面量型別為基礎,可以通過聯合型別擴充套件成多個字串。
它們跟 JavaScript 的模板字串是相同的語法,但是隻能用在型別操作中。當使用模板字面量型別時,它會替換模板中的變數,返回一個新的字串字面量:
type World = "world";
type Greeting = `hello ${World}`;
// type Greeting = "hello world"
當模板中的變數是一個聯合型別時,每一個可能的字串字面量都會被表示:
type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
// type AllLocaleIDs = "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id"
如果模板字面量裡的多個變數都是聯合型別,結果會交叉相乘,比如下面的例子就有 2 2 3 一共 12 種結果:
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
type Lang = "en" | "ja" | "pt";
type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`;
// type LocaleMessageIDs = "en_welcome_email_id" | "en_email_heading_id" | "en_footer_title_id" | "en_footer_sendoff_id" | "ja_welcome_email_id" | "ja_email_heading_id" | "ja_footer_title_id" | "ja_footer_sendoff_id" | "pt_welcome_email_id" | "pt_email_heading_id" | "pt_footer_title_id" | "pt_footer_sendoff_id"
如果真的是非常長的字串聯合型別,推薦提前生成,這種還是適用於短一些的情況。
型別中的字串聯合型別(String Unions in Types)
模板字面量最有用的地方在於你可以基於一個型別內部的資訊,定義一個新的字串,讓我們舉個例子:
有這樣一個函式 makeWatchedObject
, 它會給傳入的物件新增了一個 on
方法。在 JavaScript 中,它的呼叫看起來是這樣:makeWatchedObject(baseObject)
,我們假設這個傳入物件為:
const passedObject = {
firstName: "Saoirse",
lastName: "Ronan",
age: 26,
};
這個 on
方法會被新增到這個傳入物件上,該方法接受兩個引數,eventName
( string
型別) 和 callBack
(function
型別):
// 虛擬碼
const result = makeWatchedObject(baseObject);
result.on(eventName, callBack);
我們希望 eventName
是這種形式:attributeInThePassedObject + "Changed"
,舉個例子,passedObject
有一個屬性 firstName
,對應產生的 eventName
為 firstNameChanged
,同理,lastName
對應的是 lastNameChanged
,age
對應的是 ageChanged
。
當這個 callBack
函式被呼叫的時候:
- 應該被傳入與
attributeInThePassedObject
相同型別的值。比如passedObject
中,firstName
的值的型別為string
, 對應firstNameChanged
事件的回撥函式,則接受傳入一個string
型別的值。age
的值的型別為number
,對應ageChanged
事件的回撥函式,則接受傳入一個number
型別的值。 - 返回值型別為
void
型別。
on()
方法的簽名最一開始是這樣的:on(eventName: string, callBack: (newValue: any) => void)
。 使用這樣的簽名,我們是不能實現上面所說的這些約束的,這個時候就可以使用模板字面量:
const person = makeWatchedObject({
firstName: "Saoirse",
lastName: "Ronan",
age: 26,
});
// makeWatchedObject has added `on` to the anonymous Object
person.on("firstNameChanged", (newValue) => {
console.log(`firstName was changed to ${newValue}!`);
});
注意這個例子裡,on
方法新增的事件名為 "firstNameChanged"
, 而不僅僅是 "firstName"
,而回撥函式傳入的值 newValue
,我們希望約束為 string
型別。我們先實現第一點。
在這個例子裡,我們希望傳入的事件名的型別,是物件屬性名的聯合,只是每個聯合成員都還在最後拼接一個 Changed
字元,在 JavaScript 中,我們可以做這樣一個計算:
Object.keys(passedObject).map(x => ${x}Changed)
模板字面量提供了一個相似的字串操作:
type PropEventSource<Type> = {
on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void;
};
/// Create a "watched object" with an 'on' method
/// so that you can watch for changes to properties.
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;
注意,我們在這裡例子中,模板字面量裡我們寫的是 string & keyof Type
,我們可不可以只寫成 keyof Type
呢?如果我們這樣寫,會報錯:
type PropEventSource<Type> = {
on(eventName: `${keyof Type}Changed`, callback: (newValue: any) => void): void;
};
// Type 'keyof Type' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
// Type 'string | number | symbol' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
// ...
從報錯資訊中,我們也可以看出報錯原因,在 《TypeScript 系列之 Keyof 操作符》裡,我們知道 keyof
操作符會返回 string | number | symbol
型別,但是模板字面量的變數要求的型別卻是 string | number | bigint | boolean | null | undefined
,比較一下,多了一個 symbol 型別,所以其實我們也可以這樣寫:
type PropEventSource<Type> = {
on(eventName: `${Exclude<keyof Type, symbol>}Changed`, callback: (newValue: any) => void): void;
};
再或者這樣寫:
type PropEventSource<Type> = {
on(eventName: `${Extract<keyof Type, string>}Changed`, callback: (newValue: any) => void): void;
};
使用這種方式,在我們使用錯誤的事件名時,TypeScript 會給出報錯:
const person = makeWatchedObject({
firstName: "Saoirse",
lastName: "Ronan",
age: 26
});
person.on("firstNameChanged", () => {});
// Prevent easy human error (using the key instead of the event name)
person.on("firstName", () => {});
// Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.
// It's typo-resistant
person.on("frstNameChanged", () => {});
// Argument of type '"frstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.
模板字面量的推斷(Inference with Template Literals)
現在我們來實現第二點,回撥函式傳入的值的型別與對應的屬性值的型別相同。我們現在只是簡單的對 callBack
的引數使用 any
型別。實現這個約束的關鍵在於藉助泛型函式:
- 捕獲泛型函式第一個引數的字面量,生成一個字面量型別
- 該字面量型別可以被物件屬性構成的聯合約束
- 物件屬性的型別可以通過索引訪問獲取
- 應用此型別,確保回撥函式的引數型別與物件屬性的型別是同一個型別
type PropEventSource<Type> = {
on<Key extends string & keyof Type>
(eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void ): void;
};
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;
const person = makeWatchedObject({
firstName: "Saoirse",
lastName: "Ronan",
age: 26
});
person.on("firstNameChanged", newName => {
// (parameter) newName: string
console.log(`new name is ${newName.toUpperCase()}`);
});
person.on("ageChanged", newAge => {
// (parameter) newAge: number
if (newAge < 0) {
console.warn("warning! negative age");
}
})
這裡我們把 on
改成了一個泛型函式。
當一個使用者呼叫的時候傳入 "firstNameChanged"
,TypeScript 會嘗試著推斷 Key
正確的型別。它會匹配 key
和 "Changed"
前的字串 ,然後推斷出字串 "firstName"
,然後再獲取原始物件的 firstName
屬性的型別,在這個例子中,就是 string
型別。
內建字元操作型別(Intrinsic String Manipulation Types)
TypeScript 的一些型別可以用於字元操作,這些型別處於效能的考慮被內建在編譯器中,你不能在 .d.ts
檔案裡找到它們。
Uppercase<StringType>
把每個字元轉為大寫形式:
type Greeting = "Hello, world"
type ShoutyGreeting = Uppercase<Greeting>
// type ShoutyGreeting = "HELLO, WORLD"
type ASCIICacheKey<Str extends string> = `ID-${Uppercase<Str>}`
type MainID = ASCIICacheKey<"my_app">
// type MainID = "ID-MY_APP"
Lowercase<StringType>
把每個字元轉為小寫形式:
type Greeting = "Hello, world"
type QuietGreeting = Lowercase<Greeting>
// type QuietGreeting = "hello, world"
type ASCIICacheKey<Str extends string> = `id-${Lowercase<Str>}`
type MainID = ASCIICacheKey<"MY_APP">
// type MainID = "id-my_app"
Capitalize<StringType>
把字串的第一個字元轉為大寫形式:
type LowercaseGreeting = "hello, world";
type Greeting = Capitalize<LowercaseGreeting>;
// type Greeting = "Hello, world"
Uncapitalize<StringType>
把字串的第一個字元轉換為小寫形式:
type UppercaseGreeting = "HELLO WORLD";
type UncomfortableGreeting = Uncapitalize<UppercaseGreeting>;
// type UncomfortableGreeting = "hELLO WORLD"
字元操作型別的技術細節
從 TypeScript 4.1 起,這些內建函式會直接使用 JavaScript 字串執行時函式,而不是本地化識別 (locale aware)。
function applyStringMapping(symbol: Symbol, str: string) {
switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
}
return str;
}
TypeScript 系列
TypeScript 系列文章由官方文件翻譯、重難點解析、實戰技巧三個部分組成,涵蓋入門、進階、實戰,旨在為你提供一個系統學習 TS 的教程,全系列預計 40 篇左右。點此瀏覽全系列文章,並建議順便收藏站點。
微信:「mqyqingfeng」,加我進冴羽唯一的讀者群。
如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。