框架中的类型编程: Hono 中的模板字符串类型编程
Hono: 模板字符串类型的妙用
Hono 是一个极简的服务端框架,其优势主要在于 Edge 场景下的原生支持(Vercel Functions,AWS Lambda,Deno,Bun 等)以及超棒的 TypeScript 研发体验。后者也是它会出现在这篇文章里的原因,Hono 能够从请求的路径中提取参数,并作为类型约束:
ts
app.get('/user/:name/:id', (c) => {
const name = c.req.param('name'); // √
const level = c.req.param('level'); // x
...
})
在上面的代码中,你定义 /:name/:id
的路径后,Hono 能够解析出你的请求中存在 name 与 id 参数,c.req.param
被推导为一个仅接受 'name' | 'id'
联合类型参数的函数,在这一节我们要实现的就是这么个效果。
这部分的内容需要你对 TypeScript 中的模板字符串类型有基本的了解,如果你此前完全不理解或是知之甚少,不妨先拉到文章的最下方补补课再回来,我会一直在这里等你 :-)
首先我们会对整体代码进行简化,最终的代码结构大致会是这样:
ts
export declare function request(
requestPath: ?,
handler: ?
): void;
request('/:name', (ctx) => {
ctx.param('name'); // √
ctx.param('id'); // x
});
request('/:name/:id', (ctx) => {
ctx.param('name'); // √
ctx.param('id'); // √
ctx.param('level'); // x
});
接着,我们先来分析下泛型信息的流转,这也是在上一部分中我们着重强调的思考方式。
- 类型的来源一定是第一个参数 requestPath,它一定是个字符串,得到的类型也是字符串字面量类型。
- 在获得字面量类型后,我们要对其进行解析,提取出其中的参数部分,这里我们需要用到模板字符串类型的模式匹配。
- 提取出参数部分之后,要再把参数信息转换为联合类型的形式,作为 handler 的参数类型,来实现类型约束的效果。
先是前两步,可以直接看代码:
ts
export declare function request<Path extends string>(
requestPath: Path,
handler: () => void
): void;
然后来实现 handler 的类型,它肯定会是一个单独的工具类型,并接受 Path 这个类型参数:
ts
type RequestHandler<Path extends string> = (ctx: {
param(key: ?): string;
}) => void;
export declare function request<Path extends string>(
requestPath: Path,
handler: RequestHandler<Path>
): void;
RequestHandler 中的 Path 就是我们得到的 /:name
字面量类型,这里和此前略有不同,我们是先把泛型信息原样交给另一个工具类型,再由它内部自行处理的。那么唯一的问题就是,如何将 /:name
类型转换为 'name'
类型?
只要涉及模板字符串类型的转换,基本可以奔着模式匹配去,这里也是一样。我们先只考虑路径只会是 /:name
、/:id
这样简单结构的情况:
ts
type ExtractParam<T extends string> = T extends `${string}/:${infer Param}`
? Param
: '';
type ExtractParam_Res_1 = ExtractParam<'/users/:id'>; // id
type ExtractParam_Res_2 = ExtractParam<'/users/'>; // ''
简单解析一下:
- 由于我们不使用
/users/:id
中的/users
,在匹配时直接使用${string}
而不是${infer Prefix}
的方式,减少一个无意义的变量声明。 - 如果字符串满足
/$prefix/:$param
的结构,就返回 param 的字面量类型。
很好,这个时候我们就完成了最重要的一步转换:「请求路径」到「参数类型」的提取:
ts
type ExtractParam<T extends string> = T extends `${string}/:${infer Param}`
? Param
: '';
type RequestHandler<Path extends string> = (ctx: {
param(key: ExtractParam<Path>): string;
}) => void;
export declare function request<Path extends string>(
requestPath: Path,
handler: RequestHandler<Path>
): void;
到此为止也太寒碜了点,我们现在的解析逻辑只能解析单个 param 的固定结构,如果多来几个,比如 /:param/:id
这样,那就会导致解析逻辑异常(解析出的参数名会变成 'param/:id'
)。
要解决这个问题也好办,只需要让我们的 ExtractParam 类型能够解析多个参数,再将这些参数转换为联合类型即可。
而如果模板字符串类型需要解析一个类型中的多个参数,那直奔着递归去就行了。对前面的模式匹配进行改写,现在我们去匹配 $prefix/:$param/$rest
这样的结构,然后对 $rest
进行递归地匹配:
ts
type ExtractMuliParams<T extends string> =
T extends `${string}/:${infer Param}/${infer Rest}`
? [Param, ...ExtractMuliParams<`/${Rest}`>]
: T extends `${string}/:${infer Param}`
? [Param]
: [];
type ExtractMuliParams_Res_1 = ExtractMuliParams<'/users/:id'>; // ['id']
type ExtractMuliParams_Res_2 = ExtractMuliParams<'/users/:id/:name'>; // ['id', 'name']
type ExtractMuliParams_Res_3 = ExtractMuliParams<'/users/:id/:name/profile'>; // ['id', 'name']
type ExtractMuliParams_Res_4 = ExtractMuliParams<'/users/:id/:name/profile/'>; // ['id', 'name']
至于怎么把 ['id', 'name']
转换为 'name' | 'id'
,这个就比较简单了,Params[number]
即可。
最终的代码会是酱婶儿的:
ts
type ExtractMuliParams<T extends string> =
T extends `${string}/:${infer Param}/${infer Rest}`
? [Param, ...ExtractMuliParams<`/${Rest}`>]
: T extends `${string}/:${infer Param}`
? [Param]
: [];
type Context<Path extends string> = {
param(key: ExtractMuliParams<Path>[number]): string;
};
type RequestHandler<Path extends string> = (ctx: Context<Path>) => void;
export declare function request<Path extends string>(
requestPath: Path,
handler: RequestHandler<Path>
): void;
整理一下泛型信息的流转:
- requestPath 参数提供了 Path 参数类型,被 RequestHandler → Context 消费。
- Context 中,会调用 ExtractMuliParams 工具类型,先将 Path 解析为一个字符串数组,再使用
[number]
语法得到字符串联合类型。
最终的效果也相当还原:
模板字符串类型扩展:EventEmitter
在上面的例子中我们介绍的是模板字符串类型从「输入的字符串值」到「字符串字面量类型」的转换,这是在实际应用中较为少见的部分------因为框架的开发者通常已经为你准备好了。而更常见,也更简单的部分,实际上是「对字符串字面量类型进行类型编程」这么一个部分。
基于模板字符串类型的自动分发特性(如果不知道是啥,请看下方快速入门),我们只需要声明一组字面量联合类型,就可以通过这个特性让联合类型变成我们想要的形状,实际应用中一个常见的例子就是事件监听器。
假设我们有这么一组联合类型,表达了一个 VideoPlayer 上支持的事件:
ts
type Events = 'play' | 'pause' | 'restart' | 'update' | 'close' | 'block-video';
现在我们要开发一个 React 组件来封装 VideoPlayer,它的属性中会包括直接对这些事件的监听,但需要经过命名的转换,如 onPlay
、onUpdate
、onBlockVideo
。如果只有少数几个事件,一个个手写看起来也没什么,但身为一名程序员,肯定要想方设法把已经有的事件联合类型给利用起来,这样后续迭代中,只需要增删联合类型的事件,就能够自动在属性中生效。
首先我们要解决的是,如何将这个联合类型转换为 'onPlay' | 'onPause' | 'onRestart'
这样的结构,这一步看起来还比较简单,只需要把事件类型首字符大写,前面加个 on 就好了:
ts
type ToEventHandler<TEvent extends string> = `on${Capitalize<TEvent>}`;
来看看效果:
看起来似乎不太对,我们没有处理 KebabCase 的事件类型...。但其实也并不复杂,这里我们能够确定字符串的转换规则,即将字符串 foo-bar-baz
按 -
拆分后,将 bar
、baz
转换为首字符大写即可,写一个 KebabCase2CamelCase
类型:
ts
type KebabCase2CamelCase<S extends string> =
S extends `${infer Head}${'-'}${infer Rest}`
? `${Head}${KebabCase2CamelCase<Capitalize<Rest>>}`
: S;
简单分析一下,首先尝试对字符串应用 Head-Rest
的模式匹配,如果成功应用,保持 Head 不动,对 Rest 应用 Capitalize
,而 Rest 中可能还存在 Head-Rest
的模式匹配,因此要递归调用这个工具类型,直到再也无法匹配为止。
然后用这个类型来完善下之前的 ToEventHandler 类型:
ts
type ToEventHandler<TEvent extends string> = `on${Capitalize<
KebabCase2CamelCase<TEvent>
>}`;
这里我们实现的
KebabCase2CamelCase
结果为小驼峰,因此最后需要再Capitalize
一下,这么做是为了避免在一个工具类型里实现两种不同的转换逻辑。如果希望直接转大驼峰,可以这么写:
tstype KebabCase2CamelCase<S extends string> = S extends `${infer Head}${'-'}${infer Rest}` ? `${Capitalize<Head>}${KebabCase2CamelCase<Capitalize<Rest>>}` : Capitalize<S>; type ToEventHandler<TEvent extends string> = `on${KebabCase2CamelCase<TEvent>}`;
效果也是一样滴~
现在就没问题了:
那要把这个联合类型转成一个属性就简单了,可以使用索引签名类型,即 TypeScript 内置的 Record 类型:
ts
type VoidFunc = () => void;
type EventHandlerProperties = Record<EventHandlers, VoidFunc>;
interface VideoPlayerProps extends EventHandlerProperties {}
const VideoPlayer: React.FC<VideoPlayerProps> = ({ onPlay, onBlockVideo }) => {
return <></>;
};
上面的例子看起来已经很不错了,但实际上还有一个严重的问题------它默认了所有事件都是一个 VoidFunc
类型,但通常来说会有一些事件拥有特殊的入参与出参,比如如果 onClose
的 EventHandler 返回了 false,就意味着当前关闭播放失败了。
这种情况下,我个人推荐引入一个新的类型结构来描述特定事件的处理器:
ts
type EventHandlerType = {
onUpdate: (input: { position: number }) => void;
onClose: (input: { force: boolean }) => boolean;
};
然后改写下之前的 EventHandlerProperties
,把它变成一个由两个部分组成的新类型:
EventHandlerType
定义了的部分,这里的事件处理器类型需要使用EventHandlerType
中定义的类型。EventHandlerType
未定义的部分,继续使用VoidFunc
即可。
可以使用 Exclude 工具类型来完成这样的操作,把 EventHandlerType
定义的事件处理器从 EventHandlers
中去掉,整体的代码大概是酱婶儿的:
ts
type EventHandlerType = {
onUpdate: (input: { position: number }) => void;
onClose: (input: { force: boolean }) => boolean;
};
type EventHandlerProperties = EventHandlerType &
Record<Exclude<EventHandlers, keyof EventHandlerType>, VoidFunc>;
interface VideoPlayerProps extends EventHandlerProperties {}
const VideoPlayer: React.FC<VideoPlayerProps> = ({
onUpdate,
onBlockVideo,
}) => {
onUpdate({ position: 7777 });
return <></>;
};
除了作为 React 组件的属性类型,还有一个相当常见的场景:EventEmitter,最理想的效果是 EventEmitter 的每个事件监听注册都能为事件处理器提供对应的类型描述:
ts
const event = new EventEmitter();
event.on('play', () => {});
event.on('update', ({ position }) => {});
要实现这种效果也很简单,首先,将事件名作为泛型参数的类型来源,然后用来匹配对应的 Handler:
typescript
type GetHandlerType<TEvent extends Events> = ...?
class EventEmitter {
public on<TEvent extends Events>(
event: TEvent,
handler: GetHandlerType<TEvent>
) {
// ...
}
整体思路其实和上面差不多,如果 EventHandlerType
定义了特殊的处理器类型,就使用其中的类型,否则使用朴素的 VoidFunc:
typescript
type GetHandlerType<TEvent extends Events> =
ToEventHandler<TEvent> extends keyof EventHandlerType
? EventHandlerType[ToEventHandler<TEvent>]
: VoidFunc;
这里我们使用的是将「事件」转换为「事件处理器」后进行匹配的方式,如果你不嫌事多,其实也可以反过来,把 keyof EventHandlerType
都转换为「事件」的结构,给一个参考的实现:
typescript
type CamelCase2KebabCase<
S extends string,
Start = true
> = S extends `${infer Head}${infer Rest}`
? `${Head extends Capitalize<Head>
? Start extends true
? Lowercase<Head>
: `-${Lowercase<Head>}`
: Head}${CamelCase2KebabCase<Rest, false>}`
: S;
type CamelCase2KebabCase_Res_1 = CamelCase2KebabCase<'Update'>; // 'update'
type CamelCase2KebabCase_Res_2 = CamelCase2KebabCase<'BlockVideo'>; // 'block-video'
总结一下,这个例子里我们了解了一下更接地气一点的模板字符串类型编程,我可以打赌在你曾经写的项目里,肯定存在着可以用这种方式优化的类型定义。这也是我个人认为类型编程最有意义的场景之一:只需要手动定义一次类型,后续的类型均使用类型编程得到,这样一来,无论过程有多么复杂,后续你和你的接班人都不用再操心,只要改改手动定义的类型就好。
总结
在这两篇文章里,我们大致学习了两大类来自于社区工具的类型编程,它们分别关注「泛型的推导与转换」与「模板字符串类型的模式匹配」(严格来说所有类型编程都是在做泛型的推导与转换,这里只是便于概念区分强行分类)。
如果完整地学习下来,你会发现其实它们也没有想得那么复杂,其核心逻辑是可以在 <100 行的代码里实现的,当然,框架要处理的场景肯定复杂得多,因此其各种条件类型嵌套就显得繁复纷杂。
最后,在这几个示例的实现过程中,你可能发现了我一直在强调几个思考方式:
- 梳理泛型信息的传递链路,先想清楚泛型是否需要约束,从哪个参数来,到哪个结构去,中间需要如何转换,最终使用的效果是什么样的,想清楚了这些才能做到思维清晰。
- 别急着一步到位,我们的每一个例子都是从刀耕火种慢慢过渡到现代社会的,只要你不提交草稿,谁知道你背地里改了多少次实现呢。
- 对各种工具类型进行分类,比如 Prisma 实现中的集合工具类型,其它常见种类还有属性工具类型,结构工具类型,模式匹配工具类型等等等等...,这么做的好处就是你能根据实际的场景很快反应过来,哦这里该用榔头,哦这里该用剪刀...。
类型编程不可怕,可怕的是你把它想得太高深啦。我们下篇文章见:-)
TypeScript 中的模板字符串类型
在 Hono 的例子中,我想你已经发现了模板字符串类型的妙用------它使得 TypeScript 可以从我们的字符串中来提取类型信息并用于类型检查。如果你此前并不了解模板字符串类型,这里我们做一个简要的介绍,大部分的内容同步自笔者此前在专栏发布的 TypeScript 的另一面:类型编程。
TypeScript 4.1 中引入了模板字面量类型,使得我们可以使用${}
这一语法来构造字面量类型,如:
typescript
type World = 'world';
// "hello world"
type Greeting = `hello ${World}`;
模板字面量类型具有自动分发的特性,即如果模板插槽中接收到的是一个联合类型,那么它会为联合类型的每一个成员进行一次调用,再将结果组合成一个联合类型,如:
typescript
type SizeRecord<Size extends string> = `${Size}-Record`
// "Small-Record" | "Middle-Record" | "Huge-Record"
type UnionSizeRecord = SizeRecord<"Small" | "Middle" | "Huge">
如果存在多个插槽,各个联合类型将会被分别排列组合。
typescript
type Struct = 'Record' | 'Array';
type SizeStruct<
Size extends string,
Struct extends string
> = `${Size}-${Struct}`;
// "Small-Record" | "Middle-Record" | "Huge-Record"
// | "Small-Array" | "Middle-Array" | "Huge-Array"
type UnionSizeStruct = SizeStruct<Sizes, Struct>;
随之而来的还有四个新的工具类型:
typescript
type Uppercase<S extends string> = intrinsic;
type Lowercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type Uncapitalize<S extends string> = intrinsic;
它们的作用就是字面意思,这里不做解释了。intrinsic
代表了这些工具类型是由 TS 编译器内部实现的,其实也很好理解,我们无法通过类型编程来修改字面量的值;
TS 的实现代码:
ts
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;
}
你可能会想到,模板字面量如果想截取其中的一部分要怎么办?这里可没法调用 slice 方法。但我们还有 infer!使用 infer 占位后,便能够提取出字面量的一部分,如:
typescript
type CutStr<Str extends string> = Str extends `${infer Part}budu` ? Part : never
// "lin"
type Tmp = CutStr<"linbudu">
再进一步,[1,2,3]
这样的字符串,如果我们提供 [${infer Member1}, ${infer Member2}, ${infer Member}]
这样的插槽匹配,就可以实现神奇的提取字符串数组成员效果:
typescript
type ExtractMember<Str extends string> = Str extends `[${infer Member1}, ${infer Member2}, ${infer Member3}]` ? [Member1, Member2, Member3] : unknown;
// ["1", "2", "3"]
type Tmp = ExtractMember<"[1, 2, 3]">
注意,这里的模板插槽被使用 ,
分隔开了,如果多个带有 infer 的插槽紧挨在一起,那么前面的 infer 只会获得单个字符,最后一个 infer 会获得所有的剩余字符(如果有的话),比如我们把上面的例子改成这样:
typescript
type ExtractMember<Str extends string> = Str extends `[${infer Member1}${infer Member2}${infer Member3}]` ? [Member1, Member2, Member3] : unknown;
// ["1", ",", " 2, 3"]
type Tmp = ExtractMember<"[1, 2, 3]">
这一特性使得我们可以使用多个相邻的 infer + 插槽,对最后一个 infer获得的值进行递归操作,如:
typescript
type JoinArrayMember<T extends unknown[], D extends string> =
T extends [] ? '' :
T extends [any] ? `${T[0]}` :
T extends [any, ...infer U] ? `${T[0]}${D}${JoinArrayMember<U, D>}` :
string;
// ""
type Tmp1 = JoinArrayMember<[], '.'>;
// "1"
type Tmp3 = JoinArrayMember<[1], '.'>;
// "1.2.3.4"
type Tmp2 = JoinArrayMember<[1, 2, 3, 4], '.'>;
原理也很简单,每次将数组的第一个成员添加上.
,在最后一个成员时不作操作,在最后一次匹配([]
)返回空字符串即可。
更花里胡哨一点,我们还可以在类型层面实现 Lodash 的 get 方法,即通过 get({},"a.b.c")
的形式快速获得嵌套属性,只需要结合 infer + 条件类型即可。
typescript
type PropType<T, Path extends string> =
string extends Path ? unknown :
Path extends keyof T ? T[Path] :
Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropType<T[K], R> : unknown :
unknown;
declare function getPropValue<T, P extends string>(obj: T, path: P): PropType<T, P>;
declare const s: string;
const obj = { a: { b: {c: 42, d: 'hello' }}};
getPropValue(obj, 'a'); // { b: {c: number, d: string } }
getPropValue(obj, 'a.b'); // {c: number, d: string }
getPropValue(obj, 'a.b.d'); // string
getPropValue(obj, 'a.b.x'); // unknown
getPropValue(obj, s); // unknown