TypeScript module import declare d.ts

[TypeScript] 模組與宣告檔

TS 系列最後一篇。前面把型別系統與窄化打通了,這篇收尾在工程面:怎麼跨檔案 import / export、怎麼讓沒有型別的 JS 套件變得型別安全、怎麼擴充全域與第三方型別,以及寫 library 時該怎麼對外暴露 .d.ts

ESM 為主

現代 TS 一律以 ES Module 為主。判斷標準很簡單:一個檔案只要有 importexport,就是模組,有自己的 scope;都沒有就是 script,跟其他 script 共享全域。

// math.ts
export function add(a: number, b: number) { return a + b }
export const PI = 3.14159

export default class Calculator {}

// 重新命名匯出
export { add as plus }
// index.ts
import Calc, { add, PI } from './math'
import { add as plus } from './math'
import * as math from './math'

export default 算是歷史包袱,多數團隊偏好 named export——IDE 重構友善,也避免同一個東西在不同檔案被取不同名字。

type-only import / export

只拿來標型別、執行期根本不需要存在的東西,加 type 標明:

import type { User } from './models'
import { type User, getUser } from './models'   // 混合:getUser 是值,User 是型別

export type { User }

好處有三個:

  • tsc 編譯時會把 type-only import 完全擦掉,不留 import 語句
  • 不會發生「只是要個型別,卻意外觸發那個模組的 runtime side effect」
  • 有些 build 工具(esbuild、isolatedModules 模式)本來就依賴明確的 type import

建議開 verbatimModuleSyntax: true,強制所有純型別匯入都加 type。為什麼要強制?因為純型別 import 不加 type 的話,某些編譯工具(esbuild、swc、bundler)會誤把它當值 import 保留下來,等到執行期找不到那個符號就炸。開了之後規則變得很乾淨:import { T } 一律當值 import、import type { T } 一律編譯期消失,沒有灰色地帶。

路徑與 moduleResolution

moduleResolution 常用的就兩個值:

適用
bundler給 Vite / esbuild / webpack 等打包器(不要求副檔名)
nodenext給 Node 直接跑(必須加 .js 副檔名,即使原始檔是 .ts

Node 模式下:

// 在 .ts 裡也要寫 .js
import { add } from './math.js'

.ts 裡寫 .js 看起來很怪,但理由是這樣:Node 的 ESM loader 只認 .js(或顯式 .mjs),完全不知道 TS 的存在;而 TS 編譯時又不會改寫 import 路徑字串。所以路徑必須預先寫成編譯後實際存在的檔名——這是「TS 不做路徑改寫」這個設計的必然結果。

路徑別名(paths)

tsconfig.json 設定:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  }
}
import Button from '@/components/Button'

注意 TS 只負責型別檢查層的解析,執行期還要靠打包工具或額外 loader(例如 tsxtsconfig-paths,Vite 則自帶支援)才真的跑得起來。

宣告檔(.d.ts)

.d.ts 只含型別宣告、沒有實作。tsc 編譯 .ts 時可以自動產生對應的 .d.ts(要開 declaration: true)。

// math.d.ts
export declare function add(a: number, b: number): number
export declare const PI: number

declare 的意思是:「這東西在執行期會存在,我只是跟編譯器報告它的型別」。

主要用在三個地方:

  1. 寫 library 時對外暴露 API 型別
  2. 給沒有型別的 JS 套件補型別
  3. 擴充全域型別

給 JS 套件補型別

假設 legacy-lib 是個沒型別的 JS 套件,自己幫它補一份。

src/types/legacy-lib.d.ts

declare module 'legacy-lib' {
  export function init(opts: { debug?: boolean }): void
  export const version: string
}

確認 tsconfig.jsoninclude 有涵蓋 src/types,之後就能型別安全地 import:

import { init, version } from 'legacy-lib'

如果不在意型別細節,也有偷懶寫法:

declare module 'some-untyped-lib'
// 整個套件當 any

ambient module(萬用宣告)

要 import 非 JS / TS 的資源(圖片、CSS)時,也要先告訴 TS 那是什麼型別,配合打包工具用:

// images.d.ts
declare module '*.png' {
  const src: string
  export default src
}

declare module '*.svg?component' {
  import type { SvelteComponent } from 'svelte'
  const C: SvelteComponent
  export default C
}
import logo from './logo.png'         // logo: string
import Icon from './icon.svg?component'

擴充全域 / 第三方型別

靠 interface 合併(前面 interface 那篇提過)就能擴充既有的型別。擴充全域:

// global.d.ts
declare global {
  interface Window {
    myApp: {
      version: string
    }
  }
}

export {}   // 必須有 export 才會被當成模組,declare global 才有效
window.myApp.version   // ✅ 有型別

擴充第三方也是同一招,例如幫 Express 的 Request 加欄位:

// express.d.ts
import 'express'

declare module 'express' {
  interface Request {
    user?: { id: number; name: string }
  }
}

三斜線指令(很少用,知道有就好)

/// <reference types="node" />
/// <reference path="./other.d.ts" />

這是舊式做法,現代 TS 已經用 tsconfigtypes / typeRoots 設定取代。

tsconfig 與型別來源

{
  "compilerOptions": {
    "types": ["node", "jest"],   // 只載入這些 @types/*,其餘都不載
    "typeRoots": ["./node_modules/@types", "./src/types"]
  }
}

不寫 types 時,TS 預設載入 node_modules/@types/* 底下的所有型別,可能混進不想要的東西。

寫 library 的 export 設計

package.json 之於 TS library,就像 Cargo.toml 之於 Rust。要對外提供型別,至少要有這些設定。

package.json

{
  "name": "my-lib",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

tsconfig.json

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist"
  }
}

exports 欄位讓 Node 與打包工具能正確對應到型別與實作。

一個專案常見的型別結構

src/
├── index.ts
├── lib/
│   └── client.ts
├── models/
│   └── user.ts        // export interface User {}
└── types/
    ├── env.d.ts       // declare namespace NodeJS { interface ProcessEnv {} }
    ├── images.d.ts
    └── global.d.ts

把 ambient declarations(沒有 export 的 *.d.ts)集中放在 types/ 資料夾,好找也好管理。

速查

場景工具
模組對外公開值export ...
模組對外公開型別export type ...
純型別匯入import type ...
沒型別的 JS 套件declare module 'name' {}
非 JS 資源(圖片、CSS)declare module '*.ext'
擴充全域declare global { interface Window {} }
擴充第三方型別在第三方模組 declaration merging

TS 系列到此告一段落。八篇下來——環境、基本型別、interface/type、函式、泛型、utility types、窄化、模組——剛好是寫真實 TS 程式所需的最小完整地圖。後續的 decorator、TS plugin、各框架的延伸都是分支,之後有需要再展開。

Latest Updates

  • 2026.06.11 Content updated