早在 TypeScript 4.1 版本中,引入了一种新的类型,叫做模板字符串类型,这种类型可以让你在类型级别上操作字符串。自发布以来这个新特性并没有给我的码农生涯带来什么惊喜,直到那个夜晚。。。
Typescript模板字符串类型
在ts中模板字符串类型是字符串类型的扩展,这些字符串可以包含嵌入的表达式,可以字符串字面量类型或者是字符串字面量类型的联合类型。我们先来看看官方示例:
type World = 'world';
type Greeting = `hello ${World}`;
// ^ type = "hello world"
它的写法与es的模板字符串相同,只是把它搬到了类型定义上。
乍一看平平无奇,感觉用处不大,难道字符串还能玩儿出花来?直到睡前我看到了这么一行代码
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)
: {};
实现字符串Schema类型解析
在开发过程中偶尔会遇到需要用到字符串schema
来声明某些属性或能力,例如上面的express路由。既然字符串可以通过模板字符串来实现token
级别的类型计算,那么是不是可以用来玩一些更花哨的schema
方法,这个觉就没必要再睡下去了,原神启动!
描述结构体类型的字符串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
: never
type ParseSchema<T extends string> = T extends `${infer Key} ${infer Type}`
? { [x in Key] : GetType<Type> }
: {}
type Result = ParseSchema<'name string[]'> // { name: string[] }
多行字符串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 }
结构体类型的引用
这里已经实现了将多行字符串声明解析成对应类型,但目前都是单层结构体,如果想声明一个嵌套的结构体,声明键值的类型引用另外一个结构体类型,这时候该怎么办呢?
我们知道在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
写一个用于安全访问对象的轮子
我们在用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$/, '');
这样的开发体验确实太奇怪了。既然刚学会的模板字符串这么好玩,不如用来写个轮子吧!
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 }
对象访问
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
,并且同样支持多层嵌套访问和代码提示。
当我们期望从源数据修正并得到一个完全符合类型声明定义的对象时,可以用 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
尾声
其实写完轮子的这一刻我有些恍惚,看着一坨一坨的泛型,内心也从“它还可以这样”变成了“它为什么可以这样”。
对我而言ts很大程度上解决了js过于灵活带来的工程问题,它约束了一些js的想象力,但似乎又提供了另一种灵活的方式来弥补这种差异。
感兴趣的小伙伴欢迎一起交流。
原文链接:https://juejin.cn/post/7345423793210654730 作者:净坛食者