如何編寫 Typescript 宣告檔案

Jiasm發表於2019-03-04

使用TypeScript已經有了一段時間,這的確是一個好東西,雖說在使用的過程中也發現了一些bug,不過都是些小問題,所以整體體驗還是很不錯的。

TypeScript之所以叫Type,和它的強型別是分不開的,這也是區別於JavaScript最關鍵的一點,型別的宣告可以直接寫在程式碼中,也可以單獨寫一個用來表示型別的描述檔案*.d.ts

常用方式

首先在d.ts中是不會存在有一些簡單的基本型別定義的(因為這些都是寫在表示式、變數後邊的,在這裡定義沒有任何意義),宣告檔案中定義的往往都是一些複雜結構的型別。

大部分語法都與寫在普通ts檔案中的語法一致,也是export後邊跟上要匯出的成員。

最簡單的就是使用type關鍵字來定義:

type A = {                 // 定義複雜結構
  b: number
  c: string
}

type Func = () => number   // 定義函式

type Key = number | string // 多個型別
複製程式碼

組合型別

以及在TypeScript中有著很輕鬆的方式針對type進行復用,比如我們有一個Animal型別,以及一個Dog型別,可以使用&來進行復用。

P.S> &符號可以拼接多個

type Animal = {
  weight: number
  height: number
}

type Dog = Animal & {
  leg: number
}
複製程式碼

動態的 JSON 型別指定

如果我們有一個JSON結構,而它的key是動態的,那麼我們肯定不能將所有的key都寫在程式碼中,我們只需要簡單的指定一個萬用字元即可:

type info = {
  [k: string]: string | number // 可以指定多個型別
}

const infos: info = {
  a: 1,
  b: `2`,
  c: true, // error 型別不匹配
}
複製程式碼

以及在新的版本中更推薦使用內建函式Record來實現:

const infos: Record<string, string | number> = {
  a: 1,
  b: `2`,
  c: true, // error
}
複製程式碼

獲取變數的型別

假如我們有一個JSON物件,裡邊包含了nameage兩個屬性,我們可以通過一些TypeScript內建的工具函式來實現一些有意思的事情。

通過keyoftypeof組合可以得到我們想要的結果:

const obj = {
  name: `Niko`,
  age: 18
}

// 如果是這樣的取值,只能寫在程式碼中,不能寫在 d.ts 檔案中,因為宣告檔案裡邊不能存在實際有效的程式碼
type keys = keyof typeof obj

let a: keys = `name` // pass
let b: keys = `age`  // pass

let c: keys = `test` // error
複製程式碼

而如果我們想要將一個型別不統一的JSON修改為統一型別的JSON也可以使用這種方式:

const obj = {
  name: `Niko`,
  age: 18,
  birthday: new Date()
}

const infos: Record<keyof typeof obj, string> = {
  name: ``,
  age: ``,
  birthday: 123, // 出錯,提示型別不匹配
  test: ``, // 提示不是`info`的已知型別
}
複製程式碼

獲取函式的返回值型別

又比如說我們有一個函式,函式會返回一個JSON,而我們需要這個JSON來作為型別。

那麼可以通過ReturnType<>來實現:

function func () {
  return {
    name: `Niko`,
    age: 18
  }
}

type results = ReturnType<typeof func>

// 或者也可以拼接 keyof 獲取所有的 key
type resultKeys = keyof ReturnType<typeof func>

// 亦或者可以放在`Object`中作為動態的`key`存在
type infoJson = Record<keyof ReturnType<typeof func>, string>
複製程式碼

在程式碼中宣告函式和class型別

因為我們知道函式和class在建立的時候是都有實際的程式碼的(函式體、建構函式)。
但是我們是寫在d.ts宣告檔案中的,這只是一個針對型別的約束,所以肯定是不會存在真實的程式碼的,但是如果在普通的ts檔案中這麼寫會出錯的,所以針對這類情況,我們需要使用declare關鍵字,表示我們這裡就是用來定義一個型別的,而非是一個物件、函式:

class Personal {
  name: string
  // ^ 出錯了,提示`name`必須顯式的進行初始化
}

function getName (personal: Personal): name
// ^ 出錯了,提示函式缺失實現
複製程式碼

以下為正確的使用方式:

-declare class Personal {
+declare class Personal {
  name: string
}

-function getName (personal: Personal): name
+declare function getName (personal: Personal): name
複製程式碼

當然了,一般情況下是不建議這麼定義class的,應該使用interface來代替它,這樣的class應該僅存在於針對非TS模組的描述,如果是自己開發的模組,那麼本身結構就具有宣告型別的特性。

函式過載

這個概念是在一些強型別語言中才有的,依託於TypeScript,這也算是一門強型別語言了,所以就會有需要用到這種宣告的地方。

例如我們有一個add函式,它可以接收string型別的引數進行拼接,也可以接收number型別的引數進行相加。

需要注意的是,只有在做第三方外掛的函式過載定義時能夠放到d.ts檔案中,其他環境下建議將函式的定義與實現放在一起(雖說配置paths也能夠實現分開處理,但是那樣就失去了對函式建立時的約束)

// index.ts

// 上邊是宣告
function add (arg1: string, arg2: string): string
function add (arg1: number, arg2: number): number
// 因為我們在下邊有具體函式的實現,所以這裡並不需要新增 declare 關鍵字

// 下邊是實現
function add (arg1: string | number, arg2: string | number) {
  // 在實現上我們要注意嚴格判斷兩個引數的型別是否相等,而不能簡單的寫一個 arg1 + arg2
  if (typeof arg1 === `string` && typeof arg2 === `string`) {
    return arg1 + arg2
  } else if (typeof arg1 === `number` && typeof arg2 === `number`) {
    return arg1 + arg2
  }
}
複製程式碼

TypeScript 中的函式過載也只是多個函式的宣告,具體的邏輯還需要自己去寫,他並不會真的將你的多個重名 function 的函式體進行合併

多個函式的順序問題

想象一下,如果我們有一個函式,傳入Date型別的引數,返回其unix時間戳,如果傳入Object,則將物件的具體型別進行toString輸出,其餘情況則直接返回,這樣的一個函式應該怎麼寫?

僅做示例演示,一般正常人不會寫出這樣的函式…

function build (arg: any) {
  if (arg instanceof Date) {
    return arg.valueOf()
  } else if (typeof arg === `object`) {
    return Object.prototype.toString.call(arg)
  } else {
    return arg
  }
}
複製程式碼

但是這樣的函式過載在宣告的順序上就很有講究了,一定要將精確性高的放在前邊:

// 這樣是一個錯誤的示例,因為無論怎樣呼叫,返回值都會是`any`型別
function build(arg: any): any
function build(arg: Object): string
function build(arg: Date): number
複製程式碼

因為TypeScript在查詢到一個函式過載的宣告以後就會停止不會繼續查詢,any是一個最模糊的範圍,而Object又是包含Date的,所以我們應該按照順序從小到大進行排列:

function build(arg: Date): number
function build(arg: Object): string
function build(arg: any): any

// 這樣在使用的時候才能得到正確的型別提示
const res1 = build(new Date()) // number
const res2 = build(() => { })  // string
const res3 = build(true)       // any
複製程式碼

一些不需要函式過載的場景

函式過載的意義在於能夠讓你知道傳入不同的引數得到不同的結果,如果傳入的引數不同,但是得到的結果(型別)卻相同,那麼這裡就不要使用函式過載(沒有意義)。

如果函式的返回值型別相同,那麼就不需要使用函式過載

function func (a: number): number
function func (a: number, b: number): number

// 像這樣的是引數個數的區別,我們可以使用可選引數來代替函式過載的定義
function func (a: number, b?: number): number
// 注意第二個引數在型別前邊多了一個`?`

// 亦或是一些引數型別的區別導致的
function func (a: number): number
function func (a: string): number

// 這時我們應該使用聯合型別來代替函式過載
function func (a: number | string): number
複製程式碼

Interface

interface是在TypeScript中獨有的,在JavaScript並沒有interface一說。
因為interface只是用來規定實現它的class對應的行為,沒有任何實質的程式碼,對於指令碼語言來說這是一個無效的操作

在語法上與class並沒有什麼太大的區別,但是在interface中只能夠進行成員屬性的宣告,例如function只能夠寫具體接收的引數以及返回值的型別,並不能夠在interface中編寫具體的函式體,同樣的,針對成員屬性也不能夠直接在interface中進行賦值:

// 這是一個錯誤的示例
interface PersonalIntl {
  name: string = `Niko`

  sayHi (): string {
    return this.name
  }
}

// 在 interface 中只能存在型別宣告
interface PersonalIntl {
  name: string

  sayHi (): string
}
複製程式碼

其實在一些情況下使用interface與普通的type定義也沒有什麼區別。
比如我們要匯出一個存在nameage兩個屬性的物件:

// types/personal.d.ts
export interface PersonalIntl {
  name: string
  age:  number
}

// index.d.ts
import { PersonalIntl } from `./types/personal`

const personal: PersonalIntl = {
  name: `Niko`,
  age:  18,
}
複製程式碼

如果將interface換成type定義也是完全沒問題的:

// types/personal.d.ts
export type PersonalIntl = {
  name: string
  age:  number
}
複製程式碼

這樣的定義在基於上邊的使用是完全沒有問題的,但是這樣也僅僅適用於Object字面量的宣告,沒有辦法很好的約束class模式下的使用,所以我們採用interface來約束class的實現:

import { PersonalIntl } from `./types/personal`

class Personal implements PersonalIntl {
  constructor(public name: string, public age: number) { }

  // 上邊的簡寫與下述程式碼效果一致

  public name: string
  public age: number

  constructor (name: string, age: number) {
    this.name = name
    this.age = age
  }
}

const personal = new Personal(`niko`, 18)
複製程式碼

關於函式成員宣告的一些疑惑

首先,在介面中有兩種方式可以定義一個函式,一個被定義在例項上,一個被定義在原型鏈上。
兩種宣告方式如下:

interface PersonalIntl {
  func1 (): any      // 原型鏈方法

  func2: () => any   // 例項屬性
}
複製程式碼

但是我們在實現這兩個屬性時其實是可以互相轉換的,並沒有強要求必須使用哪種方式:

class Personal implements PersonalIntl {
  func1 () {
    console.log(this)
  }

  func2 = () => {
    console.log(this)
  }
}
複製程式碼

其實這兩者在編譯後的JavaScript程式碼中是有區別的,並不清楚這是一個bug還是設計就是如此,類似這樣的結構:

var Personal = /** @class */ (function () {
    function Personal() {
        var _this = this;
        this.func2 = function () {
            console.log(_this);
        };
    }
    Personal.prototype.func1 = function () {
        console.log(this);
    };
    return Personal;
}());
複製程式碼

所以在使用的時候還是建議最好按照interface定義的方式來建立,避免一些可能存在的奇奇怪怪的問題。

介面宣告的自動合併

因為interfaceTypeScript特有的,所以也會有一些有意思的特性,比如相同命名的interface會被自動合併:

interface PersonalIntl {
  name: string
}

interface PersonalIntl {
  age: number
}

class Personal implements PersonalIntl {
  name = `Niko`
  age = 18
}
複製程式碼

不要在 interface 中使用函式過載

interface中使用函式過載,你會得到一個錯誤的結果,還是拿上邊的build函式來說,如果在interface中宣告,然後在class中實現,那麼無論怎樣呼叫,返回值的型別都會認為是any

所以正確的做法是在class中宣告過載,在class中實現,interface中最多隻定義一個any,而非三個過載。

class Util implements UtilIntl {
  build(arg: Date): number
  build(arg: Object): string
  build(arg: any): any

  build(arg: any) {
    if (arg instanceof Date) {
      return arg.valueOf()
    } else if (typeof arg === `object`) {
      return Object.prototype.toString.call(arg)
    } else {
      return arg
    }
  }
}
複製程式碼

小結

有關TypeScript宣告型別宣告相關的目前就總結了這些比較常用的,歡迎小夥伴們進行補充。

在之前的版本中有存在modulenamespace的定義,但是目前來看,好像更推薦使用 ES-Modules 版本的 import/export來實現類似的功能,而非自定義的語法,所以就略過了這兩個關鍵字相關的描述

官方文件中有針對如何編寫宣告檔案的模版,可以參考:傳送陣

參考資料

相關文章