在 《從 JavaScript 到 TypeScript 系列》 文章我們已經學習了 TypeScript 相關的知識。
TypeScript 的核心在於靜態類型,我們在編寫 TS 的時候會定義很多的類型,但是主流的庫都是 JavaScript 編寫的,並不支持類型系統。那么如何讓這些第三方庫也可以進行類型推導呢?
這篇文章我們來講解 JavaScript 和 TypeScript 的靜態類型交叉口 —— 類型定義文件。
這篇文章首發於我的個人博客 《聽說》。
前端開發 QQ 群:377786580
類型定義文件
在 TypeScript 中,我們可以很簡單的,在代碼編寫中定義類型:
interface IBaseModel {
say(keys: string[] | null): object
}
class User implements IBaseModel {
name: string
constructor (name: string) {
this.name = name
}
}
但是主流的庫都是 JavaScript 編寫的,TypeScript 身為 JavaScript 的超集,自然需要考慮到如何讓 JS 庫也能定義靜態類型。
TypeScript 經過了一系列的摸索,先后提出了 tsd(已廢棄)、typings(已廢棄),最終在 TypeScript 2.0 的時候重新整理了類型定義,提出了 DefinitelyTyped。
DefinitelyTyped 就是讓你把 "類型定義文件(*.d.ts)",發布到 npm 中,配合編輯器(或插件),就能夠檢測到 JS 庫中的靜態類型。
類型定義文件的以 .d.ts 結尾,里面主要用來定義類型。
例如這是 jQuery 的類型定義文件 中一段代碼(為了方便理解做了一些改動)
// 定義 jQuery 需要用到的類型命名空間
declare namespace JQuery {
// 定義基本使用的類型
type Selector = string;
type TypeOrArray<T> = T | T[];
type htmlString = string;
}
// 定義 jQuery 接口,jquery 是一個 包含 Element 的集合
interface JQuery<TElement extends Node = HTMLElement> extends Iterable<TElement> {
length: number;
eq(index: number): this;
// 重載
add(selector: JQuery.Selector, context: Element): this;
add(selector: JQuery.Selector | JQuery.TypeOrArray<Element> | JQuery.htmlString | JQuery): this;
children(selector?: JQuery.Selector): this;
css(propertyName: string): string;
html(): string;
}
// 對模塊 jquery 輸出接口
declare module 'jquery' {
// module 中要使用 export = 而不是 export default
export = jQuery;
}
類型定義
*.d.ts 編寫起來非常簡單,經過 TypeScript 良好的靜態類型系統洗禮過后,語法學習成本非常低。
我們可以使用 type 用來定義類型變量:
// 基本類型
type UserName = string
// 類型賦值
type WebSite = string
type Tsaid = WebSite
可以看到 type 其實可以定義各種格式的類型,也可以和其他類型進行組合。
// 對象
type User = {
name: string;
age: number;
website: WebSite;
}
// 方法
type say = (age: number) => string
// 類
class TaSaid {
website: string;
say: (age: number) => string;
}
當然,我們也可以使用 interface 定義我們的復雜類型,在 TS 中我們也可以直接定義 interface:
interface Application {
init(): void
get(key: string): object
}
interface 和 type(或者說 class) 很像。
但是 type 的含義是定義自定義類型,當 TS 提供給你的基礎類型都不滿足的時候,可以使用 type 自由組合出你的新類型,而 interface 應該是對外輸出的接口。
type 不可以被繼承,但 interface 可以:
interface BaseApplication {
appId: number
}
export interface Application extends BaseApplication {
init(): void
get(key: string): object
}
declare
declare 可以創建 *.d.ts 文件中的變量,declare 只能作用域最外層:
declare var foo: number;
declare function greet(greeting: string): void;
declare namespace tasaid {
// 這里不能 declare
interface blog {
website: 'http://tasaid.com'
}
}
基本上頂層的定義都需要使用 declare, class 也是:
declare class User {
name: string
}
namespace
為防止類型重復,使用 namespace 用於划分區域塊,分離重復的類型,頂層的 namespace 需要 declare 輸出到外部環境,子命名空間不需要 declare。
// 命名空間
declare namespace Models {
type A = number
// 子命名空間
namespace Config {
type A = object
type B = string
}
}
type C = Models.Config.A
組合定義
上面我們只演示了一些簡單的類型組合,生產環境中會包含許多復雜的類型定義,這時候我們就需要各種組合出強大的類型定義:
動態屬性
有些類型的屬性名是動態而未知的,例如:
{
'10086': {
name: '中國移動',
website: 'http://www.10086.cn',
},
'10010': {
name: '中國聯通',
website: 'http://www.10010.com',
},
'10000': {
name: '中國電信',
website: 'http://www.189.cn'
}
}
我們可以使用動態屬性名來定義類型:
interface ChinaMobile {
name: string;
website: string;
}
interface ChinaMobileList {
// 動態屬性
[phone: string]: ChinaMobile
}
類型遍歷
當你已知某個類型范圍的時候,可以使用 in 和 keyof 來遍歷類型,例如上面的 ChinaMobile 例子,我們可以使用 in 來約束屬性名必須為三家運營商之一:
type ChinaMobilePhones = '10086' | '10010' | '10000'
interface ChinaMobile {
name: string;
website: string;
}
// 只能 type 使用, interface 無法使用
type ChinaMobileList = {
// 遍歷屬性
[phone in ChinaMobilePhones]: ChinaMobile
}
我們也可以用 keyof 來約定方法的參數
export type keys = {
name: string;
appId: number;
config: object;
}
class Application {
// 參數和值約束范圍
set<T extends keyof keys>(key: T, val: keys[T])
get<T extends keyof keys>(key: T): keys[T]
}

集成發布
有兩種主要方式用來發布類型定義文件到 npm:
- 與你的 npm 包捆綁在一起(內置類型定義文件)
- 發布到 npm 上的 @types organization
前者,安裝完了包之后會自動檢測並識別類型定義文件。
后者,則需要通過 npm i @types/xxxx 安裝,這就是我們前面所說的 DefinitelyTyped ,用於擴展 JS 庫的類型聲明。
內置類型定義文件
內置類型定義就是把你的類型定義文件和 npm 包一起發布,一般來說,類型定義文件都放在包根目錄的 types 目錄里,例如 vue:
如果你的包有一個主 .js 文件,需要在 package.json 里指定主類型定義文件。
設置 types 或 typeings 屬性指向捆綁在一起的類型定義文件。 例如包目錄如下:
├── lib
│ ├── main.js
│ └── main.d.ts # 類型定義文件
└── package.json
// pageage.json
{
"name": "demo",
"author": "demo project",
"version": "1.0.0",
"main": "./lib/main.js",
// 定義主類型定義文件
"types": "./lib/main.d.ts"
}
如果主類型定義文件名是 index.d.ts 並且位置在包的根目錄里,就不需要使用 types 屬性指定了。
├── lib
│ └── main.js
├── index.d.ts # 類型定義文件
└── package.json
如果你發的包中,package.json 中使用了 files 字段的話(npm 會根據 files 配置的規則決定發布哪些文件),則需要手動把類型定義文件加入:
// pageage.json
{
"files": [
"index.js",
"*.d.ts"
]
}
如果只發二級目錄的話,把類型定義文件放到對應的二級目錄下即可:
import { default as App } from 'demo/app'
發布到 @types organizatio
發布到 @types organizatio 的包表示源包沒有包含類型定義文件,第三方/或原作者定義好類型定義文件之后,發布到 @types 中。例如 @types/express。
根據 DefinitelyTyped 的規則,和編輯器(和插件) 自動檢測靜態類型。
@types 下面的包是從 DefinitelyTyped 里自動發布的,通過 types-publisher 工具。
如果想讓你的包發布為 @types 包,需要提交一個 pull request 到 https://github.com/DefinitelyTyped/DefinitelyTyped。
在這里查看詳細信息 contribution guidelines page。
如果你正在使用 TypeScript,而使用了一些 JS 包並沒有對應的類型定義文件,可以編寫一份然后提交到 @types。
贈人玫瑰,手留余香。
發布到 @types organizatio 的包可以通過 TypeSearch 搜索檢索,使用 npm install --save-dev @types/xxxx 安裝:

更多細節請參閱 DefinitelyTyped。
其他
module
通常來說,如果這份類型定義文件是 JS 庫自帶的,那么我們可以直接導出模塊:
interface User {}
export = User
而如果這份類型定義文件不是 JS 庫自帶的,而是第三方的,則需要使用 module 進行關聯。
例如 jquery 發布的 npm 包中不包含 *.d.ts 類型定義文件,jquery 的類型定義文件發布在了 @types/jquery,所以類型定義文件中導出類型的時候,需要關聯模塊 jquery,意思就是我專門針對這個包做的類型定義:
interface jQuery {}
declare module 'jquery' {
// module 中要使用 export = 而不是 export default
export = jQuery;
}
從而解決了一些主流的 JS 庫發布的 npm 包中沒有類型定義文件,但是我們可以用第三方類型定義文件為這些庫補充類型。
風格
經過一系列探索,個人比較推薦下面的編寫風格,先看目錄:
types
├── application.d.ts
├── config.d.ts
├── index.d.ts # 入口模塊
└── user.d.ts

入口模塊主要做這些事情:
- 定義命名空間
- 導出和聚合子模塊
主出口文件 index.d.ts:
import * as UserModel from './user'
import * as AppModel from './application'
import * as ConfigModel from './config'
declare namespace Models {
export type User = UserModel.User;
export type Application = AppModel.Application;
// 利用 as 抹平爭議性變量名
export type Config = ConfigModel.Config;
}
子模塊無需定義命名空間,這樣外部環境 (types 文件夾之外) 則無法獲取子模塊類型,達到了類型封閉的效果:
export interface User {
name: string;
age: number
}
