TypeScript的类型系统在处理复杂类型转换时,条件类型搭配infer关键字是实现类型提取的核心手段,而当待推断的内容被包裹在模板字面量类型中时,很多开发者会遇到推断不符合预期的问题。模板字面量类型本身是TypeScript 4.1引入的特性,允许我们通过字符串类型的拼接来构造新的类型,结合条件类型中的infer,我们可以从符合特定格式的字符串类型中提取出其中动态的部分。不过这种组合使用有严格的语法和逻辑规则,一旦位置放错或者条件判断的逻辑有问题,就会导致类型推断失败。
模板字面量类型中infer的基本使用规则
首先我们需要明确模板字面量类型的基本结构,它由固定的字符串部分和占位的类型部分组成,占位部分可以是string、number、boolean等基础类型,也可以是其他自定义的类型。当我们需要在条件类型中推断模板字面量类型里的某个部分时,必须把infer关键字放在模板字面量类型的对应占位位置,而不是放在模板字面量类型的外部。很多初学者会错误地写成type A<T> = T extends infer U ? ... : never这种形式,然后试图在U中匹配模板字面量,这种做法无法正确提取模板字面量内的子类型。
正确的写法是将infer直接嵌入到模板字面量类型的对应位置。比如我们有一个字符串类型,格式是`${string}-${string}`,也就是由两个字符串用短横线连接而成,如果想要提取短横线前面的部分,就可以这样写:
type ExtractPrefix<T extends string> = T extends `${infer Prefix}-${string}` ? Prefix : never;
// 测试用例
type Test1 = ExtractPrefix<"hello-world">; // 类型为 "hello"
type Test2 = ExtractPrefix<"foo-bar-baz">; // 类型为 "foo",因为infer只会匹配到第一个短横线之前的内容
type Test3 = ExtractPrefix<"test">; // 类型为 never,因为不符合模板格式
这里要注意infer的位置必须和模板的结构完全对应,模板字面量中的每一个固定字符都要准确匹配,占位的部分用infer声明待推断的类型变量。如果模板中有多个动态部分,每个部分都可以用单独的infer变量来接收,比如提取短横线前后的两个部分:
type ExtractBoth<T extends string> = T extends `${infer First}-${infer Second}` ? [First, Second] : never;
type Test4 = ExtractBoth<"a-b">; // 类型为 ["a", "b"]
type Test5 = ExtractBoth<"x-y-z">; // 类型为 ["x", "y-z"],因为第二个infer会匹配剩余的所有内容
联合类型场景下的推断分发特性
当传入条件类型的待判断类型是联合类型时,TypeScript的条件类型会触发分发特性,也就是对联合类型中的每一个成员分别进行条件判断,最后把所有满足条件的结果合并成新的联合类型。这个特性在模板字面量类型推断中非常重要,很多开发者遇到联合类型推断结果不符合预期的问题,都是因为没有理解分发特性的运作逻辑。
比如我们有一个联合类型"a-1" | "b-2" | "c-3",想要提取每个字符串中短横线前面的部分,使用之前的ExtractPrefix类型:
type UnionTest = ExtractPrefix<"a-1" | "b-2" | "c-3">; // 类型为 "a" | "b" | "c"
这里TypeScript会分别对"a-1"、"b-2"、"c-3"三个类型执行条件判断,每个都符合`${infer Prefix}-${string}`的格式,所以分别提取出"a"、"b"、"c",再合并成联合类型。但是如果联合类型中存在不符合模板格式的成员,比如"a-1" | "b-2" | "test",那么不符合的"test"会返回never,合并后never会被自动过滤掉,最终结果是"a" | "b"。
如果我们想要避免分发特性,比如希望把整个联合类型当作一个整体来判断是否符合模板格式,而不是逐个成员判断,就需要用方括号把泛型参数和 extends后面的类型包裹起来,或者使用元组类型包裹。比如:
type ExtractPrefixNoDist<T extends string> = [T] extends [`${infer Prefix}-${string}`] ? Prefix : never;
type UnionTest2 = ExtractPrefixNoDist<"a-1" | "b-2" | "test">; // 类型为 never,因为整个联合类型不符合模板格式
type UnionTest3 = ExtractPrefixNoDist<"a-1" | "b-2">; // 类型为 "a" | "b",因为整个联合的每个成员都符合,分发特性还是会在元组内部触发?不对,这里其实是[T]是联合类型的元组,只有当整个元组符合模板时才会匹配,实际上联合类型的元组不会符合字符串模板,所以这里的正确写法是如果需要整体判断,应该用T extends `${infer Prefix}-${string}` ? Prefix : never 本身就会分发,要阻止分发需要把泛型参数用[]包起来放在extends左边,比如:
type PreventDist<T> = T extends any ? T extends `${infer Prefix}-${string}` ? Prefix : never : never;
// 这种方式其实还是分发,正确的阻止分发的方式是让extends左边的类型不是裸类型参数,比如用[ T ]作为左边:
type NoDist<T extends string> = [T] extends [`${infer Prefix}-${string}`] ? Prefix : never;
// 此时如果T是联合类型,[T]是一个元组类型,比如T是"a-1"|"b-2",[T]是["a-1"|"b-2"],这个元组不符合`${infer Prefix}-${string}`的格式,所以返回never,而如果是单个类型比如"a-1",[T]是["a-1"],符合格式,返回"a"
复杂模板结构的推断策略与常见误区
在实际开发中,我们遇到的模板字面量类型往往比简单的短横线连接复杂得多,比如可能有可选部分、重复结构或者嵌套的模板。这时候infer的使用需要更谨慎,否则很容易推断出错误的结果。比如我们要处理一个路径格式的类型,格式是/user/${string}/post/${string},也就是用户ID和帖子ID的路径,这时候需要正确放置两个infer变量:
type ExtractPathParams<T extends string> = T extends `/user/${infer UserId}/post/${infer PostId}` ? { userId: UserId; postId: PostId } : never;
type Test6 = ExtractPathParams<"/user/123/post/456">; // 类型为 { userId: "123"; postId: "456" }
type Test7 = ExtractPathParams<"/user/abc/post/def">; // 类型为 { userId: "abc"; postId: "def" }
type Test8 = ExtractPathParams<"/user/123">; // 类型为 never,不符合路径格式
很多开发者在这里会犯的错误是,把infer放在模板的固定部分,或者漏写固定字符,比如写成T extends `/user/${infer UserId}/post/${infer PostId}`是正确的,但如果写成T extends `/user${infer UserId}/post/${infer PostId}`,就会把斜杠漏掉,导致匹配失败。另外如果模板中有重复的固定字符,比如多个短横线,infer会匹配到第一个符合条件的内容,除非我们用更严格的类型约束,比如限定infer的类型不是任意字符串,而是符合特定格式的类型。
还有一个常见的误区是,当模板字面量类型中的动态部分可能是数字类型时,想要直接推断出number类型,而不是字符串类型的数字。这时候需要结合模板字面量类型中的数字占位,不过TypeScript的模板字面量类型中infer出来的数字部分默认是字符串类型,比如"123"而不是123,如果需要转换成数字类型,需要额外做一层转换:
type StringToNumber<T extends string> = T extends `${infer N extends number}` ? N : never;
type ExtractUserId<T extends string> = T extends `/user/${infer UserId}/post/${string}` ? StringToNumber<UserId> : never;
type Test9 = ExtractUserId<"/user/123/post/456">; // 类型为 123,而不是 "123"
这里用到了TypeScript 4.8引入的extends约束在infer变量上的特性,infer N extends number表示推断出来的N必须符合number类型的字符串表示,也就是只能是由数字组成的字符串,然后自动转换成对应的数字字面量类型。如果没有这个约束,UserId会是字符串类型,无法直接转换成数字。
另外当待推断的类型位于模板字面量类型的末尾时,比如格式是`${string}-${string}`,末尾的infer会匹配所有剩余的内容,包括后面的短横线,比如"a-b-c"匹配这个模板的话,第一个infer是"a",第二个infer是"b-c",而不是只匹配到下一个短横线。如果需要匹配到特定的分隔符,就需要在模板中把所有分隔符都写出来,比如`${infer A}-${infer B}-${infer C}`来处理三个部分用短横线连接的情况。
实际开发中的工具类型封装示例
理解了上述规则之后,我们可以封装一些实用的工具类型来处理常见的字符串提取场景。比如一个通用的模板提取类型,可以接收模板字符串和待匹配的类型,自动提取模板中所有的infer变量:
type ExtractTemplateParams<T extends string, Template extends string> = T extends Template ? T : never;
// 不过这个类型太简单,无法提取infer变量,更好的方式是针对每个模板单独写,或者用泛型约束模板的结构
// 比如封装一个提取URL中查询参数的类型,假设URL格式是`${string}?${string}`,查询参数是key=value用&连接
type ExtractQueryString<T extends string> = T extends `${string}?${infer Query}` ? Query : never;
type ExtractQueryParams<T extends string> = ExtractQueryString<T> extends `${infer Param}&${infer Rest}`
? [Param, ...ExtractQueryParams<`?${Rest}`>]
: ExtractQueryString<T> extends `${infer Param}`
? [Param]
: [];
type Test10 = ExtractQueryParams<"https://ippipp.com?name=test&age=18">; // 类型为 ["name=test", "age=18"]
这个例子用到了递归条件类型,因为查询参数可能有多个,需要循环拆分&分隔符。不过要注意TypeScript的递归深度限制,如果查询参数太多可能会报错。另外在实际项目中,我们还可以结合这些类型来做运行时的类型校验,比如函数的参数类型约束,确保传入的字符串符合特定的模板格式,同时自动推断出提取出来的部分的类型,减少类型断言的使用。
当我们处理一些自定义的DSL或者配置文件格式的类型时,模板字面量类型加条件类型推断的组合可以极大地提升类型的准确性和开发体验。比如我们有一个自定义的日志格式类型,格式是[${string}] ${string}: ${string},分别是时间、日志级别、日志内容,我们可以封装类型来提取这三个部分:
type LogFormat = `[${string}] ${string}: ${string}`;
type ParseLog<T extends LogFormat> = T extends `[${infer Time}] ${infer Level}: ${infer Content}`
? { time: Time; level: Level; content: Content }
: never;
type Test11 = ParseLog<"[2024-05-01] INFO: Server started">;
// 类型为 { time: "2024-05-01"; level: "INFO"; content: "Server started" }
这种类型的使用可以让我们在处理日志字符串的时候,不需要手动做类型转换,TypeScript会自动帮我们推断出每个部分的类型,同时如果传入的字符串不符合日志格式,会在编译阶段就报错,避免运行时的错误。需要注意的是,模板字面量类型中的固定字符必须完全匹配,包括空格、标点、括号等,哪怕漏了一个空格,都会导致匹配失败,推断不出正确的类型。
TypeScript条件类型模板字面量类型修改时间:2026-08-30 10:30:46