前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >看了一行代码,我连夜写了个轮子

看了一行代码,我连夜写了个轮子

作者头像
腾讯云开发者
发布2024-05-07 09:21:25
3270
发布2024-05-07 09:21:25
举报

01、TypeScript 模板字符串类型

在 ts 中模板字符串类型是字符串类型的扩展,这些字符串可以包含嵌入的表达式,或者是字符串字面量类型的联合类型。我们先来看看官方示例:

代码语言:javascript
复制
type World = 'world'; 
type Greeting = `hello ${World}`; 
// ^ type = "hello world"

它的写法与 js 的模板字符串相同,只是把它搬到了类型定义上。

乍一看平平无奇,感觉用处不大,难道字符串还能玩儿出花来?直到睡前我看到了这么一行代码:

代码语言:javascript
复制
app.get('/api/:id', (req, res) => {
  const uid = req.params.id; // string
})

这段代码在express中注册了一个路由,我在路由的字符串schema中定义了一个id参数,但在监听方法的 req.params 中,竟然提取到了字符串schema中的参数类。

这是什么魔法?带着好奇 gd 进去看下源码,实现这一切魔法是 RouteParameters这个泛型,它通过泛型约束和 infer 命令字不断递归字符串来取出里面的 param 声明。看到这儿我突然就不困了,原来字符串类型还能这样玩?

代码语言:javascript
复制
export type RouteParameters<Route extends string> = string extends Route ? ParamsDictionary
    : Route extends `${string}(${string}` ? ParamsDictionary // TODO: handling for regex parameters
    : Route extends `${string}:${infer Rest}` ?
            & (
                GetRouteParameter<Rest> extends never ? ParamsDictionary
                    : GetRouteParameter<Rest> extends `${infer ParamName}?` ? { [P in ParamName]?: string }
                    : { [P in GetRouteParameter<Rest>]: string }
            )
            & (Rest extends `${GetRouteParameter<Rest>}${infer Next}` ? RouteParameters<Next> : unknown)
    : {};

02、实现字符串 Schema 类型解析

在开发过程中偶尔会遇到需要用到字符串schema来声明某些属性或能力,例如上面的 express 路由。既然字符串可以通过模板字符串来实现token级别的类型计算,那么是不是可以用来玩一些更花哨的schema方法,这个觉就没必要再睡下去了,原神启动!

2.1 描述结构体类型的字符串 Schema

先来浅试一下,假如我有一个工具函数,根据对象的字符串 schema 描述转换成对应的结构体类型,例如将type Str = 'name string'转换为type Obj = {name: string},我们设计 schema 的格式为[key] [type],然后照猫画虎用infer关键字拿出字符串中声明的keytype

代码语言:javascript
复制
type ParseSchema<T extends string> = T extends `${infer Key} ${infer Type}`
  ? {[x in Key]: Type extends `string` ? string : Type extends `number` ? number : never}
  : {}

type Result = ParseSchema<'name string'> // { name: string }

我们接着往下玩,如果是个数组类型应该怎么在字符串里声明呢?这时候我们可以往上加一层,定义一个用来解析类型声明的泛型 GetType,然后递归来转换复杂的字符串 schema 内容。

代码语言:javascript
复制
type GetType<T extends string> = T extends `${infer Type}[]`
  ? GetType<Type>[]
  : T extends `string`
    ? string
    : T extends `number`
    ? number
    : never

type ParseSchema<T extends string> = T extends `${infer Key} ${infer Type}`
  ? { [x in Key] : GetType<Type> }
  : {}

type Result = ParseSchema<'name string[]'> // { name: string[] }

2.2 多行字符串 Schema 的类型解析

到这儿已经有点上头了,那多个属性以多行字符串 Schema 的形式声明,这种情况能不能解析成功呢?

没有什么是分层解决不了的问题,如果有就再包一层。

我们加一个ParseLine的泛型递归提取每行字符串的类型,并将结果通过泛型参数组合传递,就可以得到一个能解析多行 schema 的泛型。

代码语言:javascript
复制
type ParseLine<T extends string> = T extends `${infer Key} ${infer Type}`
  ? { [x in Key] : GetType<Type> }
  : {}

type ParseSchema<Str extends string, Origins extends Object = {}> = Str extends `${infer Line}\n${infer NextLine}`
  ? ParseSchema<NextLine, ParseLine<Line> & Origins>
  : ParseLine<Str> & Origins

type Result = ParseSchema<
`name string
age number`
> // { name: string } & { age: number }

2.3 结构体类型的引用

到这里我们已经实现了将多行字符串声明解析成对应类型,但目前都是单层结构体,如果想实现一个嵌套的结构体,声明键值的类型引用另外一个结构体类型,这时候该怎么办呢?

我们知道在 ts 中只需要在类型声明中将类型声明为指定的结构体名称就可以,但在字符串类型中并没有被引用类型的结构体,所以我们需要在ParseSchema中扩展一个泛型参数用来传入需要引用的类型结构体,这可能会有多个。然后我们再修改一下 Schema 的规则,抄一个指针的声明方式来表示引用结构体类型例如user *User

我们先给GetType添加一个引用规则的解析,注意引用结构体是需要支持数组的,例如users *User[],所以在递归过程中数组的声明要优先处理。

代码语言:javascript
复制
type GetType<
  Str extends string,
  Includes extends Object = {},
> = Str extends `${infer Type}[]`
  ? Array<GetType<Type, Includes>>
  : Str extends keyof TypeTransformMap
    ? TypeTransformMap[Str]
    : Str extends `*${infer IncloudName}`
      ? IncloudName extends keyof Includes
        ? Includes[IncloudName]
        : never
      : never

上述代码中Str为目标字符串,Includes为传入的引用类型表,为了便于阅读将string | number | null等这些类型的字符串schema收拢到一个Map表来处理。

接着我们需要对ParseLineParseSchema进行改造,透传需要继承的类型。

代码语言:javascript
复制
type ParseLine<T extends string, Includes extends Object = {}> = T extends `${infer Key} ${infer Type}`
  ? { [x in Key] : GetType<Type, Includes> }
  : {}

type ParseSchema<
Str extends string,
Includes extends Object = {},
Origins extends Object = {},
> = Str extends `${infer Line}\n${infer NextLine}`
  ? ParseSchema<NextLine, ParseLine<Line, Includes> & Origins>
  : ParseLine<Str, Includes> & Origins

03、写一个用于安全访问对象的轮子

我们在用 ts 写业务代码的时候通常会用类型来约束对象的结构,例如:

代码语言:javascript
复制
interface UserInfo {
  name: string;
  email: string;
}
...
const users: UserInfo = getUser();

这些类型会在开发过程中会对变量进行类型检查,约束我们对变量的使用。但这些类型只存在开发过程中,浏览器运行时只会执行编译后的js代码。因此我们即便使用了类型约束,也会加入防御式代码来防止意外结构体导致的程序崩溃,例如:

代码语言:javascript
复制
const user: UserInfo = await getUser() // real res: { name: 'bruce', email: null }
// user.email.replace(/\.com$/, ''); // Error!
user.email?.replace(/\.com$/, '');

这样的开发体验确实太奇怪了。既然刚学会的模板字符串这么好玩,不如用来写个轮子吧!

3.1 Schema 定义

这个轮子通过接收一个描述对象结构类型的字符串来生成一个守护者实例(Keeper),然后通过示例的 api 来安全访问或格式化对象。

描述类型的字符串schema设计如下:

代码语言:javascript
复制
<property> <type> <extentions>
  • <property>:属性名称,支持字符串或数字。
  • <type>:属性类型,可以是基础类型(如 string、int、float,详情见下文)或数组类型(如 int[])。此外,也支持使用 *<extends> 格式来实现类型的嵌套。
  • <extentions>(可选):当前属性的额外描述,目前支持<copyas>:<alias>(复制当前类型为属性名为<alias>的新属性) 以及<renamefrom>:<property>(当前属性值从源对象的<property>属性返回)。

有时候我们可能遇到需要将某个对象键名的下划线转成驼峰的场景,例如:

代码语言:javascript
复制
interface UserInfo {
 user_name: string
 userName: string
}

const res = await getUser(); // { user_name }
const user = { ...res, userName: res.user_name }

实际上我们在业务代码中不需要关注和使用 user 对象中的user_name,因此我在schema中扩展了第三个声明属性<extentions>,它通过声明renamefrom关键字将对象属性重命名这件事在类型定义阶段实现。

代码语言:javascript
复制
const User = createKeeper(`
  name string
  age  int    renamefrom:user_age
`);

const data = User.from({
  name: "bruce",
  user_age: "18.0",
});

console.log(data); // { name: 'bruce', age: 18 }

3.2 对象访问

Keeper 实例提供两个方法用于获取数据,from(obj)read(obj, path)分别用于根据类型描述和源对象生成一个新对象和根据类型描述获取源对象中指定 path 的值。

当我们需要安全获取对象中的某个值时,可以用 read API 来操作,例如

代码语言:javascript
复制
const userInfo = createKeeper(`
   // name
   name    string
   // age
   age     int      renamefrom:user_age
`);

const human = createKeeper(
  `
  id      int
  scores  float[]
  info    *userInfo
`,
  { extends: { userInfo } },
);

const sourceData = {
  id: "1",
  scores: ["80.1", "90"],
  info: { name: "bruce", user_age: "18.0" },
};

const id = human.read(sourceData, "id"); // 1
const name = human.read(sourceData, "info.name"); // 'bruce'
const bro1Name = human.read(sourceData, "bros[0].name"); // 'bro1'

该方法类似 lodash.get,并且同样支持多层嵌套访问和代码提示。

当我们期望从源数据修正并得到一个完全符合类型声明定义的对象时,可以用 from API 来操作,注当原数据为空并且对应声明属性不为空类型时(null|undefined),会根据声明的类型给出一个默认值。

代码语言:javascript
复制
const sourceData = {
  id: "1",
  bros: [],
  info: { name: "bruce", user_age: "18.1" },
};
human.from(sourceData); // { id: 1, bros: [], { name: 'bruce', age: 18 } }
human.read(sourceData, "bros[0].age"); // 0

04、尾声

其实写完轮子的这一刻我有些恍惚,看着一坨一坨的泛型,内心也从“它还可以这样”变成了“它为什么可以这样”。对我而言 ts 很大程度上解决了 js 过于灵活带来的工程问题,它约束了一些 js 的想象力,但似乎又提供了另一种灵活的方式来弥补这种差异。

-End-

原创作者 | 欧阳雨辰

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2024-05-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯云开发者 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 01、TypeScript 模板字符串类型
  • 02、实现字符串 Schema 类型解析
  • 03、写一个用于安全访问对象的轮子
  • 04、尾声
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档