typescripttype EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'
type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties
type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes
type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...
function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users') // valid
callApi('PATCH /users') // TypeScript error: not in the type
适用场景:用字符串规则生成受约束的联合类型,如事件名 onClick、CSS 变量 --primary、接口标识 GET /users,避免手写枚举遗漏。
用法示例:
ts// 把方法和路径组合成唯一合法入口,调用处写错会在编译期报错
type Endpoint = `${'GET' | 'POST'} ${'/users' | '/orders'}`;
function callApi(endpoint: Endpoint) {}
callApi('GET /users');参数与返回:T extends string 表示只接受字符串字面量类型;模板类型返回按规则拼接后的字符串联合类型,不产生运行时代码。
常见坑:如果传入普通 string,结果会被放宽成 string,失去校验;配置对象建议配合 as const 保留字面量。联合项过多还会产生组合爆炸,影响类型检查性能。
这段代码在干嘛?
一句话:它是在 TypeScript 的“类型世界”里,按固定字符串规则自动拼出一批合法名字。
比如:
'click' 变成 'onClick''primary' 变成 '--primary''GET' 和 '/users' 组合成 'GET /users'然后 TypeScript 会帮你检查:你写的字符串是不是在这批合法名单里。
打个比方:它像一个自动制牌机
你可以把模板字面量类型想成一个“制牌机”。
你先告诉它规则:
前面固定写
on,后面接一个首字母大写的事件名。
再给它原材料:
click、change、submit
它就自动做出这些牌子:
onClick、onChange、onSubmit
以后别人只能拿这些牌子进门。
拿个 onSave 来,不在名单里,TypeScript 就拦住。
先看第一段:
tstype EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'
type DomEvents = EventName<'click' | 'change' | 'submit'>这里的核心是这一句:
tstype EventName<T extends string> = `on${Capitalize<T>}`拆开看。
T extends string 的意思是:
这个制牌机只接受字符串材料。
也就是说,你可以传 'click',但不能传数字、对象之类的东西。
再看这一段:
ts`on${Capitalize<T>}`这就是模板字面量类型。
它长得很像 JavaScript 里的模板字符串:
ts`hello ${name}`但注意,它不是在运行时拼字符串。
它是在 TypeScript 的类型层面拼字符串。
也就是说,它不会生成真正的运行时代码。
它只是帮你算出“哪些字符串是合法的”。
Capitalize<T> 是 TypeScript 自带的工具类型。
它的作用很简单:
tstype A = Capitalize<'click'>
// 'Click'所以:
tstype EventName<'click'>会变成:
ts'onClick'如果传进去的是一组字符串:
ts'click' | 'change' | 'submit'那它会一个个处理:
tstype DomEvents = EventName<'click' | 'change' | 'submit'>最后得到:
tstype DomEvents = 'onClick' | 'onChange' | 'onSubmit'这里的 | 可以理解成“或者”。
也就是说,DomEvents 只能是这三个值之一。
再看 CSS 变量这一段:
tstype CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'这个更直观。
规则是:
前面固定加两个横杠
--
原材料是:
ts'primary' | 'secondary' | 'bg'所以自动生成:
ts'--primary' | '--secondary' | '--bg'它适合用来约束 CSS 变量名。
比如你想规定项目里只能用这几个变量:
tstype AppVars = '--primary' | '--secondary' | '--bg'当然你可以手写。
但问题是,一多就容易漏、容易拼错。
模板字面量类型的好处就是:
规则写一次,类型自动生成。
再看接口地址这一段:
tstype HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...这个像什么?
像把“请求方法”和“接口路径”两张表做组合。
请求方法有:
ts'GET' | 'POST' | 'DELETE'接口路径有:
ts'/users' | '/orders' | '/products'模板规则是:
ts`${HttpMethod} ${Route}`中间有一个空格。
所以 TypeScript 会自动组合出:
ts'GET /users'
'GET /orders'
'GET /products'
'POST /users'
'POST /orders'
'POST /products'
'DELETE /users'
'DELETE /orders'
'DELETE /products'可以画成这样:
txtHttpMethod Route │ │ ├─ GET ├─ /users ├─ POST ├─ /orders └─ DELETE └─ /products │ │ └──────── 拼起来 ─────┘ │ ▼ 'GET /users' 'POST /orders' 'DELETE /products' ...
这就叫“联合类型的组合”。
通俗点说就是:
左边每个值,都和右边每个值配一遍。
然后这个函数就能吃到类型检查的好处:
tsfunction callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users') // valid
callApi('PATCH /users') // TypeScript error: not in the typecallApi 要求你传入的参数必须是 ApiEndpoint。
而 ApiEndpoint 里面没有:
ts'PATCH /users'因为方法只允许:
ts'GET' | 'POST' | 'DELETE'所以 TypeScript 会报错。
这个错误不是等你代码跑起来才发现。
而是在写代码、编译的时候就发现。
这就是它最实用的地方:
把“字符串拼错”这种低级问题,提前拦在开发阶段。
再看这个简化例子:
tstype Endpoint = `${'GET' | 'POST'} ${'/users' | '/orders'}`;
function callApi(endpoint: Endpoint) {}
callApi('GET /users');这段代码等价于:
tstype Endpoint =
| 'GET /users'
| 'GET /orders'
| 'POST /users'
| 'POST /orders'所以这些可以:
tscallApi('GET /users')
callApi('POST /orders')这些不行:
tscallApi('DELETE /users')
callApi('GET /products')
callApi('POST/users') // 少了中间空格,也不行注意最后这个:
ts'POST/users'看起来差不多,但模板里写的是:
ts`${'GET' | 'POST'} ${'/users' | '/orders'}`方法和路径中间有一个空格。
所以合法的是:
ts'POST /users'不是:
ts'POST/users'模板字面量类型就是这么严格。
空格、横杠、大小写,都算。
最容易踩的坑:普通 string 会让检查变松
比如这样:
tstype CSSVar<T extends string> = `--${T}`
type A = CSSVar<'primary'>
// '--primary'这个很好。
因为 'primary' 是一个很具体的字符串类型。
但如果你传的是普通 string:
tstype B = CSSVar<string>那结果就会变宽:
tstype B = `--${string}`它表示:
只要是
--开头的字符串都行。
比如:
ts'--primary'
'--abc'
'--whatever'都可能被接受。
这时候它就没有那么精确了。
更糟的是,有些场景下你的值会直接被 TypeScript 推成 string,不是具体的字面量。
比如:
tsconst routes = ['/users', '/orders']TypeScript 可能会把它理解成:
tsstring[]也就是“一个字符串数组”。
但你真正想要的是:
ts'/users' | '/orders'这时候可以加 as const:
tsconst routes = ['/users', '/orders'] as constas const 的意思可以理解成:
别把它看成随便什么字符串,就按这几个固定值来记。
这样 TypeScript 才能保留更精确的类型。
还有一个坑:组合太多会爆炸
比如你有:
tstype A = 'a1' | 'a2' | 'a3'
type B = 'b1' | 'b2' | 'b3'
type C = `${A}-${B}`结果是 3 × 3,一共 9 个:
ts'a1-b1' | 'a1-b2' | 'a1-b3' | ...这还好。
但如果你有 100 个方法、100 个路径、再加 50 个版本号:
tstype Endpoint = `${Version} ${Method} ${Route}`那数量就可能变得很大。
TypeScript 要帮你算这些组合,类型检查就会变慢。
所以它适合用来描述“有明确规则、数量可控”的字符串。
比如:
onClick--primaryGET /usersuser:createhome:button:click不适合拿来生成巨大、复杂、无限扩张的类型宇宙。
最后记一句
模板字面量类型就是 TypeScript 里的“字符串制牌机”:
你给它固定规则和一组材料,它自动拼出合法字符串名单,然后帮你拦住拼错的字符串。
它不影响运行时代码,只负责在写代码的时候帮你把字符串写对。
前几天我看一个项目里的接口调用,满屏都是这种字符串:
tscallApi('GET /users')
callApi('POST /orders')
callApi('DELETE /products')第一眼没啥问题。第二眼就开始不放心了:GET 会不会有人写成 Get?中间那个空格会不会漏?/users 会不会哪天写成 /user?
这种字符串最烦的地方是,它看起来很简单,但错了以后不一定马上爆。等接口 404、埋点丢失、CSS 变量不生效,你才回头查一个拼写问题,挺亏的。
模板字面量类型干的就是这件事:在 TypeScript 的类型世界里,按规则把字符串拼出来,然后让你只能用这些合法字符串。
tstype EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'
type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties
type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes
type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...
function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users') // valid
callApi('PATCH /users') // TypeScript error: not in the type可以把它想成一个“字符串制牌机”。
你先告诉它规则:
txton + 首字母大写的事件名
再给它材料:
txtclick、change、submit
它就自动做出:
txtonClick、onChange、onSubmit
以后谁拿着 onSave 进来,TypeScript 一看名单里没有,直接拦住。
先看事件名这段:
ts// 定义一个类型工具 EventName
// T extends string 表示:T 必须是字符串类型,不能是 number、object 之类
type EventName<T extends string> = `on${Capitalize<T>}`
// 把 'click' | 'change' | 'submit' 传进去
// Capitalize 会把首字母转成大写
type DomEvents = EventName<'click' | 'change' | 'submit'>
// 得到:'onClick' | 'onChange' | 'onSubmit'关键是这一句:
tstype EventName<T extends string> = `on${Capitalize<T>}`它长得很像 JavaScript 里的模板字符串:
ts`hello ${name}`但它不是运行时拼字符串。
它是在类型层面拼字符串。
也就是说,它不会真的生成一段 JS 代码,也不会在浏览器里运行。它只是告诉 TypeScript:
txt合法字符串应该长这样。
Capitalize<T> 是 TypeScript 自带的工具类型。它的作用很直白:
tstype A = Capitalize<'click'>
// 'Click'所以:
tstype EventName<'click'>会变成:
ts'onClick'如果传进去的是一组值:
ts'click' | 'change' | 'submit'TypeScript 会一个个套规则,最后得到:
ts'onClick' | 'onChange' | 'onSubmit'这里的 | 可以先理解成“或者”。
也就是说,DomEvents 只能是这三个字符串之一。
再看 CSS 变量这段,更像一台小机器:
ts// CSS 变量都要求以 -- 开头
// T 是变量名,比如 'primary'
type CSSVar<T extends string> = `--${T}`
// 把项目里的变量名传进去
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// 得到:'--primary' | '--secondary' | '--bg'如果你不用模板字面量类型,也可以手写:
tstype AppVars = '--primary' | '--secondary' | '--bg'少的时候没问题。
但字段一多,就开始容易漏。比如设计稿里加了 danger,你只改了某个地方,类型没补。或者你手一抖写成了 --primay,少了一个 r。
模板字面量类型的舒服点在于:
txt规则写一次,名单自动生成。
它不替你决定业务规则,但能保证你按照规则写字符串。
接口这段最能体现它的价值:
ts// 允许的请求方法
type HttpMethod = 'GET' | 'POST' | 'DELETE'
// 允许的接口路径
type Route = '/users' | '/orders' | '/products'
// 方法 + 空格 + 路径
type ApiEndpoint = `${HttpMethod} ${Route}`
// 得到:
// 'GET /users' | 'GET /orders' | 'GET /products'
// 'POST /users' | 'POST /orders' | 'POST /products'
// 'DELETE /users' | 'DELETE /orders' | 'DELETE /products'这里 TypeScript 做的是“全排列组合”。
左边每个方法,都和右边每个路径配一遍。
txtHttpMethod Route │ │ ├─ GET ├─ /users ├─ POST ├─ /orders └─ DELETE └─ /products │ │ └────────── 按模板拼接 ───────┘ │ ▼ `${HttpMethod} ${Route}` │ ▼ ┌───────────────────────────────┐ │ 'GET /users' │ │ 'GET /orders' │ │ 'GET /products' │ │ 'POST /users' │ │ 'POST /orders' │ │ 'POST /products' │ │ 'DELETE /users' │ │ 'DELETE /orders' │ │ 'DELETE /products' │ └───────────────────────────────┘
所以这个函数:
tsfunction callApi(endpoint: ApiEndpoint) {
// ...
}就不是随便收一个字符串了。
它只收这 9 个合法组合。
tscallApi('GET /users')
// 可以,因为它在 ApiEndpoint 里
callApi('PATCH /users')
// 报错,因为 HttpMethod 里没有 PATCH
callApi('POST/users')
// 也报错,因为模板里方法和路径中间有一个空格这个点很重要。
模板字面量类型很严格。大小写、横杠、冒号、空格,都算。
你写的是:
ts`${HttpMethod} ${Route}`那合法的就是:
ts'GET /users'不是:
ts'GET/users'中间少了空格,也不行。
这就是它最实用的地方:把“字符串拼错”这种低级问题,提前拦在写代码的时候。
上面那版适合理解概念。真实项目里,我更建议从配置数组里推类型。这样运行时配置和类型来源是同一份,不用维护两套。
TS 版可以这么写:
ts// api.ts
// as const 的意思可以先理解成:
// “别把它们当成普通 string,按这几个固定值记住”
const methods = ['GET', 'POST', 'DELETE'] as const
const routes = ['/users', '/orders', '/products'] as const
// 从数组里取出联合类型
type HttpMethod = typeof methods[number]
// 'GET' | 'POST' | 'DELETE'
type Route = typeof routes[number]
// '/users' | '/orders' | '/products'
// 组合出合法接口标识
export type ApiEndpoint = `${HttpMethod} ${Route}`
export function callApi(endpoint: ApiEndpoint) {
const [method, route] = endpoint.split(' ')
return fetch(route, {
method,
})
}
callApi('GET /users')
// ✅ 可以
callApi('PATCH /users')
// ❌ 报错:PATCH 不在 methods 里
callApi('POST/users')
// ❌ 报错:少了中间空格CSS 变量也可以这样管:
ts// theme.ts
const colorTokens = ['primary', 'secondary', 'bg', 'danger'] as const
type ColorToken = typeof colorTokens[number]
// 'primary' | 'secondary' | 'bg' | 'danger'
export type CSSVarName = `--${ColorToken}`
export function getCssVar(name: CSSVarName) {
return `var(${name})`
}
getCssVar('--primary')
// ✅ 可以
getCssVar('primary')
// ❌ 报错:少了 --
getCssVar('--main')
// ❌ 报错:main 不在 colorTokens 里事件名也一样:
tstype NativeEvent = 'click' | 'change' | 'submit'
type HandlerName = `on${Capitalize<NativeEvent>}`
// 'onClick' | 'onChange' | 'onSubmit'
const handlerName: HandlerName = 'onClick'
// ✅
const badHandlerName: HandlerName = 'onclick'
// ❌ 报错:大小写不对如果你没有 TypeScript,只写 JS,那就没有“编译期类型检查”这件事了。JS 版本只能在运行时兜底,比如用数组和 Set 做校验:
js// api.js
const methods = ['GET', 'POST', 'DELETE']
const routes = ['/users', '/orders', '/products']
const validEndpoints = new Set(
methods.flatMap((method) =>
routes.map((route) => `${method} ${route}`)
)
)
function callApi(endpoint) {
if (!validEndpoints.has(endpoint)) {
throw new Error(`Invalid API endpoint: ${endpoint}`)
}
const [method, route] = endpoint.split(' ')
return fetch(route, {
method,
})
}
callApi('GET /users')
// ✅ 可以
callApi('PATCH /users')
// ❌ 运行时报错这两个版本的区别要分清:
txtTypeScript 版:写代码时就报错 JavaScript 版:代码跑起来才检查
所以如果你已经在用 TS,模板字面量类型的价值就在这里:很多字符串问题,不用等到运行时才炸。
模板字面量类型最容易踩的坑,是把具体字符串弄丢了。
比如这样:
tstype CSSVar<T extends string> = `--${T}`
type A = CSSVar<'primary'>
// '--primary'这个很精确。
但如果你传的是普通 string:
tstype B = CSSVar<string>
// `--${string}`这时候它就不是“只能是 --primary”了,而是:
txt只要以 -- 开头,后面是什么都行。
比如这些都可能通过:
tsconst a: B = '--primary'
const b: B = '--hello'
const c: B = '--whatever'它还有一点约束,至少要求 -- 开头,但已经不是精确名单了。
更常见的是数组没加 as const:
tsconst routes = ['/users', '/orders']TypeScript 往往会把它理解成:
tsstring[]意思是:这是一个字符串数组,里面可以是任何字符串。
但你真正想要的是:
ts'/users' | '/orders'这时候要写:
tsconst routes = ['/users', '/orders'] as constas const 就像给这份名单盖个章:
txt就这几个值,别给我放宽成普通 string。
还有一个坑是组合爆炸。
比如:
tstype Version = 'v1' | 'v2' | 'v3'
type Method = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type Endpoint = `${Version} ${Method} ${Route}`这里会产生:
txt3 × 3 × 3 = 27 个组合
27 个还好。
但如果你有 20 个版本、30 个方法标识、200 个路径,那就是:
txt20 × 30 × 200 = 120000 个组合
TypeScript 要帮你算这些类型,编辑器提示、类型检查都有可能变慢。
所以它适合这些场景:
txt事件名:onClick、onChange CSS 变量:--primary、--bg 接口标识:GET /users 权限 key:user:create、order:delete 埋点名:home:banner:click
不适合这些场景:
txt组合数量巨大 字符串规则经常变 本身就是用户输入的任意内容 为了炫技把简单类型写成十层嵌套
说白了,模板字面量类型不是让你把所有字符串都塞进类型系统里。
它最适合管那种“有固定格式、数量可控、写错代价还挺烦”的字符串。
最后记一句就够了:
txt模板字面量类型 = TypeScript 里的字符串制牌机。
你给它规则和材料,它自动拼出合法名单。
你写错大小写、空格、前缀、路径,TypeScript 提前拦。
你项目里有没有这种字符串?比如权限 key、接口名、埋点名、CSS 变量名。要是现在还全靠手写,最适合先挑一类改成模板字面量类型试试。

扫码关注「前端达人」,干货代码每周更新