框架中的类型编程: Hono 中的模板字符串类型编程

框架中的类型编程: 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,它的属性中会包括直接对这些事件的监听,但需要经过命名的转换,如 onPlayonUpdateonBlockVideo 。如果只有少数几个事件,一个个手写看起来也没什么,但身为一名程序员,肯定要想方设法把已经有的事件联合类型给利用起来,这样后续迭代中,只需要增删联合类型的事件,就能够自动在属性中生效。

首先我们要解决的是,如何将这个联合类型转换为 'onPlay' | 'onPause' | 'onRestart' 这样的结构,这一步看起来还比较简单,只需要把事件类型首字符大写,前面加个 on 就好了:

ts 复制代码
type ToEventHandler<TEvent extends string> = `on${Capitalize<TEvent>}`;

来看看效果:

看起来似乎不太对,我们没有处理 KebabCase 的事件类型...。但其实也并不复杂,这里我们能够确定字符串的转换规则,即将字符串 foo-bar-baz- 拆分后,将 barbaz 转换为首字符大写即可,写一个 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 一下,这么做是为了避免在一个工具类型里实现两种不同的转换逻辑。如果希望直接转大驼峰,可以这么写:

ts 复制代码
type 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
相关推荐
2401_8827275722 分钟前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
NoneCoder25 分钟前
CSS系列(36)-- Containment详解
前端·css
anyup_前端梦工厂36 分钟前
初始 ShellJS:一个 Node.js 命令行工具集合
前端·javascript·node.js
5hand40 分钟前
Element-ui的使用教程 基于HBuilder X
前端·javascript·vue.js·elementui
GDAL1 小时前
vue3入门教程:ref能否完全替代reactive?
前端·javascript·vue.js
六卿1 小时前
react防止页面崩溃
前端·react.js·前端框架
z千鑫1 小时前
【前端】详解前端三大主流框架:React、Vue与Angular的比较与选择
前端·vue.js·react.js
m0_748256142 小时前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
小马哥编程3 小时前
Function.prototype和Object.prototype 的区别
javascript
小白学前端6663 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react