本文 AI 產出,尚未審核

TypeScript 與 JavaScript 整合(JS Interop)

主題:與既有 JS 程式共存


簡介

在現代前端開發中,TypeScript 已成為提升程式碼可讀性與安全性的首選語言。可是,大多數專案在導入 TypeScript 前,都已經有一大批 JavaScript 程式碼、第三方函式庫或是舊有的業務邏輯。直接把整個專案一次性改寫成 TypeScript,既耗時又容易產生錯誤。

因此,讓 TypeScript 與既有的 JavaScript 共存,成為了實務上最常見、也是最實用的需求。透過正確的設定與技巧,我們可以在不破壞現有功能的前提下,逐步將程式碼遷移到 TypeScript,享受到型別檢查、IDE 智慧提示等好處,同時保留 JavaScript 的彈性。

本文將從概念、實作、常見陷阱與最佳實踐,逐步說明如何在同一個專案裡安全、順暢地混合使用 TypeScript 與 JavaScript。


核心概念

1. 專案結構與編譯設定

目標 做法
允許 .js 檔案被編譯 tsconfig.json 中加入 allowJs: true
保留原有的 JavaScript 設定 checkJs: false(除非想要在 .js 中也啟用型別檢查)
分離輸出 使用 outDir 把編譯後的檔案放到 dist/,避免覆蓋原始 .js 檔案
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "allowJs": true,          // 允許編譯 .js 檔案
    "checkJs": false,         // 不對 .js 執行型別檢查
    "outDir": "./dist",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

小技巧:若想在部分 JavaScript 檔案中啟用型別檢查,只要在檔案開頭加上 // @ts-check,即可讓 TypeScript 嚴格檢查該檔案。


2. 宣告檔 (*.d.ts) 的角色

當 TypeScript 需要使用純 JavaScript 函式或物件時,型別資訊 必須由宣告檔提供。

  • 手寫宣告檔:適用於自家寫的工具函式或尚未有官方型別套件的第三方庫。
  • declare module:用於告訴編譯器「這是一個什麼樣的模組,但我不提供實作」。
// utils.d.ts
declare module "legacy-utils" {
  export function formatDate(date: Date, pattern?: string): string;
  export const VERSION: string;
}

這樣在 TypeScript 檔案裡 import { formatDate } from "legacy-utils" 時,就能得到完整的型別提示與錯誤檢查。


3. anyunknown 與型別斷言

在與不熟悉的 JavaScript 程式碼互動時,暫時使用 any 可以避免編譯錯誤,但會失去型別安全。

  • any:直接告訴編譯器「我什麼都不知道」。
  • unknown:比 any 更安全,必須先做型別檢查才能使用。
// 假設從全域變數取得第三方庫
declare const legacyLib: any;

// 正確做法:先檢查再斷言
if (typeof legacyLib?.doSomething === "function") {
  (legacyLib.doSomething as (arg: string) => void)("hello");
}

4. 混合模組系統:CommonJS vs ES Module

舊有的 JavaScript 常使用 module.exports(CommonJS),而 TypeScript 預設編譯成 ES Module。

  • 使用 esModuleInterop: true 能讓 import foo from "foo" 正常對應 module.exports = foo
  • 若需要保留 CommonJS 風格,可在 tsconfig.json 設定 module: "CommonJS",或在 TypeScript 檔案裡使用 import * as foo from "foo"
// legacy-lib.js (CommonJS)
module.exports = {
  greet(name) {
    return `Hi, ${name}!`;
  }
};
// ts 使用 ES Module 方式匯入
import legacyLib from "./legacy-lib";
console.log(legacyLib.greet("Tom"));

5. 漸進式遷移的策略

  1. 先從入口檔案改寫:把 main.js 改成 main.ts,並在裡面逐步 import 其他 .js
  2. 寫型別宣告:對常用的 JavaScript 函式或物件寫 .d.ts,讓後續的 TypeScript 檔案能安全使用。
  3. 逐檔轉換:挑選風險較低、單元測試覆蓋率高的檔案改寫成 .ts,同時保留原始 .js 作為備援。

程式碼範例

範例 1:從純 JavaScript 直接引用 TypeScript 函式

// src/util.js (純 JS)
function add(a, b) {
  return a + b;
}
module.exports = { add };
// src/util.ts (TS)
export function multiply(a: number, b: number): number {
  return a * b;
}
// src/main.js
const { add } = require("./util");          // 引入 JS
import { multiply } from "./util";          // 同時引入 TS (需要 Babel/TS-node)

console.log(add(2, 3));                     // 5
console.log(multiply(2, 3));                // 6

說明:透過 esModuleInterop,Node.js 可以同時支援 requireimport,讓舊有的 JavaScript 檔案無縫呼叫新寫的 TypeScript 函式。


範例 2:使用 declare 為第三方無型別庫提供型別

// src/global.d.ts
declare const _ : {
  map<T, U>(arr: T[], fn: (item: T) => U): U[];
  filter<T>(arr: T[], predicate: (item: T) => boolean): T[];
};
// src/legacy.js
// 假設全域載入了 lodash 的舊版
function processData(data) {
  return _.map(data, d => d * 2);
}
module.exports = { processData };
// src/processor.ts
import { processData } from "./legacy";

const raw = [1, 2, 3];
const result = processData(raw); // TypeScript 會根據 declare 的型別推斷 result 為 number[]
console.log(result);

重點:只要在 global.d.ts 中宣告全域變數 _,TypeScript 就能在編譯期間提供正確的型別資訊,避免使用 any


範例 3:使用 unknown 與型別保護 (type guard)

// src/api.ts
export async function fetchData(url: string): Promise<unknown> {
  const res = await fetch(url);
  return res.json(); // 回傳 unknown,呼叫端必須自行驗證
}
// src/main.ts
import { fetchData } from "./api";

function isUser(obj: any): obj is { id: number; name: string } {
  return (
    typeof obj === "object" &&
    obj !== null &&
    typeof obj.id === "number" &&
    typeof obj.name === "string"
  );
}

async function showUser() {
  const data = await fetchData("/api/user");
  if (isUser(data)) {
    console.log(`User: ${data.name} (ID: ${data.id})`);
  } else {
    console.error("取得的資料格式不符合預期");
  }
}
showUser();

說明unknown 讓 API 函式保持彈性,同時透過自訂的型別保護 (isUser) 在使用端取得完整的型別安全。


範例 4:混用 CommonJS 與 ES Module

// legacy/math.js (CommonJS)
module.exports = {
  pi: 3.14159,
  square(x) {
    return x * x;
  }
};
// src/math.ts (ES Module)
export const e = Math.E;
export function cube(x: number): number {
  return x * x * x;
}
// src/index.ts
import math from "./legacy/math";   // 透過 esModuleInterop 取得 default
import { e, cube } from "./math";

console.log(`π = ${math.pi}, e = ${e}`);
console.log(`square(4) = ${math.square(4)}`);
console.log(`cube(3) = ${cube(3)}`);

關鍵esModuleInterop: trueimport math from "./legacy/math" 直接對應到 module.exports,避免了 import * as math 的冗長寫法。


範例 5:逐步遷移 – 先在 JavaScript 加上 // @ts-check

// src/helpers.js
// @ts-check
/**
 * @param {number[]} arr
 * @returns {number}
 */
function sum(arr) {
  return arr.reduce((a, b) => a + b, 0);
}
module.exports = { sum };
// src/app.ts
import { sum } from "./helpers";

const total = sum([1, 2, 3]); // TypeScript 會根據 JSDoc 推斷參數型別
console.log(total);

優點:不必立刻改寫成 .ts,只要在 JS 檔案加入 JSDoc 或 // @ts-check,就能享受到型別提示,降低遷移成本。


常見陷阱與最佳實踐

陷阱 可能的問題 解法 / 最佳實踐
忘記 allowJs 編譯時會忽略 .js,導致找不到模組 確認 tsconfig.json 中已啟用 allowJs
過度使用 any 失去型別安全,錯誤只能在執行時才被發現 儘量使用 unknown + 型別保護或自行撰寫 .d.ts
全域變數未宣告 編譯時出現 Cannot find name 錯誤 global.d.ts 中宣告全域變數或使用 declare const
模組解析衝突 (CommonJS vs ES) import 失敗或得到 default{} 開啟 esModuleInterop,或使用 import * as 方式
編譯輸出覆蓋原始 JS 原有程式被覆寫,導致部署失敗 設定 outDir,並在 package.json 中使用 node dist/index.js 入口
忽略測試 遷移過程中不易發現行為差異 在每次把 .js 改成 .ts 前後,都執行單元測試,確保行為一致

最佳實踐

  1. 先寫型別宣告:對常用的 JavaScript 函式寫 .d.ts,即使只包含少量的 export,也能大幅提升開發體驗。
  2. 使用 JSDoc:在仍保留 .js 的檔案裡加入 JSDoc,讓 TypeScript 能自動推斷型別,降低遷移阻力。
  3. 分層編譯:把 TypeScript 的輸出放在 dist/,讓原始的 .js 保持不變,方便在開發與生產環境切換。
  4. 持續執行 lint & type-check:在 CI 中加入 tsc --noEmiteslint,提前捕捉型別錯誤。
  5. 漸進式遷移:不要期望一次改完所有檔案,先從「純工具函式」或「不涉及 UI」的模組開始,逐步擴散。

實際應用場景

場景 為何需要 JS/TS 共存 具體做法
大型遺留系統(如舊版管理介面) 完全重寫成本過高,且業務需求頻繁 先在入口 index.js 改寫為 index.ts,逐步把子模組遷移,同時使用 declare 為舊函式提供型別。
第三方未提供型別的庫(如老舊的 UI 套件) 想要在 TypeScript 中使用卻缺少 @types 手動建立 *.d.ts,或在使用處加上 // @ts-ignore 作為最後手段。
混合前端框架(React + jQuery) 部分 UI 仍依賴 jQuery,其他部分使用 React+TS tsconfig.json 開啟 allowJs,把 jQuery 相關檔案保留 .js,新寫的 React 元件使用 .tsx
Node.js 後端微服務 部分服務仍是舊的 Express 版,想逐步導入 TypeScript 在服務入口 server.js 改寫為 server.ts,其餘路由檔案先保留 .js,同時在 src/types/express.d.ts 補足缺失的型別。
跨平台共用程式庫(Web + React Native) 同一套工具函式要在瀏覽器與行動端共享,部分程式碼只能以純 JS 實作 把共用程式庫寫成 .ts,針對平台特有的實作保留 .js,並使用 declare module 讓 TypeScript 能正確辨識。

總結

在實務開發中,TypeScript 與 JavaScript 的共存不僅是可能的,更是提升專案品質、降低長期維護成本的關鍵策略。透過以下幾點,你可以順利完成漸進式遷移:

  1. 啟用 allowJs,讓編譯器接受 .js 檔案。
  2. 使用宣告檔 (*.d.ts) 為現有程式碼提供型別資訊。
  3. 善用 anyunknown 與型別斷言,在保留彈性的同時逐步收緊型別。
  4. 處理模組系統差異esModuleInterop 讓 CommonJS 與 ES Module 可以互相呼叫。
  5. 採取漸進式遷移策略,從入口檔案、工具函式開始,配合測試與 lint,確保功能不被破壞。

只要遵守上述最佳實踐,開發者就能在不犧牲既有功能的前提下,逐步享受到 TypeScript 帶來的靜態型別、IDE 補完與更可靠的程式碼基礎。未來,當所有核心模組都完成遷移,專案的可維護性、可擴充性將會大幅提升,開發效率與團隊協作也會因此受益。

祝你在 TypeScript 與 JavaScript 的共存之路上順利前行! 🎉