TypeScript 之模板字面量型別

冴羽發表於2021-12-13

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 方法會被新增到這個傳入物件上,該方法接受兩個引數,eventNamestring 型別) 和 callBackfunction 型別):

// 虛擬碼
const result = makeWatchedObject(baseObject);
result.on(eventName, callBack);

我們希望 eventName 是這種形式:attributeInThePassedObject + "Changed" ,舉個例子,passedObject 有一個屬性 firstName,對應產生的 eventNamefirstNameChanged,同理,lastName 對應的是 lastNameChangedage 對應的是 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 型別。實現這個約束的關鍵在於藉助泛型函式:

  1. 捕獲泛型函式第一個引數的字面量,生成一個字面量型別
  2. 該字面量型別可以被物件屬性構成的聯合約束
  3. 物件屬性的型別可以通過索引訪問獲取
  4. 應用此型別,確保回撥函式的引數型別與物件屬性的型別是同一個型別
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,對作者也是一種鼓勵。

相關文章