TypeScript 模版字面量型別 (Template Literal Types)

模版字面量型別 (Template Literal Types) 是 TypeScript 4.1 引入的功能,它結合了字面量型別和模版字串的能力,允許我們在型別層級進行字串操作。

基本語法

使用反引號和 ${} 語法:

type Greeting = `Hello, ${string}`;

let a: Greeting = 'Hello, World'; // OK
let b: Greeting = 'Hello, TypeScript'; // OK
// let c: Greeting = "Hi, World";     // 錯誤

與字面量型別結合

type Color = 'red' | 'green' | 'blue';
type Size = 'small' | 'medium' | 'large';

type ColoredSize = `${Color}-${Size}`;
// "red-small" | "red-medium" | "red-large" |
// "green-small" | "green-medium" | "green-large" |
// "blue-small" | "blue-medium" | "blue-large"

let item: ColoredSize = 'red-small'; // OK
let item2: ColoredSize = 'green-large'; // OK
// let item3: ColoredSize = "yellow-small";  // 錯誤

內建字串操作型別

TypeScript 提供了四個內建的字串操作型別:

Uppercase<S>

將字串轉換為大寫:

type Loud = Uppercase<'hello'>; // "HELLO"

type Event = 'click' | 'focus';
type UpperEvent = Uppercase<Event>; // "CLICK" | "FOCUS"

Lowercase<S>

將字串轉換為小寫:

type Quiet = Lowercase<'HELLO'>; // "hello"

type Command = 'START' | 'STOP';
type LowerCommand = Lowercase<Command>; // "start" | "stop"

Capitalize<S>

將首字母大寫:

type Title = Capitalize<'hello'>; // "Hello"

type Property = 'name' | 'age';
type GetterName = `get${Capitalize<Property>}`; // "getName" | "getAge"

Uncapitalize<S>

將首字母小寫:

type Lower = Uncapitalize<'Hello'>; // "hello"

type Method = 'GetUser' | 'SetUser';
type CamelMethod = Uncapitalize<Method>; // "getUser" | "setUser"

事件處理器模式

type EventName = 'click' | 'focus' | 'blur' | 'change';

type EventHandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur" | "onChange"

type EventHandler = (event: Event) => void;

type EventHandlers = {
  [K in EventName as `on${Capitalize<K>}`]: EventHandler;
};
// {
//     onClick: EventHandler;
//     onFocus: EventHandler;
//     onBlur: EventHandler;
//     onChange: EventHandler;
// }

Getter 和 Setter 模式

interface User {
  name: string;
  age: number;
  email: string;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};

type Setters<T> = {
  [K in keyof T as `set${Capitalize<K & string>}`]: (value: T[K]) => void;
};

type UserGetters = Getters<User>;
// {
//     getName: () => string;
//     getAge: () => number;
//     getEmail: () => string;
// }

type UserSetters = Setters<User>;
// {
//     setName: (value: string) => void;
//     setAge: (value: number) => void;
//     setEmail: (value: string) => void;
// }

type UserAccessors = Getters<User> & Setters<User>;

CSS 屬性型別

type CSSUnit = 'px' | 'em' | 'rem' | '%' | 'vh' | 'vw';
type CSSValue = `${number}${CSSUnit}`;

let width: CSSValue = '100px'; // OK
let height: CSSValue = '50vh'; // OK
let margin: CSSValue = '1.5rem'; // OK
// let padding: CSSValue = "10";  // 錯誤

type CSSColor = `#${string}` | `rgb(${number}, ${number}, ${number})`;

let color1: CSSColor = '#ff0000'; // OK
let color2: CSSColor = 'rgb(255, 0, 0)'; // OK

API 路由型別

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'posts' | 'comments';

type ApiEndpoint = `/${ApiVersion}/${Resource}`;
// "/v1/users" | "/v1/posts" | "/v1/comments" |
// "/v2/users" | "/v2/posts" | "/v2/comments"

type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;
// "GET /v1/users" | "POST /v1/users" | ...

// 帶參數的路由
type ResourceId = `/${Resource}/:id`;
// "/users/:id" | "/posts/:id" | "/comments/:id"

type NestedResource = `/${Resource}/:id/${Resource}`;
// "/users/:id/users" | "/users/:id/posts" | ...

提取字串部分

結合條件型別和 infer

// 提取前綴後的部分
type RemovePrefix<S extends string, P extends string> = S extends `${P}${infer Rest}`
  ? Rest
  : never;

type WithoutGet = RemovePrefix<'getName', 'get'>; // "Name"
type WithoutOn = RemovePrefix<'onClick', 'on'>; // "Click"

// 提取後綴前的部分
type RemoveSuffix<S extends string, Suffix extends string> = S extends `${infer Rest}${Suffix}`
  ? Rest
  : never;

type WithoutId = RemoveSuffix<'userId', 'Id'>; // "user"

分割字串

type Split<S extends string, D extends string> = S extends `${infer T}${D}${infer U}`
  ? [T, ...Split<U, D>]
  : [S];

type Parts = Split<'a-b-c', '-'>; // ["a", "b", "c"]
type Words = Split<'hello world', ' '>; // ["hello", "world"]

路徑參數提取

type ExtractParams<Path extends string> =
  Path extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : Path extends `${infer _Start}:${infer Param}`
      ? Param
      : never;

type Params = ExtractParams<'/users/:userId/posts/:postId'>;
// "userId" | "postId"

字串轉換

駝峰式轉連字號

type CamelToKebab<S extends string> = S extends `${infer C}${infer Rest}`
  ? C extends Uppercase<C>
    ? `-${Lowercase<C>}${CamelToKebab<Rest>}`
    : `${C}${CamelToKebab<Rest>}`
  : S;

type Kebab = CamelToKebab<'backgroundColor'>; // "background-color"
type Kebab2 = CamelToKebab<'fontSize'>; // "font-size"

連字號轉駝峰式

type KebabToCamel<S extends string> = S extends `${infer Start}-${infer Rest}`
  ? `${Start}${KebabToCamel<Capitalize<Rest>>}`
  : S;

type Camel = KebabToCamel<'background-color'>; // "backgroundColor"
type Camel2 = KebabToCamel<'font-size'>; // "fontSize"

實用範例

i18n 鍵值型別

type Locale = 'en' | 'zh' | 'ja';
type Namespace = 'common' | 'home' | 'about';

type I18nKey = `${Namespace}.${string}`;
type LocalizedKey = `${Locale}:${I18nKey}`;

function translate(key: I18nKey): string {
  // ...
  return '';
}

translate('common.hello'); // OK
translate('home.title'); // OK
// translate("invalid");       // 錯誤

環境變數型別

type EnvPrefix = 'NEXT_PUBLIC_' | 'REACT_APP_' | 'VITE_';
type EnvVar = `${EnvPrefix}${string}`;

type PublicEnv = `NEXT_PUBLIC_${string}`;
type ApiUrl = `${PublicEnv}API_URL`;

const apiUrl: ApiUrl = 'NEXT_PUBLIC_API_URL';

資料庫查詢

type TableName = 'users' | 'posts' | 'comments';
type Column<T extends TableName> = T extends 'users'
  ? 'id' | 'name' | 'email'
  : T extends 'posts'
    ? 'id' | 'title' | 'content' | 'author_id'
    : T extends 'comments'
      ? 'id' | 'text' | 'post_id' | 'user_id'
      : never;

type SelectQuery<T extends TableName> = `SELECT ${Column<T> | '*'} FROM ${T}`;

type UserQuery = SelectQuery<'users'>;
// "SELECT id FROM users" | "SELECT name FROM users" | ...

type WhereClause<T extends TableName> = `WHERE ${Column<T>} = ?`;

type FullQuery<T extends TableName> = `${SelectQuery<T>} ${WhereClause<T>}`;

Redux Action 型別

type EntityName = 'user' | 'post' | 'comment';
type ActionVerb = 'fetch' | 'create' | 'update' | 'delete';

type ActionType = `${EntityName}/${ActionVerb}`;
// "user/fetch" | "user/create" | "user/update" | "user/delete" |
// "post/fetch" | "post/create" | ...

type AsyncActionType =
  | `${ActionType}`
  | `${ActionType}/pending`
  | `${ActionType}/fulfilled`
  | `${ActionType}/rejected`;

interface Action<T extends AsyncActionType> {
  type: T;
  payload?: unknown;
}

效能考量

模版字面量型別與聯合型別結合時,可能會產生大量的組合:

// 這會產生很多組合!
type A = 'a' | 'b' | 'c' | 'd' | 'e'; // 5 個
type B = '1' | '2' | '3' | '4' | '5'; // 5 個

type Combined = `${A}${B}`; // 25 個組合

// 更多層級會指數增長
type Triple = `${A}${B}${A}`; // 125 個組合
當聯合型別過大時,可能會影響編譯效能。建議在必要時才使用,並注意組合數量。