做者:劉輝html
最近筆者把一箇中等規模的 Koa2 項目遷移到 TypeScript,和你們分享一下 TypeScript 實踐中的經驗和技巧。vue
原項目基於 Koa2,MySQL,sequelize,request,接口加頁面總計 100 左右。遷移後項目基於 Midway,MySQL,sequelize-typescript,axios。react
本項目使用 TypeScript3.7,TypeScript 配置以下:ios
"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 的實踐,第二部分是構建類型系統的實踐。git
使用 TypeScript 就不得不面對 any 帶來的問題,首先來看看爲何 any 值得咱們認真對待。github
咱們來看這段代碼:typescript
function add(a: number, b: number):number { return a + b; } var a:any = '1'; var b = 2; console.log(add(a,b)) // '12' 複製代碼
代碼能夠直接粘貼到 Playground 執行數據庫
add 的本意是兩個數字相加,可是由於 a 實際上是字符串,經過使用 any 類型跳過了類型檢查,因此變成了字符串鏈接,輸出了字符串 「12」,並且這個 「12」 依然被當成 number 類型向下傳遞。express
固然,咱們通常不會犯這麼明顯的錯誤,那麼再來看這個例子:json
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?
TypeScript 在 3.0 版本以前,只有 any 這樣一個頂級類型。若是有一個值來自動態的內容,咱們在定義的時候並不肯定它的類型時,any 多是惟一的選擇,官方文檔也是如此解釋的。所以咱們能夠看到 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 可能出於如下幾個理由:
頂級類型能夠考慮使用 unknown 代替;暫時不知道怎麼寫或者項目遷移,仍是應該儘早消滅 any;對於寫第三方庫,本文無此方面實踐,歡迎你們思考提建議。
本項目處理 any 的思路很簡單,不顯式使用 any,使用 unknown 做爲頂級類型。接收到一個 any 類型的數據時使用類型守護「Type Guards」或者斷言函數「Assertion Functions」來明確數據類型,而後把類型守護函數和斷言函數統一管理。
TypeScript 3.0 增長了新的頂級類型 unknown。
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 安全的多。
主流靜態類型語言基本都提供了類型轉換,類型轉換會嘗試把數據轉換成須要的類型,轉換失敗時會報錯。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 類型覆蓋掉,換成 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」。
結合 keyof、never、in 等特性,使 TypeScript 具備了必定程度上的類型運算能力,可讓咱們得到一個類型的變體和衍生類型。
假設咱們有 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,交叉類型就是取並集,聯合類型就是取其中之一。
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 使咱們有了對一個索引類型/聯合類型遍歷、從新設置每一個屬性的類型的能力。
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 是一個條件類型,T一、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,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 來調用。例如:
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」可讓咱們區分類似的類型。例如:
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 獲得大量提示,避免語法、拼寫錯誤,這時候咱們能夠不那麼嚴謹,繼續用動態語言的思路寫代碼。
咱們也能夠把它當成類型約束來用,這可能會增長咱們的工做量。咱們除了維護代碼自己,還要維護類型系統,並且建立一個精簡、合理的類型系統可能並非一件簡單的事。
若是你以爲這篇內容對你有價值,請點贊,並關注咱們的官網和咱們的微信公衆號(WecTeam),每週都有優質文章推送: