看了一行代码,我连夜写了个轮子
👉目录
1 Typescript 模板字符串类型
2 实现字符串 Schema 类型解析
3 写一个用于安全访问对象的轮子
4 尾声
01
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中的参数类。
这是什么魔法?带着好奇 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): {};
02
在开发过程中偶尔会遇到需要用到字符串schema来声明某些属性或能力,例如上面的 express 路由。既然字符串可以通过模板字符串来实现token级别的类型计算,那么是不是可以用来玩一些更花哨的schema方法,这个觉就没必要再睡下去了,原神启动!
先来浅试一下,假如我有一个工具函数,根据对象的字符串 schema 描述转换成对应的结构体类型,例如将type Str = 'name string'转换为type Obj = {name: string},我们设计 schema 的格式为[key] [type],然后照猫画虎用infer关键字拿出字符串中声明的key和type。
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: nevertype ParseSchema<T extends string> = T extends `${infer Key} ${infer Type}`? { [x in Key] : GetType<Type> }: {}type Result = ParseSchema<'name string[]'> // { name: string[] }
到这儿已经有点上头了,那多个属性以多行字符串 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> & Originstype Result = ParseSchema<`name stringage number`> // { name: string } & { age: number }
到这里我们已经实现了将多行字符串声明解析成对应类型,但目前都是单层结构体,如果想实现一个嵌套的结构体,声明键值的类型引用另外一个结构体类型,这时候该怎么办呢?
我们知道在 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表来处理。
接着我们需要对ParseLine和ParseSchema进行改造,透传需要继承的类型。
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 写业务代码的时候通常会用类型来约束对象的结构,例如:
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$/, '');
这样的开发体验确实太奇怪了。既然刚学会的模板字符串这么好玩,不如用来写个轮子吧!
这个轮子通过接收一个描述对象结构类型的字符串来生成一个守护者实例(Keeper),然后通过示例的 api 来安全访问或格式化对象。
描述类型的字符串schema设计如下:
<property> <type> <extentions><property>:属性名称,支持字符串或数字。<type>:属性类型,可以是基础类型(如 string、int、float,详情见下文)或数组类型(如 int[])。此外,也支持使用*<extends>格式来实现类型的嵌套。<extentions>(可选):当前属性的额外描述,目前支持<copyas>:<alias>(复制当前类型为属性名为<alias>的新属性) 以及<renamefrom>:<property>(当前属性值从源对象的<property>属性返回)。
interface UserInfo {user_name: stringuserName: string}const res = await getUser(); // { user_name }const user = { ...res, userName: res.user_name }
user_name,因此我在schema中扩展了第三个声明属性<extentions>,它通过声明renamefrom关键字将对象属性重命名这件事在类型定义阶段实现。const User = createKeeper(`name stringage int renamefrom:user_age`);const data = User.from({name: "bruce",user_age: "18.0",});console.log(data); // { name: 'bruce', age: 18 }
Keeper 实例提供两个方法用于获取数据,from(obj)和read(obj, path)分别用于根据类型描述和源对象生成一个新对象和根据类型描述获取源对象中指定 path 的值。
当我们需要安全获取对象中的某个值时,可以用 read API 来操作,例如
const userInfo = createKeeper(`// namename string// ageage int renamefrom:user_age`);const human = createKeeper(`id intscores 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"); // 1const name = human.read(sourceData, "info.name"); // 'bruce'const bro1Name = human.read(sourceData, "bros[0].name"); // 'bro1'
lodash.get,并且同样支持多层嵌套访问和代码提示。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
04
其实写完轮子的这一刻我有些恍惚,看着一坨一坨的泛型,内心也从“它还可以这样”变成了“它为什么可以这样”。对我而言 ts 很大程度上解决了 js 过于灵活带来的工程问题,它约束了一些 js 的想象力,但似乎又提供了另一种灵活的方式来弥补这种差异。
📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~
(长按图片立即扫码)