TypeScript 官方手冊翻譯計劃【十一】:型別操控-模板字面量型別

Chor發表於2021-12-10
  • 說明:目前網上沒有 TypeScript 最新官方文件的中文翻譯,所以有了這麼一個翻譯計劃。因為我也是 TypeScript 的初學者,所以無法保證翻譯百分之百準確,若有錯誤,歡迎評論區指出;
  • 翻譯內容:暫定翻譯內容為 TypeScript Handbook,後續有空會補充翻譯文件的其它部分;
  • 專案地址TypeScript-Doc-Zh,如果對你有幫助,可以點一個 star ~

本章節官方文件地址:Template Literal Types

模板字面量型別

模板字面量型別基於字串字面量型別構建,可以通過聯合型別擴充成多種字串。

其語法和 JavaScript 中的模板字串一樣,但在 TypeScript 中用於表示型別。和具體的字面量型別一起使用的時候,模板字面量會通過拼接內容產生一個新的字串字面量型別。

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"

如果模板字面量有多個插值位置,那麼各位置上的聯合型別之間會進行叉積運算,從而得到最終的型別:

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"

對於大型的字串聯合型別,我們推薦你提前生成。對於較小的字串聯合型別,則可以使用上面例子中的方法生成。

型別中的字串聯合型別

模板字面量的強大之處在於它能夠基於型別中的已有資訊定義一個新的字串。

假設現在有一個 makeWatchedObject 函式,它可以給傳入的物件新增一個 on 方法。在 JavaScript 中,該函式的呼叫形如:makeWatchedObject(baseObject)。其中,傳入的物件引數類似下面這樣:

const passedObject = {
    firstName: 'Saoirse',
    lastName: 'Ronan',
    age: 26,
};

即將新增給物件的 on 方法會接受兩個引數,一個是 eventName(字串),一個是 callBack(回撥函式)。

eventName 的形式類似於 attributeInThePassedObject + 'Changed'。比如說,傳入物件有個 firstName 屬性,那麼對應就會有一個叫做 firstNameChangedeventName

callBack 回撥函式,在被呼叫的時候會:

  • 接受一個引數,引數的型別和 attributeInThePassedObject 的型別相關聯。比如說,firstName 的型別是 string,那麼 firstNameChanged 這個事件對應的回撥函式在被呼叫的時候也期望接受一個 string 型別的引數。同理,和 age 相關聯的事件回撥函式在被呼叫的時候應該接受一個 number 型別的引數。
  • 返回值型別為 void(為了方便例子的講解)

on() 的簡易版函式簽名可能是這樣的:on(eventName: string, callBack: (newValue: any) => void)。不過,從上面的描述來看,我們發現程式碼中還需要實現很重要的型別約束。而模板字面量型別正好就可以幫助我們做到這一點。

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26,
});
 
// makeWatchedObject 函式給匿名物件新增了 on 方法
 
person.on("firstNameChanged", (newValue) => {
  console.log(`firstName was changed to ${newValue}!`);
});

注意,on 監聽的事件是 "firstNameChanged",而不是 "firstName"。如果我們要確保符合條件的事件名的集合受到物件屬性名(末尾加上“Changed”)的聯合型別的約束,那麼我們的簡易版 on() 方法還需要進一步完善才行。雖然在 JavaScript 中我們可以很方便地實現這個效果,比如使用 Object.keys(passedObject).map(x => ${x}Changed),不過,型別系統中的模板字面量也提供了一種類似的操控字串的方法:

type PropEventSource<Type> = {
    on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void;
};
 
// 建立一個帶有 on 方法的監聽物件,從而監聽物件屬性的變化
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

這樣,當傳入錯誤的引數時,TypeScript 會丟擲一個錯誤:

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});
 
person.on("firstNameChanged", () => {});
 
// 預防常見的人為錯誤(錯誤地使用了物件的屬性名而不是事件名)
person.on("firstName", () => {});
// Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.
 
// 拼寫錯誤
person.on("frstNameChanged", () => {});
// Argument of type '"frstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.

模板字面量的推斷

注意,目前為止我們還沒有完全利用傳入物件提供的資訊。firstName 改變的時候(觸發 firstNameChanged 事件),我們期望回撥函式會接受一個 string 型別的引數。同理,age 改變的時候,對應的回撥函式也會接受一個 number 型別的引數。但目前,我們僅僅只是用 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" 呼叫了 on 方法的時候,TypeScript 會嘗試推斷出 Key 的正確型別。具體地說,它會將 Key"Changed" 前面的部分進行匹配,並推斷出字串 "firstName"。一旦 TypeScript 推斷完成,on 方法就可以取出原物件的 firstName 屬性的型別 —— 即 string 型別。同理,當通過 "ageChanged" 呼叫方法的時候,TypeScript 也會發現 age 屬性的型別是 number

推斷有多種不同的結合方式,通常用於解構字串,並以不同的方式對字串進行重構。

內建的字串操控型別

為了方便操控字串,TypeScript 引入了一些相關的型別。為了提高效能,這些型別是內建到編譯器中的,並且無法在 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 的字串執行時函式進行操作,並且無法做到本地化識別。

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;
}

相關文章