跳到主要内容

模板字面量类型与字符串操作

问题

什么是 TypeScript 模板字面量类型?如何使用 UppercaseLowercase 等内置字符串操作类型?

答案

模板字面量类型是 TypeScript 4.1 引入的特性,允许在类型系统中进行字符串操作和模式匹配,类似于 JavaScript 的模板字符串,但作用于类型层面。


基础语法

// 模板字面量类型
type Greeting = `Hello, ${string}`;

const g1: Greeting = 'Hello, World'; // OK
const g2: Greeting = 'Hello, Alice'; // OK
// const g3: 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'

// 与数字结合
type Position = 1 | 2 | 3;
type GridCell = `cell-${Position}-${Position}`;
// 'cell-1-1' | 'cell-1-2' | 'cell-1-3' | ...(共 9 种)

内置字符串操作类型

Uppercase / Lowercase

type Greeting = 'hello';

type Upper = Uppercase<Greeting>; // 'HELLO'
type Lower = Lowercase<'HELLO'>; // 'hello'

// 应用于联合类型
type Colors = 'red' | 'green' | 'blue';
type UpperColors = Uppercase<Colors>; // 'RED' | 'GREEN' | 'BLUE'

Capitalize / Uncapitalize

type Hello = 'hello world';

type Capitalized = Capitalize<Hello>; // 'Hello world'
type Uncapitalized = Uncapitalize<'Hello'>; // 'hello'

// 实际应用:事件名称转换
type EventName = 'click' | 'focus' | 'blur';
type OnEvent = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'

类型推断与模式匹配

使用 infer 从字符串中提取部分:

基本提取

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

type Result = RemovePrefix<'onClick', 'on'>; // 'Click'

// 提取后缀前的部分
type RemoveSuffix<S extends string, P extends string> =
S extends `${infer Rest}${P}` ? Rest : S;

type Result2 = RemoveSuffix<'loading...', '...'>; // 'loading'

分割字符串

// 分割成元组
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: S extends ''
? []
: [S];

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

连接字符串

// 联合类型转字符串
type Join<T extends string[], D extends string> = T extends []
? ''
: T extends [infer F extends string]
? F
: T extends [infer F extends string, ...infer R extends string[]]
? `${F}${D}${Join<R, D>}`
: never;

type Joined = Join<['a', 'b', 'c'], '-'>; // 'a-b-c'

常见字符串转换

驼峰命名 (camelCase)

// kebab-case 转 camelCase
type CamelCase<S extends string> =
S extends `${infer First}-${infer Rest}`
? `${Lowercase<First>}${CamelCase<Capitalize<Rest>>}`
: Lowercase<S>;

type Result = CamelCase<'background-color'>; // 'backgroundColor'
type Result2 = CamelCase<'font-size'>; // 'fontSize'
type Result3 = CamelCase<'margin-top-left'>; // 'marginTopLeft'

短横线命名 (kebab-case)

// camelCase 转 kebab-case
type KebabCase<S extends string> = S extends `${infer First}${infer Rest}`
? First extends Uppercase<First>
? First extends Lowercase<First>
? `${First}${KebabCase<Rest>}` // 非字母
: `-${Lowercase<First>}${KebabCase<Rest>}` // 大写字母
: `${First}${KebabCase<Rest>}` // 小写字母
: S;

type Result = KebabCase<'backgroundColor'>; // 'background-color'
type Result2 = KebabCase<'fontSize'>; // 'font-size'

下划线命名 (snake_case)

// camelCase 转 snake_case
type SnakeCase<S extends string> = S extends `${infer First}${infer Rest}`
? First extends Uppercase<First>
? First extends Lowercase<First>
? `${First}${SnakeCase<Rest>}`
: `_${Lowercase<First>}${SnakeCase<Rest>}`
: `${First}${SnakeCase<Rest>}`
: S;

type Result = SnakeCase<'backgroundColor'>; // 'background_color'

Pascal 命名

// 转 PascalCase
type PascalCase<S extends string> =
S extends `${infer First}-${infer Rest}`
? `${Capitalize<First>}${PascalCase<Rest>}`
: Capitalize<S>;

type Result = PascalCase<'background-color'>; // 'BackgroundColor'

实际应用场景

事件处理器类型

type EventMap = {
click: MouseEvent;
focus: FocusEvent;
keydown: KeyboardEvent;
};

// 自动生成 on 方法类型
type EventHandlers<T> = {
[K in keyof T as `on${Capitalize<K & string>}`]: (event: T[K]) => void;
};

type Handlers = EventHandlers<EventMap>;
// {
// onClick: (event: MouseEvent) => void;
// onFocus: (event: FocusEvent) => void;
// onKeydown: (event: KeyboardEvent) => void;
// }

Getter/Setter 生成

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

interface State {
name: string;
age: number;
}

type StateGetters = Getters<State>;
// { getName: () => string; getAge: () => number }

type StateSetters = Setters<State>;
// { setName: (value: string) => void; setAge: (value: number) => void }

路由参数提取

type ExtractParams<T extends string> = 
T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};

type Params = ExtractParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }

// 路由定义
function createRoute<T extends string>(
path: T,
handler: (params: ExtractParams<T>) => void
) {
// ...
}

createRoute('/users/:id', (params) => {
console.log(params.id); // 类型安全
});

CSS 属性转换

// CSS 属性对象键转换
type CSSInJS = {
backgroundColor: string;
fontSize: string;
marginTop: string;
paddingBottom: string;
};

// 转为 CSS 属性名
type CSSProperties = {
[K in keyof CSSInJS as KebabCase<K & string>]: CSSInJS[K];
};
// {
// 'background-color': string;
// 'font-size': string;
// 'margin-top': string;
// 'padding-bottom': string;
// }

高级技巧

字符串长度

type StringLength<S extends string, T extends any[] = []> = 
S extends `${infer _}${infer Rest}`
? StringLength<Rest, [...T, any]>
: T['length'];

type Len = StringLength<'hello'>; // 5
type Len2 = StringLength<''>; // 0

字符串反转

type Reverse<S extends string> = S extends `${infer First}${infer Rest}`
? `${Reverse<Rest>}${First}`
: S;

type Reversed = Reverse<'hello'>; // 'olleh'

替换字符串

type Replace<
S extends string,
From extends string,
To extends string
> = S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${Tail}`
: S;

type ReplaceAll<
S extends string,
From extends string,
To extends string
> = S extends `${infer Head}${From}${infer Tail}`
? ReplaceAll<`${Head}${To}${Tail}`, From, To>
: S;

type Result1 = Replace<'hello world', 'world', 'TypeScript'>;
// 'hello TypeScript'

type Result2 = ReplaceAll<'a-b-c', '-', '_'>;
// 'a_b_c'

去除空格

type TrimLeft<S extends string> = S extends ` ${infer Rest}`
? TrimLeft<Rest>
: S;

type TrimRight<S extends string> = S extends `${infer Rest} `
? TrimRight<Rest>
: S;

type Trim<S extends string> = TrimLeft<TrimRight<S>>;

type Trimmed = Trim<' hello world '>; // 'hello world'

常见面试问题

Q1: 如何将对象键从 camelCase 转换为 kebab-case?

答案

// 驼峰转连字符
type CamelToKebab<S extends string> = S extends `${infer C}${infer Rest}`
? C extends Uppercase<C>
? C extends Lowercase<C>
? `${C}${CamelToKebab<Rest>}`
: `-${Lowercase<C>}${CamelToKebab<Rest>}`
: `${C}${CamelToKebab<Rest>}`
: '';

// 转换对象键
type KeysToKebab<T> = {
[K in keyof T as CamelToKebab<K & string>]: T[K];
};

// 测试
interface CamelStyle {
backgroundColor: string;
fontSize: number;
marginTop: string;
}

type KebabStyle = KeysToKebab<CamelStyle>;
// {
// 'background-color': string;
// 'font-size': number;
// 'margin-top': string;
// }

Q2: 如何从路由路径中提取参数类型?

答案

// 方法一:递归提取
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;

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

// 方法二:构建对象类型
type ParamsObject<T extends string> = {
[K in ExtractParams<T>]: string;
};

type ParamsObj = ParamsObject<'/users/:id/posts/:postId'>;
// { id: string; postId: string }

// 类型安全的路由函数
function navigate<T extends string>(
path: T,
params: ParamsObject<T>
): void {
// ...
}

navigate('/users/:id', { id: '123' }); // OK
// navigate('/users/:id', {}); // 错误:缺少 id

Q3: 实现 Split 和 Join 类型

答案

// Split:字符串分割为元组
type Split<
S extends string,
D extends string
> = S extends ''
? []
: S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];

// 测试
type Parts = Split<'a.b.c', '.'>; // ['a', 'b', 'c']
type Empty = Split<'', '.'>; // []
type NoDel = Split<'hello', '.'>; // ['hello']

// Join:元组连接为字符串
type Join<
T extends string[],
D extends string
> = T extends []
? ''
: T extends [infer First extends string]
? First
: T extends [infer First extends string, ...infer Rest extends string[]]
? `${First}${D}${Join<Rest, D>}`
: never;

// 测试
type Joined = Join<['a', 'b', 'c'], '.'>; // 'a.b.c'
type Single = Join<['hello'], '-'>; // 'hello'
type Empty2 = Join<[], '-'>; // ''

Q4: 如何构建类型安全的 i18n 函数?

答案

// 提取模板变量
type ExtractVars<T extends string> =
T extends `${string}{${infer Var}}${infer Rest}`
? Var | ExtractVars<Rest>
: never;

// 构建参数对象
type I18nVars<T extends string> = {
[K in ExtractVars<T>]: string | number;
};

// 翻译函数
function t<T extends string>(
template: T,
vars: I18nVars<T> extends Record<string, never>
? never
: I18nVars<T>
): string;
function t<T extends string>(template: T): string;
function t<T extends string>(
template: T,
vars?: I18nVars<T>
): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key]));
}

// 使用
t('Hello, {name}!', { name: 'Alice' }); // OK
t('Hello, World!'); // OK,无参数
// t('Hello, {name}!', {}); // 错误:缺少 name
// t('Hello, {name}!', { age: 25 }); // 错误:需要 name 不是 age

Q5: 模板字面量类型的性能问题?

答案

// 模板字面量类型会产生笛卡尔积,数量可能爆炸

type Letter = 'a' | 'b' | 'c' | 'd' | 'e';
type Digit = '0' | '1' | '2' | '3' | '4';

// 组合数量:5 * 5 * 5 = 125 种
type Code = `${Letter}${Digit}${Letter}`;

// 如果类型太多,TypeScript 会报错或变慢
// "Expression produces a union type that is too complex to represent"

// 解决方案:
// 1. 减少联合成员数量
// 2. 使用更宽泛的类型
type SafeCode = `${string}${string}${string}`;

// 3. 分步处理
type LetterDigit = `${Letter}${Digit}`;
type Code2 = `${LetterDigit}${Letter}`; // 更可控

// 4. 使用模板字符串模式而非穷举
type AnyCode = `${string}-${string}-${string}`;
// 只验证格式,不穷举所有可能

相关链接