Node.js 24 與 TypeScript:2026 年免建置開發真的可行嗎?

Node.js 技術相關|TypeScript 原生支援實務

Node.js 24 正式將 TypeScript 的 type stripping 從 experimental 標記為穩定功能。這代表你可以直接 node app.ts 而不需要任何編譯步驟。但「可以跑」和「production 可用」之間還有一段距離。這篇文章從實務角度拆解:哪些情境可以真的免建置、哪些仍然需要 tsc 或 bundler、以及團隊該怎麼規劃遷移。

先講結論

  • 可以免建置的情境 — CLI 工具、腳本、cron job、內部 API 服務、開發階段的快速原型。這些場景用 node app.ts 直接跑,開發體驗大幅提升。
  • 仍然需要建置的情境 — 需要 enumnamespacedecorator(舊語法)、const enum 等需要語義轉換的 TypeScript 功能;需要產出 npm 套件供他人使用;前端打包。
  • 團隊遷移策略 — 從開發腳本和測試開始,逐步擴大到內部服務,最後才考慮 production API。不要一步到位。

Node.js 24 的 TypeScript 支援到底做了什麼

Node.js 的 TypeScript 支援採用的是 type stripping(型別剝離)策略,而不是完整的 TypeScript 編譯。理解這個區別很重要,因為它直接決定了哪些功能能用、哪些不能用。

Type stripping 的工作方式很簡單:在執行前,Node.js 使用 @anthropic-ai/swc 的輕量版本,把 TypeScript 原始碼中的型別標註「擦除」成空白,保留原始的 JavaScript 程式碼結構。這意味著:

  • 不做型別檢查 — Node.js 不會檢查你的型別是否正確。它只是把型別標註移除,然後執行剩下的 JavaScript。型別檢查仍然是 IDE 和 CI 中 tsc --noEmit 的工作。
  • 不做語法轉換 — 任何需要改變 JavaScript 輸出結構的 TypeScript 功能都不支援。型別標註可以被無痛移除,但 enum 需要被轉換成物件,這就超出 type stripping 的範圍了。
  • source map 自動對應 — 因為只是擦除型別(用空白取代),行號和列號不會改變,所以錯誤堆疊追蹤天然就是準確的,不需要額外的 source map。
# Node.js 24 直接執行 TypeScript
# 不需要任何 flag — 已是穩定功能
node app.ts

# 搭配 watch mode 做開發
node --watch app.ts

# 搭配內建測試執行器
node --test tests/*.ts

可以免建置直接跑的 TypeScript 功能

以下這些 TypeScript 功能都是「純型別標註」,可以被安全地剝離而不影響程式行為:

型別標註與介面

// 全部可以直接 node app.ts 執行

// 型別標註
const port: number = 3000;
const name: string = "nodejs-tw";

// 介面定義
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

// 泛型函式
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

// type alias
type RequestHandler = (req: Request, res: Response) => Promise<void>;

// union / intersection types
type Result<T> = { ok: true; data: T } | { ok: false; error: string };

// utility types
type UserDTO = Pick<User, "id" | "name">;
type PartialUser = Partial<User>;

型別斷言與型別守衛

// as 斷言 — 剝離後就是普通賦值
const input = event.body as string;

// 型別守衛 — 函式本體是純 JavaScript,型別標註被剝離
function isUser(obj: unknown): obj is User {
  return typeof obj === "object" && obj !== null && "id" in obj;
}

// satisfies 運算子 — 純型別層面,直接剝離
const config = {
  port: 3000,
  host: "localhost",
} satisfies ServerConfig;

類別中的型別標註

// class 中的型別標註可以被剝離
class UserService {
  private db: Database;          // 型別標註被剝離
  readonly logger: Logger;       // readonly 修飾詞被剝離

  constructor(db: Database, logger: Logger) {
    this.db = db;
    this.logger = logger;
  }

  async getUser(id: string): Promise<User | null> {  // 回傳型別被剝離
    return this.db.query("SELECT * FROM users WHERE id = $1", [id]);
  }
}

仍然需要 tsc / bundler 的功能

以下功能會讓 Node.js 的 type stripping 失敗或產生錯誤行為,因為它們需要語義層面的轉換:

enum(列舉)

// 不能直接用 node 跑 — enum 需要被轉換成 JavaScript 物件
enum Status {
  Active = "active",
  Inactive = "inactive",
  Suspended = "suspended",
}

// 替代方案:用 as const 物件(可以被 type stripping 處理)
const Status = {
  Active: "active",
  Inactive: "inactive",
  Suspended: "suspended",
} as const;

type Status = typeof Status[keyof typeof Status];
// 結果是 "active" | "inactive" | "suspended"

const enum

// 不能用 — const enum 需要在編譯時內聯值
const enum Direction {
  Up = 0,
  Down = 1,
}
// tsc 會把 Direction.Up 直接替換成 0
// type stripping 做不到這件事

// 替代方案:同樣用 as const
const Direction = { Up: 0, Down: 1 } as const;

namespace(命名空間)

// 不能用 — namespace 需要被轉換成 IIFE
namespace Validation {
  export function isEmail(s: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
  }
}

// 替代方案:用 ES module
// validation.ts
export function isEmail(s: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}

舊式 Decorator(experimentalDecorators)

// 舊式 decorator 不能用 — 需要 tsc 轉換
// (指 tsconfig 中 experimentalDecorators: true 的語法)
@Controller("/users")
class UserController {
  @Get("/:id")
  getUser(@Param("id") id: string) { ... }
}

// 注意:TC39 Stage 3 的新式 decorator 語法
// 是 JavaScript 原生提案,不依賴 tsc 轉換
// 但目前 Node.js 24 的 V8 尚未完整支援

parameter properties

// 不能用 — 需要轉換成 constructor 中的賦值
class UserService {
  constructor(
    private readonly db: Database,  // 這是 TypeScript 獨有的簡寫語法
    private readonly logger: Logger
  ) {}
}

// 替代方案:明確寫出賦值
class UserService {
  private db: Database;
  private logger: Logger;

  constructor(db: Database, logger: Logger) {
    this.db = db;
    this.logger = logger;
  }
}

功能支援速查表

TypeScript 功能免建置可用替代方案
型別標註 / interface / type可以
泛型可以
as 斷言 / satisfies可以
型別守衛(is)可以
utility types(Pick, Omit 等)可以
enum不行as const 物件
const enum不行as const 物件
namespace不行ES module
experimentalDecorators不行等待 TC39 decorator 支援
parameter properties不行明確 constructor 賦值
import type / type-only import可以
paths / baseUrl(tsconfig)不行Node.js subpath imports

適用與不適用情境

適合免建置的情境

  • CLI 工具與腳本 — 資料庫遷移腳本、資料處理腳本、DevOps 自動化腳本。這類工具通常不需要 enum 或 decorator,直接 node migrate.ts 就能跑。
  • Cron job / 背景任務 — 排程任務、佇列消費者。型別標註提供開發時的安全感,執行時直接剝離。
  • 內部 API 服務 — 用 Express、Fastify 或 Hono 寫的內部微服務。不需要打包給前端,不需要產出 .d.ts 檔案。
  • 原型開發與 PoC — 快速驗證想法時,少一個建置步驟就是少一個摩擦力。
  • 測試 — 搭配 node --test tests/*.ts,測試檔案也可以用 TypeScript 寫,不需要 ts-jest 或 vitest。

仍然需要建置步驟的情境

  • 發佈 npm 套件 — 套件消費者可能使用任何 runtime 和工具鏈。你需要提供 .js + .d.ts 檔案。
  • 使用 NestJS 等依賴 decorator 的框架 — NestJS 核心架構依賴 experimentalDecorators,目前無法免建置。
  • 大量使用 enum 的既有專案 — 如果程式碼中有上百個 enum,遷移成 as const 的成本太高,不如繼續用 tsc。
  • 前端應用 — React/Vue/Angular 應用本來就需要 bundler 處理 JSX/SFC/樣式,TypeScript 只是其中一環。
  • 需要 path alias 的專案 — tsconfig 的 paths 設定不被 Node.js 支援。需要改用 Node.js 原生的 imports 欄位或繼續用 bundler。

團隊遷移清單

如果你的團隊決定開始在部分場景採用免建置開發,以下是一個務實的遷移清單:

第一步:盤點程式碼中的不相容功能

# 掃描專案中使用了哪些不相容的 TypeScript 功能
# 檢查 enum 使用
grep -rn "^[[:space:]]*enum " src/ --include="*.ts" | wc -l

# 檢查 const enum
grep -rn "const enum" src/ --include="*.ts" | wc -l

# 檢查 namespace
grep -rn "^[[:space:]]*namespace " src/ --include="*.ts" | wc -l

# 檢查 experimentalDecorators
grep -n "experimentalDecorators" tsconfig.json

# 檢查 parameter properties
grep -rn "constructor.*private\|constructor.*protected\|constructor.*readonly" src/ --include="*.ts" | wc -l

# 檢查 paths alias
grep -n '"paths"' tsconfig.json

第二步:調整 tsconfig.json

// tsconfig.json — 免建置開發建議設定
{
  "compilerOptions": {
    // 目標設定
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",

    // 只做型別檢查,不輸出檔案
    "noEmit": true,

    // 嚴格模式(開發時由 IDE 和 CI 執行型別檢查)
    "strict": true,
    "skipLibCheck": true,

    // 避免使用不相容的功能
    "verbatimModuleSyntax": true,  // 強制使用 import type

    // 不要啟用這些
    // "experimentalDecorators": true,  // 不相容
    // "emitDecoratorMetadata": true,   // 不相容
    // "paths": { ... },               // 不相容
  }
}

第三步:從低風險場景開始

建議的推進順序:

  1. 開發用腳本 — 把 ts-node scripts/seed.ts 改成 node scripts/seed.ts
  2. 測試 — 用 node --test 取代 ts-jest,少裝一個依賴。
  3. 開發模式 — 把 tsx watch src/server.ts 改成 node --watch src/server.ts
  4. 新的內部服務 — 新專案從第一天就免建置。
  5. 既有 production 服務 — 最後才考慮。確認沒有不相容功能後再遷移。

CI/CD 注意事項

免建置開發改變了 CI/CD 的流程設計。以下是需要調整的地方:

型別檢查必須是獨立步驟

以前 tsc 同時做型別檢查和編譯輸出。現在你跳過了編譯,型別檢查就必須明確放進 CI pipeline:

# GitHub Actions 範例
name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "24"

      - run: npm ci

      # 型別檢查 — 必須獨立執行
      - name: Type check
        run: npx tsc --noEmit

      # Lint
      - name: Lint
        run: npx eslint src/

      # 測試 — 直接用 node 跑 .ts 測試檔
      - name: Test
        run: node --test tests/**/*.ts

      # 不需要 build 步驟了!

Docker 映像檔簡化

# 免建置的 Dockerfile — 更簡潔
FROM node:24-slim
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

# 直接複製 .ts 原始碼,不需要 build 步驟
COPY src/ ./src/

# 直接執行 .ts 檔案
CMD ["node", "src/server.ts"]

# 對比傳統需要建置的 Dockerfile:
# FROM node:24-slim AS builder
# WORKDIR /app
# COPY package*.json ./
# RUN npm ci
# COPY . .
# RUN npm run build     <-- 不需要了
#
# FROM node:24-slim
# WORKDIR /app
# COPY --from=builder /app/dist ./dist   <-- 不需要了
# COPY --from=builder /app/node_modules ./node_modules
# CMD ["node", "dist/server.js"]

注意事項

  • 映像檔大小.ts 原始碼包含型別標註,檔案比編譯後的 .js 稍大。對大多數應用來說差異不大,但如果你嚴格控管映像檔大小,需要評估。
  • 啟動時間 — 每次啟動都需要做 type stripping。在冷啟動敏感的 serverless 環境,這個額外開銷(通常數十毫秒)可能需要測量。如果啟動時間是關鍵指標,production 仍然建議預編譯。
  • source map — type stripping 保持行號不變,所以 error stack trace 天然正確。但如果你用了 --experimental-transform-types(啟用語法轉換以支援 enum 等功能),就需要另外處理 source map。
  • devDependencies 中的 tsc — 即使不用 tsc 編譯,你仍然需要 typescript 套件來做 CI 中的型別檢查和 IDE 支援。不要把它從 devDependencies 移除。

實戰範例:免建置的 Express API 服務

# 初始化專案
mkdir my-api && cd my-api
npm init -y
npm install express
npm install -D typescript @types/express @types/node

# 建立 tsconfig.json
cat << 'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "verbatimModuleSyntax": true
  }
}
EOF
// src/server.ts
import express, { type Request, type Response } from "express";

interface CreateUserBody {
  name: string;
  email: string;
}

type ApiResponse<T> = {
  ok: true;
  data: T;
} | {
  ok: false;
  error: string;
};

const app = express();
app.use(express.json());

const users: Map<string, CreateUserBody & { id: string }> = new Map();

app.post("/users", (req: Request, res: Response) => {
  const body = req.body as CreateUserBody;

  if (!body.name || !body.email) {
    const response: ApiResponse<never> = { ok: false, error: "name and email required" };
    res.status(400).json(response);
    return;
  }

  const id = crypto.randomUUID();
  const user = { id, ...body };
  users.set(id, user);

  const response: ApiResponse<typeof user> = { ok: true, data: user };
  res.status(201).json(response);
});

app.get("/users/:id", (req: Request, res: Response) => {
  const user = users.get(req.params.id);

  if (!user) {
    const response: ApiResponse<never> = { ok: false, error: "user not found" };
    res.status(404).json(response);
    return;
  }

  const response: ApiResponse<typeof user> = { ok: true, data: user };
  res.json(response);
});

app.listen(3000, () => {
  console.log("Server running on port 3000");
});
# 開發模式 — 直接跑,不需要任何建置
node --watch src/server.ts

# 型別檢查(IDE 會即時做,CI 也要做)
npx tsc --noEmit

# Production 啟動
node src/server.ts

常見問答

Q: 效能有差嗎?每次啟動都要做 type stripping?

Type stripping 的開銷很小(通常在數十毫秒等級),而且 Node.js 會快取處理結果。對長時間運行的服務來說,啟動時的一次性開銷完全可以忽略。但對 serverless 的冷啟動或需要頻繁重啟的場景,建議實測確認是否在可接受範圍內。

Q: 可以和現有的 tsc 建置流程並存嗎?

完全可以。最務實的做法是:開發和測試用 node app.ts 直接跑,CI 中用 tsc --noEmit 做型別檢查,production 部署你可以選擇直接跑 .ts 或繼續用 tsc 編譯後跑 .js。兩條路可以並行。

Q: ts-node 和 tsx 還需要嗎?

如果你的程式碼不使用 enum、namespace、parameter properties 等需要轉換的功能,Node.js 24 的原生支援可以完全取代 ts-node 和 tsx。少一個依賴就是少一個可能出問題的地方。

Q: 這跟 Bun / Deno 的 TypeScript 支援有什麼不同?

Bun 和 Deno 都支援完整的 TypeScript 語法轉換(包含 enum、decorator 等)。Node.js 刻意選擇了更保守的 type stripping 路線——只處理型別標註的剝離,不做語法轉換。這個設計決策的好處是實作更簡單、更可預測,缺點是支援的 TypeScript 功能較少。

總結

Node.js 24 的原生 TypeScript 支援不是要取代 tsc,而是讓你在不需要完整編譯流程的場景中,省下建置步驟帶來的摩擦。

務實的做法是:

  • 新的腳本、CLI 工具、內部服務 — 直接用 node app.ts,享受免建置的開發體驗。
  • 既有專案 — 先把開發腳本和測試遷移過去,漸進式降低對 ts-node / tsx 的依賴。
  • 需要 enum / decorator 的專案 — 繼續用 tsc,或者考慮逐步將 enum 改為 as const
  • CI/CD — 記得把 tsc --noEmit 加進 pipeline,型別檢查不能省。

免建置開發在 2026 年已經是可行的,但「可行」的前提是你了解它的邊界。知道什麼能用、什麼不能用,才能做出正確的技術決策。

相關資源