ExpressJS (TypeScript) 與 Prisma 整合
讓 TypeScript 與資料庫的互動變得安全、直覺且高效
簡介
在現代的 Node.js 生態系中,Express 仍是最受歡迎的 Web 框架,而 TypeScript 則為 JavaScript 帶來了靜態型別的保障。兩者結合後,開發者可以在寫 API 時即時得到編譯期的錯誤提示,極大提升開發效率與程式碼品質。
然而,僅有 Express 與 TypeScript 還不足以完整構築一套可靠的後端系統,資料庫 的存取是不可或缺的一環。傳統的 ORM(如 Sequelize、TypeORM)在型別推斷與開發體驗上常常會出現「型別不匹配」或「手動寫 SQL」的痛點。
Prisma 以「型別安全的資料庫工具」自居,與 TypeScript 天生契合。它的 schema‑first 設計、生成的 Prisma Client 以及自動化的 migration 機制,使得在 Express 中操作資料庫變得既簡潔又可靠。本文將從概念說明到實作範例,帶你一步步完成 Express + TypeScript + Prisma 的完整整合。
核心概念
1. Prisma 是什麼?
Prisma 是一套 下一代 ORM,核心由三個部分組成:
| 組件 | 功能 | 為什麼重要 |
|---|---|---|
| Prisma Schema | 用 .prisma 檔描述資料模型、資料庫連線與關聯 |
單一來源的模型定義,避免程式碼與資料庫不同步 |
| Prisma Migrate | 依照 schema 自動產生 migration,管理資料庫變更 | 減少手動撰寫 SQL,確保每次部署都有可追溯的變更紀錄 |
| Prisma Client | 依 schema 產生的 TypeScript 客戶端,提供型別安全的 CRUD API | 編譯期即能捕捉欄位名稱、關聯錯誤,提升開發體驗 |
2. 為什麼 Prisma 與 TypeScript 搭配是「最佳」?
- 型別安全:Prisma Client 會根據 schema 產生對應的 TypeScript 型別,IDE 能即時提示欄位、關聯、可選屬性等資訊。
- 自動補全:在 VS Code 中使用
prisma.user.findUnique時,會自動列出where,select,include等可用參數。 - 可預測的執行結果:所有資料庫操作皆回傳明確的型別,減少
any帶來的執行期錯誤。
3. Prisma Schema 基礎
// prisma/schema.prisma
datasource db {
provider = "postgresql" // 支援 PostgreSQL、MySQL、SQLite、SQL Server
url = env("DATABASE_URL") // 透過 .env 管理機密資訊
}
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma-client"
}
// 資料模型範例
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
}
重點:
@relation、@unique、@default等屬性會直接映射到 Prisma Client 的型別定義,讓後續程式碼自動得到正確的型別資訊。
4. 產生 Prisma Client
# 安裝 Prisma CLI(開發依賴)
npm install -D prisma
# 初始化 Prisma(會產生 schema.prisma)
npx prisma init
# 產生 Client(會在 src/generated/... 產生 .ts 檔)
npx prisma generate
產生的 prisma-client 只需要在程式碼中 import 即可使用:
import { PrismaClient } from '../generated/prisma-client';
export const prisma = new PrismaClient();
5. 整合到 Express
在 Express 中,我們通常會把 Prisma Client 放在 middleware 或 service 層,讓每一次請求都可以共享同一個實例(或依需求使用 per‑request instance)。
// src/app.ts
import express from 'express';
import { prisma } from './prisma';
const app = express();
app.use(express.json());
// 讓 Prisma 在每個 request 後自動斷開連線(防止連線泄漏)
app.use(async (req, res, next) => {
res.on('finish', async () => {
await prisma.$disconnect();
});
next();
});
程式碼範例
以下示範 5 個在 Express + TypeScript 中常見的 Prisma 用法,皆附有說明註解。
範例 1️⃣:建立新使用者(Create)
// src/routes/user.ts
import { Router, Request, Response } from 'express';
import { prisma } from '../prisma';
const router = Router();
router.post('/users', async (req: Request, res: Response) => {
const { email, name } = req.body;
try {
// Prisma Client 會自動根據 schema 推斷欄位型別
const user = await prisma.user.create({
data: {
email,
name, // name 為 optional,若未提供會被設定為 null
},
});
res.status(201).json(user);
} catch (error) {
// 例如 email 重複時會拋出 PrismaClientKnownRequestError
res.status(400).json({ error: (error as Error).message });
}
});
export default router;
範例 2️⃣:取得使用者與其文章(Read + Include)
router.get('/users/:id', async (req: Request, res: Response) => {
const userId = Number(req.params.id);
const user = await prisma.user.findUnique({
where: { id: userId },
include: { // 直接把關聯的 posts 包進回傳結果
posts: {
where: { published: true },
select: { title: true, createdAt: true },
},
},
});
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
範例 3️⃣:更新文章(Update)與型別安全
router.patch('/posts/:id', async (req: Request, res: Response) => {
const postId = Number(req.params.id);
const { title, content, published } = req.body;
const updated = await prisma.post.update({
where: { id: postId },
data: {
title,
content,
published,
},
});
res.json(updated);
});
提示:若
published欄位忘記傳值,TypeScript 仍會允許undefined,但 Prisma 只會更新實際提供的欄位。
範例 4️⃣:刪除使用者(Delete)並處理關聯
router.delete('/users/:id', async (req: Request, res: Response) => {
const userId = Number(req.params.id);
// 先刪除所有文章,避免外鍵衝突
await prisma.post.deleteMany({ where: { authorId: userId } });
const deleted = await prisma.user.delete({
where: { id: userId },
});
res.json({ message: 'User removed', deleted });
});
範例 5️⃣:Transaction(原子操作)
router.post('/posts/:id/publish', async (req: Request, res: Response) => {
const postId = Number(req.params.id);
const result = await prisma.$transaction(async (tx) => {
// 1. 把文章設為已發布
const post = await tx.post.update({
where: { id: postId },
data: { published: true },
});
// 2. 同時更新作者的最後發布時間
await tx.user.update({
where: { id: post.authorId },
data: { updatedAt: new Date() },
});
return post;
});
res.json(result);
});
重點:
$transaction內的所有操作要麼全部成功,要麼全部回滾,確保資料一致性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
忘記執行 prisma generate |
修改 schema 後未重新產生 client,導致型別不匹配或 runtime error。 | 每次修改 .prisma 檔後執行 npx prisma generate,或在 package.json 加入 postinstall script。 |
| 資料庫連線泄漏 | 在開發環境未妥善關閉 Prisma 連線,導致 ECONNREFUSED。 |
在 Express 結束回應時呼叫 prisma.$disconnect(),或在應用關閉時使用 process.on('SIGTERM')。 |
過度使用 any |
為了省事直接把 Prisma 回傳值 cast 為 any,失去型別安全。 |
盡量保留 Prisma 自動生成的型別,例如 User、Post,必要時使用 Pick<>、Omit<> 產生子型別。 |
| 忽視 migration 歷史 | 手動改資料表卻未同步到 Prisma schema,造成生產環境不一致。 | 使用 prisma migrate dev 產生 migration,並在 CI/CD 流程中執行 prisma migrate deploy。 |
| 未處理 Prisma 錯誤類別 | Prisma 拋出多種錯誤(PrismaClientKnownRequestError、PrismaClientValidationError),未分類處理會讓 API 回傳不一致。 |
建立全域錯誤處理 middleware,根據 error.code 返回相應的 HTTP status。 |
最佳實踐:
- 統一管理 Prisma 實例:在
src/prisma.ts中建立單例,避免在每個模組重複new PrismaClient()。 - 使用環境變數管理資料庫連線:
.env檔內只放DATABASE_URL,別把密碼寫在程式碼裡。 - 利用
select/include精確控制返回欄位,減少不必要的資料傳輸與 N+1 問題。 - 在測試環境使用 SQLite 或獨立的測試資料庫,搭配
prisma migrate reset快速重置測試資料。 - 設定 Prisma Logging:
prisma.$on('query', (e) => console.log(e));方便除錯與效能監控。
實際應用場景
1. 電子商務平台的訂單系統
- 需求:訂單、商品、使用者三個模型互相關聯,必須保證「下單」與「庫存扣減」的原子性。
- 使用 Prisma:透過
@relation建立外鍵,使用$transaction同時寫入Order、OrderItem並更新Product.stock。 - 好處:型別安全保證每筆資料都有正確的欄位,若庫存不足會在 transaction 內拋錯並自動回滾,避免產生錯誤的訂單紀錄。
2. 多租戶 SaaS 系統
- 需求:每個租戶擁有獨立的資料表或 schema,且需要在 API 中根據 JWT 中的
tenantId動態切換資料庫。 - 使用 Prisma:在
prisma實例化時傳入不同的datasourceURL(透過prisma.$connect()),配合 Express 中間件注入tenantId。 - 好處:Prisma 的 type‑safe client 仍然適用於每個租戶,且 migration 可以一次性部署到所有租戶的資料庫。
3. 即時聊天系統(WebSocket + REST)
- 需求:訊息需要儲存、查詢最近聊天紀錄、支援分頁。
- 使用 Prisma:
Message模型的createdAt欄位自動生成索引,使用findMany搭配orderBy、skip、take完成分頁;在 WebSocket 中使用同一 Prisma 實例寫入訊息,確保資料一致。 - 好處:一次編寫的 TypeScript 型別在 REST 與 WebSocket 兩端皆可共享,減少重複定義與錯誤。
總結
- Prisma 為 TypeScript 提供了 型別安全、開發友好 的資料庫存取層,與 Express 完美結合後,開發者可以在撰寫 API 時同時享受到編譯期錯誤檢查與自動補全的便利。
- 透過 Prisma Schema 定義模型、Prisma Migrate 管理 migration、Prisma Client 產生型別安全的 CRUD 方法,我們可以在 Express 中以最少的程式碼完成資料庫操作,同時保持代碼的可讀性與可維護性。
- 本文提供的 5 個實作範例、常見陷阱與最佳實踐,已足以讓你在專案中快速上手。未來若需要擴充功能(多租戶、交易、效能監控),Prisma 仍提供靈活的 API 與豐富的社群資源,值得在任何 Node.js/TypeScript 專案中長期使用。
下一步:把上述範例整合到你的專案,使用
npm run dev觀察 Prisma 與 Express 的即時互動,並在 CI 流程中加入prisma migrate deploy,讓資料庫變更永遠保持可追蹤、可回溯。祝開發順利,寫出安全、乾淨的後端程式碼!