Node.js 項目 TypeScript 改造指南(二)
- 2019 年 12 月 26 日
- 筆記
最近筆者把一個中等規模的 Koa2 項目遷移到 TypeScript,和大家分享一下 TypeScript 實踐中的經驗和技巧。
原項目基於 Koa2,MySQL,sequelize,request,介面加頁面總計 100 左右。遷移後項目基於 Midway,MySQL,sequelize-typescript,axios。
本項目使用 TypeScript3.7,TypeScript 配置如下:
"compilerOptions": { "declaration": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, "incremental": true, "inlineSourceMap": true, "module": "commonjs", "newLine": "lf", "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "outDir": "dist", "pretty": true, "skipLibCheck": true, "strict": true, "strictPropertyInitialization": false, "stripInternal": true, "target": "ES2017" }
本文分為兩部分,第一部分是處理 any 的實踐,第二部分是構建類型系統的實踐。
對 any 的處理
使用 TypeScript 就不得不面對 any 帶來的問題,首先來看看為什麼 any 值得我們認真對待。
any 的危害
我們來看這段程式碼:
function add(a: number, b: number):number { return a + b; } var a:any = '1'; var b = 2; console.log(add(a,b)) // '12'
程式碼可以直接粘貼到 Playground[1] 執行
add 的本意是兩個數字相加,但是因為 a 其實是字元串,通過使用 any 類型跳過了類型檢查,所以變成了字元串連接,輸出了字元串 「12」,而且這個 「12」 依然被當成 number 類型向下傳遞。
當然,我們一般不會犯這麼明顯的錯誤,那麼再來看這個例子:
var resData = `{"a":"1","b":2}` function add(a: number, b: number):number { return a + b; } var obj = JSON.parse(resData); console.log(add(obj.a,obj.b)) // '12'
我們假設 resData 為介面返回的 json 字元串,我們用 JSON.parse 解析出數據然後相加,為什麼類型檢查沒有提醒我 obj.a 不是 number 類型?
因為 JSON.parse 的簽名是這樣的:
// lib.es5.d.ts parse(text: string, reviver?: (this: any, key: string, value: any) => any): any;
JSON.parse 返回的是 any 類型,不受類型檢查約束,數據從進入 add 方法以後,才受類型檢查約束,但是這時數據類型已經對不上了。
在這種數據類型已經對不上真實類型的情況下,我們怎麼進行糾正?來看以下的程式碼:
var resData = `{"a":"1","b":2}` function add(a: number, b: number):number { var c = parseInt(a) // Error: Argument of type 'number' is not assignable to parameter of type 'string'. var d:string = a as string //Error: Conversion of type 'number' to type 'string' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. var e = Number(a) return a + b; } var obj = JSON.parse(resData); console.log(add(obj.a,obj.b))
parseInt 只接受 string 類型參數,a 已經被推斷為 number,因此報錯。使用 as 更改類型同樣報錯,編譯器建議如果一定要更改類型,需要使用 unknown 類型中轉一下。Number() 可以進行正確的轉換,因為 Number 上有這樣一個簽名:參數為 any,可以接受任何類型的參數。
// lib.es5.d.ts interface NumberConstructor { // ... (value?: any): number; // ... } declare var Number: NumberConstructor;
然而這樣做,我們的類型檢查還有意義嗎?為什麼不直接寫 js?
any 的來源
TypeScript 在 3.0 版本之前,只有 any 這樣一個頂級類型。如果有一個值來自動態的內容,我們在定義的時候並不確定它的類型時,any 可能是唯一的選擇,官方文檔[2]也是如此解釋的。因此我們可以看到 any 在基礎庫、第三方庫中普遍存在。
但是 any 跳過了類型檢查,確實給我們帶來了隱患,為了保證多人協作時不因此引發問題,我們需要想辦法讓這種危險可控。
首先,我們需要明確系統中哪裡有 any。
- 開啟嚴格選項
在 tsconfig.json 的 compilerOptions 屬性中開啟嚴格選項 "strict": true
。此選項可以保證,我們自己寫的程式碼不會製造出隱式的 any。
- 了解基礎庫、第三方庫中的類型
寫程式碼時,應注意基礎庫、第三方庫中函數輸入輸出是否使用了 any,類型、介面是否直接、間接使用了 any。
最典型的,例如 require:
interface NodeRequireFunction { /* tslint:disable-next-line:callable-types */ (id: string): any; }
var path = require("path") // require 引入的內容都是 any
還有 JSON:
// 一個對象使用了 JSON.parse(JSON.stringify(obj)) 就會變成 any 類型,不再受類型檢查約束 interface JSON { parse(text: string, reviver?: (this: any, key: string, value: any) => any): any; stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string; stringify(value: any, replacer?: (number | string)[] | null, space?: string | number): string; }
對於本項目來說,Koa 的 ctx 上的 query:
// @types/koa/index.d.ts declare interface ContextDelegatedRequest { // ... header: any; headers: any; query: any; // ... }
Axios 請求方法的泛型參數上的默認類型 T,如果 get 上沒有註明返回的數據類型來覆蓋 T,res.data 的類型就是 any:
// axios/index.d.ts interface AxiosInstance { get<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R>; } interface AxiosResponse<T = any> { data: T; status: number; statusText: string; headers: any; config: AxiosRequestConfig; request?: any; }
- 不顯式使用 any
也就是自己不寫 any。
使用 any 可能出於以下幾個理由:
- 需要頂級類型
- 暫時不知道類型怎麼寫
- 項目遷移方便
- 寫第三方庫,使用者用起來方便
頂級類型可以考慮使用 unknown 代替;暫時不知道怎麼寫或者項目遷移,還是應該儘早消滅 any;對於寫第三方庫,本文無此方面實踐,歡迎大家思考提建議。
讓 any 可控
本項目處理 any 的思路很簡單,不顯式使用 any,使用 unknown 作為頂級類型。接收到一個 any 類型的數據時使用類型守護「Type Guards[3]」或者斷言函數「Assertion Functions[4]」來明確數據類型,然後把類型守護函數和斷言函數統一管理。
用 unknown 作為頂級類型
TypeScript 3.0 增加了新的頂級類型 unknown[5]。
TypeScript 3.0 introduces a new top type unknown. unknown is the type-safe counterpart of any. Anything is assignable to unknown, but unknown isn』t assignable to anything but itself and any without a type assertion or a control flow based narrowing. Likewise, no operations are permitted on an unknown without first asserting or narrowing to a more specific type. — typescript handbook
unknown 可以看做是類型安全的 any。任何類型的數據都可以賦值給一個 unknown 變數,但是 unknown 類型的數據只能分配給 unknown 和 any 類型。我們必須通過斷言或者收窄把 unknown 變成一個具體的類型,否則無法進行其他操作。
我們把之前使用 any 的程式碼改成 unknown 看一下:
function add(a: number, b: number):number { return a + b; } var a:unknown = '1'; var b = 2; console.log(add(a,b)) // Error: Argument of type 'unknown' is not assignable to parameter of type 'number'.
unknown 類型不能賦值給 number 類型。
我們使用類型守護把 unknown 收窄,因為 a 的真實類型不是 number 因此會走到 else 分支:
function add(a: number, b: number):number { return a + b; } var a:unknown = '1'; var b = 2; if (typeof a == "number") { console.log(add(a,b)) } else { console.log('params error') } // params error
類型守護 & 斷言函數
類型守護可以使用 in 操作符、typeof、instanceof 來收窄類型。除此之外,還可以自定義類型守護函數。斷言函數的功能類似,例如下面一段程式碼,用類型守護和斷言函數處理 any 類型的 ctx.body。
// 定義一個類型 interface ApiCreateParams { name:string info:string } // 確認data上是否有names中的欄位 function hasFieldOnBody<T extends string>(obj:unknown,names:Array<T>) :obj is { [P in T]:unknown } { return typeof obj === "object" && obj !== null && names.every(name=>{ return name in obj }) } function assertApiCreateParams(data:unknown):asserts data is ApiCreateParams { if( hasFieldOnBody(data,['name', 'info']) && typeof data.name === "string" && typeof data.info === "string" ){ console.log(data.name,data.info,data) // data.name 的類型為 string,data.info的類型為string,但是data的類型是{name:unknown,info:unknown} }else{ throw "api create params error" } } @get('/create') // midway controller 上定義的方法,處理 /create 路由 async create(): Promise<void> { let data = this.ctx.request.body; // data的類型為any assertApiCreateParams(data); console.log(data) // data的類型已經被推斷為ApiCreateParams // ... }
對 unknown 進行類型收窄在處理複雜 JSON 時會比較繁瑣,我們可以結合 JSON Schema 來進行驗證。自定義斷言函數本質上是把類型驗證的工作交給了開發者,一個錯誤的斷言函數,或者直接寫一個空的斷言函數,同樣會導致類型系統推導錯誤。但是我們可以把斷言函數管理起來,比如制定斷言函數的命名規範,把斷言函數集中在一個文件管理。這樣可以使不安全因素更可控,比到處都是 any 安全的多。
不要使用類型斷言「type-assertions[6]」處理 any
主流靜態類型語言基本都提供了類型轉換,類型轉換會嘗試把數據轉換成需要的類型,轉換失敗時會報錯。TypeScript 的類型斷言「type-assertions」語法上像極了類型轉換,但是它並不是類型安全的。
Type assertions are a way to tell the compiler 「trust me, I know what I』m doing.」 A type assertion is like a type cast in other languages, but performs no special checking or restructuring of data. It has no runtime impact, and is used purely by the compiler. TypeScript assumes that you, the programmer, have performed any special checks that you need. — typescript handbook
尤其是對一個 any 類型使用 as 時,肯定不會失敗,例如:
function add (a:number,b:number){ var c = a + b; console.log(c); } var a: any = '1'; var b = 2 var c = a as number; add(c, b); // '12'
我們想把 a 轉換成數字來相加,數字和字元串原本不能直接做類型轉換,但是 any 不受類型檢查約束。最後還是返回了字元串 「12」,而不是我們想要的 3。
覆蓋第三方庫中的 any
我們可以通過繼承的方式,把第三方庫原有 any 類型覆蓋掉,換成 unknown 或者更具體的類型。例如處理 Koa Context 上的 query 和 request.body。
interface ParsedUrlQuery { [key: string]: string | string[]; } // copy from querystring.d.ts interface IBody { [key: string]: unknown; } interface RequestPlus extends Request{ body:IBody } interface ContextPlus extends Context{ query:ParsedUrlQuery request:RequestPlus }
在 Midway 中使用:
@provide() @controller('/api') export class ApiController extends AbstractController { @inject() ctx: ContextPlus; }
構建強大的類型系統
使用 TypeScript 經常會遇到的一個問題就是,需要寫很多類型,但是有很多類型都很相似,每個類型都重新定義感覺很啰嗦,很容易違反 DRY 原則。
本項目是一個管理系統,核心模型就是資料庫表,對應到程式碼里首先就是 Model 層,圍繞這個核心會有很多 Model 類型的變體和衍生類型。例如,SQL 的查詢條件,增刪改查介面的各種參數;Model 里可能是數字類型,但是 url query 上都當字元串類型傳過來;創建參數不包含 id 欄位,更新參數包含 id 欄位,但是其他欄位可選;兩個 Model 的一部分合併一個新的對象,等等。。
接下來我們將通過 TypeScript 提供的功能,構建合理且精簡的類型系統。
介面繼承
介面繼承大家應該都不陌生,以帶分頁功能的查詢參數為例:
interface Paging { pageIndex:number pageSize:number } // 繼承 Paging 的新類型 interface APIQueryParams extends Paging { keyword:string title:string } // 繼承 Paging 的新類型 interface PackageQueryParams extends Paging { name:string desc:string }
類型推導
TypeScript 2.8 增加了條件類型「Conditional Types[7]」。
結合 keyof、never、in 等特性,使 TypeScript 具有了一定程度上的類型運算能力,可以讓我們獲得一個類型的變體和衍生類型。
可供使用的工具
交叉類型「Intersection Types[8]」 和 聯合類型「Union Types[9]」
假設我們有 Serializable 和 Loggable 兩個類型。
type Serializable = {toString:(data:unknown) => string} type Loggable = {log:(data:unknown) => void} type A = Serializable & Loggable type B = Serializable | Loggable
類型 A 表示一個交叉類型,它需要同時滿足 Serializable 和 Loggable。
類型 B 表示一個聯合類型,它只要滿足 Serializable 和 Loggable 其中之一即可。
如果我們把一個類型看做一組規則的 Map,交叉類型就是取並集,聯合類型就是取其中之一。
索引類型「Index types[10]」和映射類型「Mapped types[11]」
type Person = { name: string; age: number; } type PersonKeys = keyof Person; // 'name' | 'age' type PersonMap = { [K in PersonKeys]: boolean }; // { name:boolean,age:boolean } type PersonMapEx1 = { [K in PersonKeys]: Person[K] | boolean }; // { name: string | boolean,age:string | number } type PersonMapEx2 = { [K in PersonKeys]: Person[K] }["name"]; // string type PersonMapEx3 = { [K in PersonKeys]: Person[K] }["name"|"age"]; // string|number type PersonMapEx4 = { [K in PersonKeys]: Person[K] }[keyof Person]; // string|number type PersonMapEx5 = Person['name'|'age']; // string|number
PersonKeys 是一個索引類型,同時也是聯合類型,通過 Keyof 實現。PersonMap 是一個映射類型,使用 in 實現遍歷,注意映射類型的格式。觀察 PersonMapEx1-5,可以發現,在類型定義中,{}
用來構造一個鍵值對,[]
用來放置 key 或 key 組成的聯合,{}[]
可以用來取對應 key 的類型。
如果我們把一個類型看做一組規則組成的 Map,key 是屬性名,value 是類型,keyof 使我們有了取得所有 key 的能力。
in 使我們有了對一個索引類型/聯合類型遍歷、重新設置每個屬性的類型的能力。
條件類型「Conditional Types[12]」
type Circle = { rad:number, x:number, y:number } type TypeName<T> = T extends {rad:number} ? Circle : unknown type T1 = TypeName<{rad:number}> // Circle type T2= TypeName<{rad:string}> // unknown
以上是一個最基本的條件類型,條件類型基於泛型,通過對泛型參數操作獲取新類型。extend 在這裡表示可兼容的「assignable」,和鴨子類型的機制一樣,如果把類型看做集合,也可以理解為集合上的包含關係
。?:
和 js 的三目運算符功能一致,使我們具備了條件分支的能力
。在上例中,TypeName 是一個條件類型,T1、T2 是把泛型參數明確以後通過條件分支得到的類型。
另外,我們還可以用在映射類型中提到的 {}[]
的形式表達複雜的判斷邏輯,例如以下這段來自 Vue 的程式碼,雖然看著複雜,但是只要明確了extends ?: {} []
這些符號的作用,就很容易理清程式碼表達的意思:
// https://github.com/vuejs/vue-next/blob/master/packages/reactivity/src/ref.ts export type UnwrapRef<T> = { cRef: T extends ComputedRef<infer V> ? UnwrapRef<V> : T ref: T extends Ref<infer V> ? UnwrapRef<V> : T array: T extends Array<infer V> ? Array<UnwrapRef<V>> & UnwrapArray<T> : T object: { [K in keyof T]: UnwrapRef<T[K]> } }[T extends ComputedRef<any> ? 'cRef' : T extends Ref ? 'ref' : T extends Array<any> ? 'array' : T extends Function | CollectionTypes ? 'ref' // bail out on types that shouldn't be unwrapped : T extends object ? 'object' : 'ref']
如果 T 可以解釋為聯合類型,在條件判斷中可以進行展開,除了聯合類型,any、boolean、使用 keyof 得到的索引類型,都可以展開。例如:
type F<T> = T extends U ? X : Y type union_type = A | B | C type FU = F<union_type> // a的結果為 A extends U ? X :Y | B extends U ? X :Y | C extends U ? X : Y type Params = { name: string; title:string; id: number; } type UX<T> = { [K in keyof T]: T[K] extends string ? T[K] : string} type StringFields<T> = { [K in keyof T]: T[K] extends string ? K : never }[keyof T] type U1 = UX<Params> // {name:string,title:string,id:string} type U2 = StringFields<Params> // "name"|"title"
注意類型 StringFields中的 never[13],never 是 TypeScript 的基礎類型之一,表示不可到達。
// 返回never的函數必須存在無法達到的終點 function error(message: string): never { throw new Error(message); }
在條件類型中,起到了過濾的效果。也就是說 never 讓我們有了從一個類型中刪減規則的能力。
除此之外,還有一個關鍵詞 infer 即 inference 的縮寫,使我們具備了代換、提取類型的能力。
官方的例子:
type Unpacked<T> = T extends (infer U)[] ? U : T extends (...args: any[]) => infer U ? U : T extends Promise<infer U> ? U : T; type T0 = Unpacked<string>; // string type T1 = Unpacked<string[]>; // string type T2 = Unpacked<() => string>; // string type T3 = Unpacked<Promise<string>>; // string type T4 = Unpacked<Promise<string>[]>; // Promise<string> type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string
在 T extends
後面的類型表達式上,我們可以對一個可以表達為類型的符號使用 infer,然後在輸出類型中使用 infer 引用的類型,至於這個類型具體是什麼,會在 T 被確定時自動推導出來。示例程式碼的功能就是從數組、函數、Promise 中解出其中的類型。
可選 & 只讀屬性
type MutableRequired<T> = { -readonly [P in keyof T]-?: T[P] }; // Remove readonly and ? type ReadonlyPartial<T> = { +readonly [P in keyof T]+?: T[P] }; // Add readonly and ?
我們可以給類型屬性增加只讀或者可選標記,使用 – 號,可以把原本帶有的只讀和可選標記去掉,+ 代表增加,可以省略。
基礎庫中提供的抽象類型
以上述能力為基礎,基礎庫中提供了許多常用的抽象類型,為得到衍生類型和變體提供了很大幫助。以 TypeScript 3.7 為例:
type Circle = { rad:number, x:number, y:number, name:string } type Params = { name: string; title:string; id: number; } class Shape { constructor (x:number,y:number){ this.x = x; this.y = y; } x:number;y:number; } type a1 = Partial<Params> // 使Params上的欄位變為可選 type a2 = Required<Params> // 使Params上的欄位變為必選 type a3 = Readonly<Params> // 使Params上的欄位變為只讀 type a4 = Pick<Params,'name'|'id'> // 提前Params上的name和id {name:string,id:number} type a5 = Record<'a'|'b',Params> // 用a,b做key,Params為value建立類型 {a:Params,b:Params} type a6 = Exclude<keyof Circle,keyof Params> // 排除Circle上Params也有的欄位 "rad"|"x"|"y" type a7 = Extract<keyof Circle,keyof Params> // 提取Circle和Params的公共欄位 "name" type a8 = Omit<Circle,'name'> // 從Circle上去掉name欄位 {x:number,y:number:rad:number} type a9 = NonNullable<Params> // 去掉為空的欄位 type a10 = Parameters<(name:string,id:number)=>void> // 提取函數參數類型 [string,number] type a11 = ConstructorParameters<typeof Shape> // 提取Shape的構造器參數 [number,number] type a12 = ReturnType<()=>Params> // 提取函數返回類型 Params type a13 = InstanceType<typeof Shape> // 提取實例類型 Shape
實際應用
以一個簡化的模組為例,首先使用 sequelize-typescript 提供的基類 Model 和裝飾器創建一個業務類。
import { DataType, Model,Column,Comment,AutoIncrement,PrimaryKey } from 'sequelize-typescript'; const { STRING,TEXT,INTEGER,ENUM } = DataType; export class ApiModel extends Model<ApiModel> { @AutoIncrement @PrimaryKey @Comment("id") @Column({ type: INTEGER({length:11}), allowNull: false }) id!: number; @Comment("parent") @Column({ type: INTEGER({length:11}), allowNull: false }) parent!: number; @Comment("name") @Column({ type: STRING(255), allowNull: false }) name!: string; @Comment("url") @Column({ type: STRING(255), allowNull: false }) url!: string; }
此業務類繼承了 Model,Model 上有大量的屬性和方法,如 version、createdAt、init() 等。我們需要獲取一個只包含業務屬性的類型,因為創建和更新只會傳這幾個欄位,並且創建時沒有 id。查詢的時候,欄位為可選的。下面我們根據需求來定義類型:
// 使用 Omit 排除掉基類上定義的屬性和方法,因為基類上也定義了 id,因此要把 id 留下 type ApiObject = Omit<ApiModel,Exclude<keyof Model,"id">> // {id:number,parent:number,name:string,url:string} // 合併兩個類型,T優先 type Merge<T,S> = { [ K in keyof(T & S) ] : (K extends keyof T ? T[K] : K extends keyof S ? S[K] :never ) } // 創建Api使用的參數,id為自增,所以要去掉id type ApiCreateParams = Omit<ApiObject,"id"> // {parent:number,name:string,url:string} // 查詢參數,創建參數上的欄位可選,使用Partial將欄位全部變為可選 帶分頁功能,因此要和分頁類型合併 // 用上面定義的 Merge 方法合併類型 type ApiQueryParams = Merge<Partial<ApiCreateParams>,Paging> // {id?:number,parent?:number,name?:string,pageIndex:number,pageSize:number} // 分頁類型的定義 type Paging = { pageIndex:number pageSize:number }
收窄類型
TypeScript 沒有提供類型轉換的能力,我們如何從 any、unknown、複雜的聯合類型中獲取具體類型就成為一個問題。
as 可以用來收窄類型,但是風險很大,例如:
type c1 = { name:string,id:number } var v1 = { name:'cccc' } as c1
這段程式碼不會報錯,但是 v1 上其實沒有 id 屬性,造成了隱患。
對於可能為 null 的類型或可選屬性,我們可以用 Optional Chaining[14] 來調用。例如:
interface erpValidateResult { retcode:number msg?:string data?:{ [username: string]:string} } declare function erpValidate(opt:{id:number}):Promise<erpValidateResult> erpValidate({id:1}).then(res=>{ var name = res.data?.username || "" })
對於 any、unknown,可使用前面提到的類型守護和斷言函數收窄。
使用可辨識聯合「Discriminated Unions[15]」可以讓我們區分相似的類型。例如:
interface Square { kind: "square"; size: number; } interface Rectangle { kind: "rectangle"; width: number; height: number; } interface Circle { kind: "circle"; radius: number; } type Shape = Square | Rectangle | Circle; function area(s: Shape) { switch (s.kind) { case "square": return s.size * s.size; // Square case "rectangle": return s.height * s.width; // Rectangle case "circle": return Math.PI * s.radius ** 2; // Circle } }
kind 屬性是一個字元串字面量類型,而且在聯合類型 Shape 的每一個子類型上都不一樣,這個 kind 屬性就被稱為可辨識的特徵或 tag。我們就可以用 kind 來收窄類型。
條件類型允許我們為類型建立包含關係,也是收窄的一種方式。
總結
TypeScript 是個強大並且靈活的工具,而且它的特性還在逐步完善。
我們可以把它當成類型標註來用,讓我們開發時能夠從 IDE 得到大量提示,避免語法、拼寫錯誤,這時候我們可以不那麼嚴謹,繼續用動態語言的思路寫程式碼。
我們也可以把它當成類型約束來用,這可能會增加我們的工作量。我們除了維護程式碼本身,還要維護類型系統,而且創建一個精簡、合理的類型系統可能並不是一件簡單的事。
參考資料
[1]
Playground: https://www.typescriptlang.org/play/index.html
[2]
官方文檔: https://www.typescriptlang.org/docs/handbook/basic-types.html?#any
[3]
Type Guards: https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-guards-and-differentiating-types
[4]
Assertion Functions: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions
[5]
unknown: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#new-unknown-top-type
[6]
type-assertions: https://www.typescriptlang.org/docs/handbook/basic-types.html#type-assertions
[7]
Conditional Types: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#conditional-types
[8]
Intersection Types: https://www.typescriptlang.org/docs/handbook/advanced-types.html#intersection-types
[9]
Union Types: https://www.typescriptlang.org/docs/handbook/advanced-types.html#union-types
[10]
Index types: https://www.typescriptlang.org/docs/handbook/advanced-types.html#index-types
[11]
Mapped types: https://www.typescriptlang.org/docs/handbook/advanced-types.html#mapped-types
[12]
Conditional Types: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#conditional-types
[13]
never: https://www.typescriptlang.org/docs/handbook/basic-types.html#never
[14]
Optional Chaining: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#optional-chaining
[15]
Discriminated Unions: https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions