框架中的类型编程(三):Elysia 中的链式调用与错误处理

这是专栏「框架中的类型编程」的第三篇内容,你可以在 Framework Typings笔者的个人博客 找到前两篇内容,在之前的内容里我们介绍了 Prisma、tRPC 以及 Hono 中的类型编程,还顺便对 TypeScript 中的「模板字符串类型」进行了展开介绍。如果你已经理解了此前介绍的类型编程,那么在阅读本篇对 ElysiaJS 的介绍时,也不会理解得更快(摊手,毕竟本篇我们简化过的类型编程代码仍然有100+ 行,比之前三个框架的实现加起来都多。

简单介绍下 ElysiaJS ,它是一个基于 Bun 的服务端框架,号称比 Express 快 21 倍,同时还提供了非常棒的类型安全,你可以在 ElysiaJS 上同时找到 Hono 的路由参数提取(从 /user/:id 分析出参数类型为 { id: number })与 tRPC 的 Schema Validation(使用 z.object() 创建输入输出类型 ),还有一些将在这篇文章里介绍的新的东西。

来看一张官网的宣传图片,揣摩揣摩图里都有哪些神奇的黑科技?

我们一个个分析下:

  • 首先是从字符串到链式调用的转换,比如,这里的 /user/profile 转换为了 api.user.profile 的链式调用,这里其实也支持 Hono 那样 /user/profile/:id 这样的输入,最后也能够将其类型附加到 RequestHandler 的入参类型上。
  • 接着是 Schema Validation,这个是我们的老朋友了,.patch 方法的第三个参数接收一个 Schema 定义对象,其提取出的类型会被提供给 RequestHandler。
  • RequestHandler 的返回值类型到响应类型(即 data)的转换,可以看到这里定义了几个返回错误的情况,在最终的 error 类型里全部得到了完整保留,甚至包括错误码和错误信息的关联。

在这篇文章里,我们会先分别了解这三点背后的类型编程,再将它们组合成最后 ElysiaJS 形状的代码。

字符串到链式调用的转换

在 Hono 的那一篇介绍里其实我们已经很熟悉这种「字符串字面量类型」的解析了,当时我们解析的流程是,先从 /user/:id/:param 解析出 ['id', 'param'],然后再使用索引类型访问,['id', 'param'][number] 就得到了 'id' | 'param' 类型,而这个例子里其实难度也差不多,以 /system/user/games 为例,我们最终需要的结构是 { system: { user: games: { } } } 这样的。

首先,转换到元组这一步肯定是少不了的,相比于直接操作字符串字面量类型,操作联合类型元素要更加直观和简单,也就是说,首先要转换成 ['system', 'user', 'games'] 的元组。接着元组转对象类型就好办了,我们实现一个 ComposeObject 工具类型,ComposeObject<'foo', {}> 的结果会是 foo: { },然后递归调用,即可从元组生成嵌套的对象类型。

第一步,转换到元组,思路和 Hono 中的实现还是一样的,使用 /{infer Param}/${infer Rest} 来进行模式匹配即可,这里比较简单,直接看实现就好了:

typescript 复制代码
type ParsePathAsTuple<T extends string> =
  T extends `${string}/${infer Param}/${infer Rest}`
    ? [Param, ...ParsePathAsTuple<`/${Rest}`>]
    : T extends `${string}/${infer Param}`
    ? [Param]
    : [];

//  ['system', 'user', 'games'];
type Testing2 = ParsePathAsTuple<'/system/user/games'>;

接着来实现 ComposeObject 工具类型,不知道你看到上面的时候会不会觉得这个工具类型的作用眼熟------是的,其实它就是 TS 内置的 Record 类型,只不过我们常见的用法是 Record<string, number> 酱婶儿的,而现在我们输入的键值只会是单个的字符串字面量。

来试一下递归调用的效果:

typescript 复制代码
type Simplify<T> = {
  [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : Simplify<T[K]>
    : T[K];
} & {};

// {
//   foo: {
//     bar: {
//       baz: {
//       }
//     }
//   }
// }
type Testing4 = Simplify<Record<'foo', Record<'bar', Record<'baz', {}>>>>;

这里的 Simplify 类型仅仅用于在 IDE 内提供最终生成的类型结构展示,并没有额外的效果,直接复制粘贴即可。

那么下一步要考虑的就是,如何从元组生成这个调用效果了,很简单,还是递归------

  • ComposeObjectFromTuple 应该接受两个参数:Tuple 与 Internal,Tuple 是我们上一步生成的元组,Internal 则是我们放置在对象嵌套最深处的结构(别忘了最终使用是 api.user.profile.fetch)。
  • 通过 [infer First, ...infer Rest] 来进行模式匹配,每次出栈一个字面量类型,作为 Record 的输入。
  • 检查剩余 Rest 的长度:
    • 如果还有值,则重复上一步,构建出 Record<'foo', Record<'bar', ...>> 这样的结构。
    • 如果已经遍历完毕,说明这一次的 First 是元组中最后一个字面量类型,此时它的属性类型即是 Internal 。这里需要注意的是,要通过 Rest['length'] extends 0 是否成立。

最终实现会是这样的:

typescript 复制代码
type ComposeObjectFromTuple<
  Tuple extends string[],
  Internal extends object = {}
> = Tuple extends [infer First, ...infer Rest]
  ? First extends string
    ? Record<
        First,
        Rest extends string[]
          ? Rest['length'] extends 0
            ? Internal
            : ComposeObjectFromTuple<Rest, Internal>
          : Internal
      >
    : {}
  : {};

// {
//   foo: {
//     bar: {
//       baz: {
//         patch: () => Promise<unknown>;
//       }
//     }
//   }
// }
type Testing5 = Simplify<
  ComposeObjectFromTuple<
    ['foo', 'bar', 'baz'],
    {
      patch: () => Promise<unknown>;
    }
  >
>;

Schema Validation

在开始前,你可能注意到了,ElysiaJS 使用的 Schema Builder 是自己导出的 t,而不是之前我们已经初步了解的 Zod / ArkType / ... 等工具,其实这并没什么差别,不管是哪个工具,其最终输出的 Schema 上,一定会留一个获取对应 TS 类型的口子,比如 Zod 的话我们可以这么做:

typescript 复制代码
import { z } from 'zod';

type InferType<T> = T extends z.ZodType<infer U> ? U : T;

回到 ElysiaJS 的 t,它留的口子则是 static 属性:

typescript 复制代码
import { t } from 'elysia';

type UnwrapSchemaType<T extends ReturnType<typeof t.Object>> = T['static'];

来测试一下:

typescript 复制代码
const schema = t.Object({
  name: t.String(),
  age: t.Number(),
});

// {
//   name: string;
//   age: number;
// }
type Testing1 = UnwrapSchemaType<typeof schema>;

那这一步其实到这就差不多了,后面我们只需要简单地留个泛型参数来保存 Schema 类型,然后再 UnwrapSchemaType 一下就能获得原始的 TS 类型了。

RequestHandler 的返回值类型到响应类型

完整的从返回值类型到响应类型的转换其实并不复杂,用一个泛型参数保存推导出的返回值类型,然后解析其中所有的 Error 类型,合并到 result.error 中即可,这个在最后的组装环节讲解要更好理解。这一部分我们其实可以关注一个更有趣的东西:预设错误类型。

不知道你是否注意到了,在最开始官网的图里,我们可以直接 error(418) 来抛出错误,在 switch case 语句中仍然能为 418 这个错误码自动关联上预设的错误信息 'I am a teapot',这又是如何实现的?

如果在类型层面感觉没什么头绪,不妨想想在逻辑层面你会怎么做?

typescript 复制代码
const builtInErrorMap = {
  418: 'I am a teapot',
};

const createErrorMessage = (errorCode: number, errorMessage?: string) =>
  builtInErrorMap[errorCode] ?? `HTTP Error ${errorCode}`;

那么类型层面不也是一样的吗,定义一个对象类型来保存映射关系,尝试用 ErrorCode 类型去其中匹配,如果匹配到了就返回值,否则就创建一个新的字面量类型即可:

typescript 复制代码
type BuiltInErrorMap = {
  418: 'I am a teapot';
};

type FallbackError<Code extends number> = `HTTP Error ${Code}`;

type BuildErrorMessage<Code extends number> = Code extends keyof BuiltInErrorMap
  ? BuiltInErrorMap[Code]
  : FallbackError<Code>;

type Build418Error = BuildErrorMessage<418>; // "I am a teapot"
type Build500Error = BuildErrorMessage<500>; // "HTTP Error 500"

组装时间

好好好,把几个比较关键的地方都安排明白了,接下来的组装就很简单了。上面几个部分里,我们的实现都是先构造了输入然后进行实现,在组装时,我们要考虑的就是如何从开发者的输入,推导出我们需要的输入。

我们先把最基础的结构定出来,比如需要的泛型参数:

typescript 复制代码
export class App<AppStruct = {}> {
  constructor() {}

  public patch<
    Path extends string,
    Handler,
    Schema extends ReturnType<typeof t.Object>,
    AdditionalStruct = unknown
  >(
    path: Path,
    handler: Handler,
    schema: Schema
  ): App<AdditionalStruct & AppStruct> {
    return this;
  }

  public listen(port: number) {
    return this;
  }
}

export function init<Struct extends App>(domain: string) {
  return {} as Struct extends App<infer T> ? T : never;
}

这里 App<AppStruct = {}> 中的 AppStruct 定义,本质上是为了尽可能模拟 ElysiaJS 的 API,同时类似于 tRPC 讲解中在链式调用下不断附加类型信息的方式,通过 app.patch.get.patch... 这样连续的定义,我们就能够不断将这个 App 上附加的信息添加到 AppStruct 这个泛型参数内。而这里的「信息」,其实就是每个方法的入参与返回值信息。

我们的 patch 方法一共有三个参数:path、handler、schema,来思考思考它们分别会提供哪些类型信息,我们会怎么使用它们?

  • path,提供路径的字符串字面量类型,用于转换成链式调用。
  • handler,它的入参类型会依赖 schema 的解析后类型,同时它会提供 api.system.user.games() 的返回值类型。
  • schema,提供解析后的 TS 类型,它会提供 api.system.user.games() 的入参类型。

这样我们就知道如何进行下一步了:

  • 首先添加一个 UnwrappedSchema 泛型参数
  • 定义 Handler 的约束类型,它依赖 UnwrappedSchema 作为输入
  • 本次方法调用带来的 App 结构信息,应该来自于对 Path 泛型参数的解析,即对于 patch('/user/data'),需要为 App 新增 { user: { data: patch () => {} } } 的结构。
typescript 复制代码
type HandlerStruct<UnwrappedSchema> = (input: {
  body: UnwrappedSchema;
  error: any;
}) => unknown;

export class App<AppStruct = {}> {
  constructor() {}

  public patch<
    Path extends string,
    Handler extends HandlerStruct<UnwrappedSchema>,
    Schema extends ReturnType<typeof t.Object>,
    UnwrappedSchema = UnwrapSchemaType<Schema>,
    AdditionalStruct = ComposeObjectFromTuple<
      ParsePathAsTuple<Path>,
      {
        patch: (body: UnwrappedSchema) => any;
      }
    >
  >(
    path: Path,
    handler: Handler,
    schema: Schema
  ): App<AdditionalStruct & AppStruct> {
    return this;
  }

  public listen(port: number) {
    return this;
  }
}

// 仅仅是提取出 AppStruct
export function init<Struct extends App>(domain: string) {
  return {} as Struct extends App<infer T> ? T : never;
}

到这里,其实我们已经实现了两个重要能力:链式调用转换 & Schema Validation,来试试效果:

typescript 复制代码
import { App, init } from './Elysia';

const app = new App()
  .patch(
    '/user/profile',
    ({ body, error }) => {
      if (body.age < 18) return error(400, 'Oh no');

      if (body.name === 'Nagisa') return error(418);

      if (body.age === 25) return error(599);

      return body;
    },
    t.Object({
      name: t.String(),
      age: t.Number(),
    })
  )
  .listen(80);

const server = init<typeof app>('localhost');

const res = await server.user.profile.patch({
  name: 'saltyaom',
  age: '21', // x 不能将类型"string"分配给类型"number"。
});

下一步我们要思考的是,如何将 Handler 的返回值类型传递给 patch res ,首先明确下,要实现官网图中判断完 if(res.error),并且将所有 res.error.status 枚举完毕后,res.data 自动获得正确类型的效果,最终的 patch res 类型需要是啥样的?

其实这里甚至不需要上 XOR 这样的互斥类型操作,只需要简单的联合类型即可:{ data: null; error: true; } | { data: ...; error: null; },TS 已经足够聪明啦。

首先,我们肯定要在作为 Internal 参数的 patch 方法的返回值上做文章。其次,data 的有效类型仍然就是 UnwrappedSchema,而 error 的类型其实就是 Handler 的返回值类型(必然是个联合类型)中,符合 Error 结构的那几个类型的组合。所以我们现在可以这么修改下代码,首先定义下 Error 结构,之前 HandlerStruct 中的 error 入参类型可是 any,现在要给它一个名分了,这部分我们在前面已经了解过,直接把代码都复制过来即可:

typescript 复制代码
type BuiltInErrorMap = {
  418: 'I am a teapot';
};

type WithError<TErrorCodes extends number, TMessage extends string> = {
  error: {
    status: TErrorCodes;
    value: TMessage;
  };
};

type FallbackError<Code extends number> = `HTTP Error ${Code}`;

type BuildErrorMessage<Code extends number> = Code extends keyof BuiltInErrorMap
  ? BuiltInErrorMap[Code]
  : FallbackError<Code>;

type DefineError = <
  Code extends number,
  Message extends string = BuildErrorMessage<Code>
>(
  errorCode: Code,
  message?: Message
) => WithError<Code, Message>;

type HandlerStruct<UnwrappedSchema> = (input: {
  body: UnwrappedSchema;
  error: DefineError;
}) => unknown;

一个小细节是,虽然我们为 Handler 定义了 HandlerStruct 作为约束,但由于我们并没有在 HandlerStruct 中定义具体的返回值类型,所以仍然可以通过 ReturnType<Handler> 来获取到开发者实际定义的返回值类型。

紧接着,我们现在可以创建一个 TransformHandlerResult 工具类型作为 patch 方法的返回值类型,它接收 UnwrappedSchema、 ReturnType<Handler> 作为输入,功能就是组装出需要的 data 与 error 互斥的联合类型:

typescript 复制代码
type ExtractErrors<Input> = Input extends { error: infer T } ? T : never;

type TransformHandlerResult<
  UnwrappedSchema,
  HandlerReturns extends ReturnType<HandlerStruct<UnwrappedSchema>>
> = Promise<
  | {
      data: UnwrappedSchema;
      error: null;
    }
  | {
      data: null;
      error: ExtractErrors<HandlerReturns>;
    }
>;

这里的一个小技巧是,ExtractErrors 工具类型利用了条件类型的分布式特性,它会将 HandlerReturns 这个联合类型的每一个类型成员都与 { error: infer T } 进行一次匹配,所有符合条件的结构会重新组成联合类型,也就是我们需要的,错误类型的联合啦。

再看一遍完整的代码:

typescript 复制代码
import { t } from 'elysia';

type Simplify<T> = {
  [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : Simplify<T[K]>
    : T[K];
} & {};

type UnwrapSchemaType<T extends ReturnType<typeof t.Object>> = Simplify<
  T['static']
>;

type HandlerStruct<UnwrappedSchema> = (input: {
  body: UnwrappedSchema;
  error: DefineError;
}) => unknown;

type BuiltInErrorMap = {
  418: 'I am a teapot';
};

type WithError<TErrorCodes extends number, TMessage extends string> = {
  error: {
    status: TErrorCodes;
    value: TMessage;
  };
};

type FallbackError<Code extends number> = `HTTP Error ${Code}`;

type BuildErrorMessage<Code extends number> = Code extends keyof BuiltInErrorMap
  ? BuiltInErrorMap[Code]
  : FallbackError<Code>;

type DefineError = <
  Code extends number,
  Message extends string = BuildErrorMessage<Code>
>(
  errorCode: Code,
  message?: Message
) => WithError<Code, Message>;

type ParsePathAsTuple<T extends string> =
  T extends `${string}/${infer Param}/${infer Rest}`
    ? [Param, ...ParsePathAsTuple<`/${Rest}`>]
    : T extends `${string}/${infer Param}`
    ? [Param]
    : [];

type ComposeObjectFromTuple<
  T extends string[],
  DeepestStruct extends object = {}
> = T extends [infer First, ...infer Rest]
  ? First extends string
    ? Record<
        First,
        Rest extends string[]
          ? Rest['length'] extends 0
            ? DeepestStruct
            : ComposeObjectFromTuple<Rest, DeepestStruct>
          : DeepestStruct
      >
    : {}
  : {};

type ExtractErrors<Input> = Input extends { error: infer T } ? T : never;

type TransformHandlerResult<
  UnwrappedSchema,
  HandlerReturns extends ReturnType<HandlerStruct<UnwrappedSchema>>
> = Promise<
  | {
      data: UnwrappedSchema;
      error: null;
    }
  | {
      data: null;
      error: ExtractErrors<HandlerReturns>;
    }
>;

export class App<AppStruct = {}> {
  constructor() {}

  public patch<
    Path extends string,
    Handler extends HandlerStruct<UnwrappedSchema>,
    Schema extends ReturnType<typeof t.Object>,
    UnwrappedSchema = UnwrapSchemaType<Schema>,
    AdditionalStruct = ComposeObjectFromTuple<
      ParsePathAsTuple<Path>,
      {
        patch: (
          body: UnwrappedSchema
        ) => TransformHandlerResult<UnwrappedSchema, ReturnType<Handler>>;
      }
    >
  >(
    path: Path,
    handler: Handler,
    schema: Schema
  ): App<AdditionalStruct & AppStruct> {
    return this;
  }

  public listen(port: number) {
    return this;
  }
}

export function init<Struct extends App>(domain: string) {
  return {} as Struct extends App<infer T> ? T : never;
}

现在再来试试效果:

typescript 复制代码
const res = await server.user.profile.patch({
  name: 'saltyaom',
  age: '21', // x
});

if (res.error) {
  switch (res.error.status) {
    case 400:
      throw res.error.value; // "Oh no"
    case 418:
      throw res.error.value; // "I am a teapot"
    case 599:
      throw res.error.value; // "HTTP Error 599"
  }
}

res.data.name.toString(); // √

搞定!

总结一下

这次的 ElysiaJs 的类型编程实现确实有点复杂,但实际上实现过程还是一样的步骤:明确泛型信息的提供方,预设最终类型的结构,然后就是编写工具类型,让输入一步步变成输出的形状,就这么简单。如果本节的内容你认为理解起来有些困难,不妨将我们的各个拼装部分再拆分成更细粒度的实现。

我个人认为,本节的示例非常适合作为类型编程的综合练习题,毕竟它用到了泛型、联合类型、模板字符串类型、映射类型、条件类型等等等等一堆概念,如果能真正把这些概念都融汇贯通,那 TypeScript 简直是想学不会都难 ^_^

相关推荐
我要洋人死14 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人25 分钟前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人26 分钟前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR31 分钟前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香33 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q24985969336 分钟前
前端预览word、excel、ppt
前端·word·excel
小华同学ai41 分钟前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9151 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
逐·風6 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#