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

f331a98e6ff1942167779ca0b57c4b8a.png

9d5ddaaa6b5649ed874ce507efa62ec1.gif

👉目录

1 Typescript 模板字符串类型

2 实现字符串 Schema 类型解析

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

4 尾声

早在 TypeScript 4.1 版本中,引入了一种新的类型,叫做模板字符串类型,这种类型可以让你在类型级别上操作字符串。自发布以来这个新特性并没有给我的码农生涯带来什么惊喜,直到那个夜晚。。。

01

TypeScript 模板字符串类型

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

type World = 'world'; 
type Greeting = `hello ${World}`; 
// ^ type = "hello world"

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

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

app.get('/api/:id', (req, res) => {
  const uid = req.params.id; // string
})

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

90a92a0ffe911738c016ac1b5787a653.png

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

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)
    : {};

4e04d0c7bcd98a4fa1027895ce4ac9ce.png

02

实现字符串 Schema 类型解析

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

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

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

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 内容。

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 的泛型。

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[],所以在递归过程中数组的声明要优先处理。

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表来处理。

ae6bc24f637b8834c9d8233b9067da56.png

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

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

77ad57d74cfdc650d9d3d459f0b90c4c.png

03

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

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

interface UserInfo {
  name: string;
  email: string;
}
...
const users: UserInfo = getUser();

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

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设计如下:

<property> <type> <extentions>
  • <property>:属性名称,支持字符串或数字。

  • <type>:属性类型,可以是基础类型(如 string、int、float,详情见下文)或数组类型(如 int[])。此外,也支持使用 *<extends> 格式来实现类型的嵌套。

  • <extentions>(可选):当前属性的额外描述,目前支持<copyas>:<alias>(复制当前类型为属性名为<alias>的新属性) 以及<renamefrom>:<property>(当前属性值从源对象的<property>属性返回)。

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

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关键字将对象属性重命名这件事在类型定义阶段实现。

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 来操作,例如

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,并且同样支持多层嵌套访问和代码提示。

91f938e1696c326c87827b13f9ba42ec.gif

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

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

d89b68b1cd010aeda1471cc6d1b95975.gif

04

尾声

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

-End-

原创作者 | 欧阳雨辰

 88553688fa46e18c678a85f65d1ac9b7.png

Typescript 还有什么冷门但是很好用的特性?欢迎评论留言。我们将选取1则优质的评论,送出腾讯Q哥公仔1个(见下图)。5月13日中午12点开奖。

8a54bfafafd231c5517d47c56b0e1354.png

📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~

a1dee6e3e9a25d8e8441cc17910836f6.jpeg

(长按图片立即扫码)

a3602d1dcf0d52c157f7d5cad622f486.png

7d9a6ee2662df561712f3000f2129f56.png

15fe1a182bfbc8391c9fcc588b1e2f2a.png

452eec71cae43c7bc2da46c70c3bc874.png

fee4e6ce22946d42a25c6cc882986721.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值