天天看點

看了一行代碼,我連夜寫了個輪子

作者:散文随風想

目錄

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中的參數類。

看了一行代碼,我連夜寫了個輪子

這是什麼魔法?帶着好奇 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 類型解析

在開發過程中偶爾會遇到需要用到字元串schema來聲明某些屬性或能力,例如上面的 express 路由。既然字元串可以通過模闆字元串來實作token級别的類型計算,那麼是不是可以用來玩一些更花哨的schema方法,這個覺就沒必要再睡下去了,原神啟動!

2.1 描述結構體類型的字元串 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[] }           

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表來處理。

看了一行代碼,我連夜寫了個輪子

接着我們需要對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$/, '');           

這樣的開發體驗确實太奇怪了。既然剛學會的模闆字元串這麼好玩,不如用來寫個輪子吧!

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,并且同樣支援多層嵌套通路和代碼提示。

看了一行代碼,我連夜寫了個輪子

當我們期望從源資料修正并得到一個完全符合類型聲明定義的對象時,可以用 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 的想象力,但似乎又提供了另一種靈活的方式來彌補這種差異。