TypeScript 進階主題與最佳實踐
程式安全性與穩定性設計
簡介
在現代前端與 Node.js 專案中,程式碼的安全性與穩定性往往是決定產品能否順利上線、持續維護的關鍵因素。雖然 JavaScript 本身是弱型別語言,容易在執行時產生不可預期的錯誤,但 TypeScript 透過靜態型別檢查,讓開發者在編譯階段就能捕捉到大多數潛在問題。
本單元將說明如何在 TypeScript 中運用 型別系統、編譯選項與工具鏈,打造 安全、可預測、易於維護 的程式碼基礎。即使你是剛踏入 TypeScript 的新手,只要掌握以下概念與實務技巧,就能大幅降低執行時例外、資料遺失或未授權存取的風險。
核心概念
以下幾個概念是提升 TypeScript 程式安全性與穩定性的基礎,建議在新專案或既有專案升級時同步導入。
1. strict 系列編譯選項
tsconfig.json 中的 strict 系列選項會把 TypeScript 的檢查力度提升到最高。最常用的幾個子選項說明如下:
| 選項 | 功能說明 | 為什麼重要 |
|---|---|---|
strictNullChecks |
null 與 undefined 必須明確宣告 |
防止 空指標例外 |
noImplicitAny |
未明確指定型別時不允許自動推斷為 any |
避免 隱藏的型別錯誤 |
noImplicitThis |
this 需明確型別 |
防止 上下文錯誤 |
strictPropertyInitialization |
類別屬性必須在建構子或宣告時初始化 | 確保 屬性永遠有值 |
// tsconfig.json 範例
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictPropertyInitialization": true,
// 其他選項...
}
}
建議:在專案的最初階段就開啟
strict,即使需要逐步調整也能在早期發現問題。
2. readonly、const 與不可變資料
不可變資料是防止 意外變更 的第一道防線。TypeScript 提供了 readonly 修飾子與 as const 斷言,讓物件或陣列在編譯時即被鎖定。
// readonly 屬性
interface User {
readonly id: number; // id 只能在建構時設定,之後不可更改
name: string;
}
// 使用 as const 產生不可變的字面量
const ROLE = {
ADMIN: "admin",
USER: "user",
} as const;
// ROLE 的型別變成 { readonly ADMIN: "admin"; readonly USER: "user"; }
type Role = typeof ROLE[keyof typeof ROLE]; // "admin" | "user"
實務技巧:對於 API 回傳的 DTO、設定檔或常數表,盡量使用
readonly或as const,可減少因不小心寫入而產生的 bug。
3. 條件型別與類型守衛(Type Guards)
條件型別 (T extends U ? X : Y) 以及自訂的類型守衛函式,能在執行時安全地 縮窄型別,避免不必要的 any 或 unknown。
// 自訂類型守衛:判斷是否為 Error 物件
function isError(value: unknown): value is Error {
return value instanceof Error;
}
// 使用範例
function handleResult(res: string | Error) {
if (isError(res)) {
// 在此分支裡 TypeScript 確定 res 為 Error
console.error(res.message);
} else {
// 此分支裡 res 為 string
console.log(res);
}
}
透過類型守衛,我們可以在 runtime 中確認資料型別,同時讓編譯器保留型別資訊,提升程式的可預測性。
4. Discriminated Union(辨別式聯合型別)
辨別式聯合型別是建構 安全的狀態機 或 API 回傳結構 的好幫手。透過一個共同的「標籤」屬性,TypeScript 能自動完成型別縮窄。
type ApiResponse =
| { status: "success"; data: { id: number; name: string } }
| { status: "error"; error: string }
| { status: "loading" };
function render(resp: ApiResponse) {
switch (resp.status) {
case "success":
// resp 被縮窄為 { status: "success"; data: {...} }
console.log("User:", resp.data.name);
break;
case "error":
console.error("Error:", resp.error);
break;
case "loading":
console.log("Loading...");
}
}
使用辨別式聯合型別,可保證 所有可能的分支 都被處理,避免遺漏 default 造成的隱蔽錯誤。
5. 运行时验证(Runtime Validation)
型別檢查只能在編譯期保證,從外部 API、JSON 檔或使用者輸入取得的資料 必須在執行時再次驗證。常見的做法是結合 Zod、io-ts 或 class‑validator 等庫。
以下以 Zod 為例,示範如何在取得資料後立即驗證,若驗證失敗則拋出安全的例外。
import { z } from "zod";
// 定義資料結構
const UserSchema = z.object({
id: z.number().int(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
});
// 取得 API 回傳並驗證
async function fetchUser(id: number) {
const res = await fetch(`/api/users/${id}`);
const raw = await res.json();
// 若資料不符合 schema,會拋出 ZodError
const user = UserSchema.parse(raw);
return user; // 此時 TypeScript 已知 user 為正確的型別
}
最佳實踐:在所有 邊界(API、檔案、使用者輸入)加入 runtime validation,能有效防止 不符合預期的資料 造成的安全漏洞與程式崩潰。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
過度使用 any |
失去型別保護,會在執行時產生未捕捉的錯誤。 | 盡量使用 unknown,配合類型守衛或斷言。 |
忽略 strictNullChecks |
null / undefined 直接傳遞,導致 Cannot read property … of undefined。 |
開啟 strictNullChecks,使用可選鏈 (?.) 或空值合併 (??)。 |
| 未對外部資料做 runtime validation | 編譯器只能檢查靜態程式碼,外部資料仍可能不符合型別。 | 使用 Zod、io-ts 等庫,在取得資料後立即驗證。 |
| 資料結構變更未同步更新型別 | 介面或型別與實際回傳不一致,執行時出現錯誤。 | 建立 API contract(如 OpenAPI)並自動產生型別,或使用辨別式聯合型別集中管理。 |
過度依賴 as any 斷言 |
失去型別安全,等同於關閉 TypeScript。 | 只在確定無誤的情況下使用,並加上註解說明原因。 |
其他最佳實踐
- 啟用 ESLint + @typescript-eslint:統一程式碼風格,防止未使用的變數、隱式
any等問題。 - 使用
noUncheckedIndexedAccess:陣列或物件索引存取時會自動加上undefined,避免直接存取造成例外。 - 將
readonly盡可能向外傳遞:對外提供的 API 建議回傳ReadonlyArray<T>或Readonly<T>,保護資料不被外部意外修改。 - 分層錯誤處理:在服務層捕捉外部錯誤,轉換為自訂的 Error 類別,讓上層只需處理已知的錯誤類型。
實際應用場景
場景一:大型電商平台的商品 API
在電商平台,商品資料會由多個微服務匯入,且每筆資料都可能缺少欄位或格式錯誤。使用 Zod 進行 runtime validation,結合 discriminated union 產生統一的回傳型別,能保證前端在渲染商品列表前,資料已完整且安全。
// 商品 API 回傳型別
type ProductResponse =
| { status: "ok"; product: Product }
| { status: "error"; code: number; message: string };
// 取得商品並驗證
async function getProduct(id: number): Promise<ProductResponse> {
const raw = await fetch(`/api/products/${id}`).then(r => r.json());
// Zod schema
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number().nonnegative(),
tags: z.array(z.string()),
});
try {
const product = ProductSchema.parse(raw);
return { status: "ok", product };
} catch (e) {
return {
status: "error",
code: 400,
message: (e as z.ZodError).message,
};
}
}
場景二:Node.js 後端的資料庫存取層
在 Node.js 後端,對資料庫的 CRUD 操作若未使用 readonly 或 strictPropertyInitialization,容易出現「欄位遺失」或「未初始化」的錯誤。以下示範如何透過 class、readonly 以及 型別守衛,確保每筆資料在寫入前已完整。
// UserEntity.ts
export class UserEntity {
public readonly id: number;
public name: string;
public email: string;
constructor(data: { id: number; name: string; email: string }) {
this.id = data.id; // id 為 readonly,建構後不可變更
this.name = data.name;
this.email = data.email;
}
}
// repository.ts
import { UserEntity } from "./UserEntity";
function isValidUser(obj: unknown): obj is UserEntity {
return obj instanceof UserEntity;
}
export async function saveUser(user: unknown): Promise<void> {
if (!isValidUser(user)) {
throw new TypeError("Invalid user entity");
}
// 實際 DB 寫入 (簡化示範)
await db.query(
"INSERT INTO users (id, name, email) VALUES (?, ?, ?)",
[user.id, user.name, user.email]
);
}
場景三:React 前端的表單驗證
React 中的表單驗證常因資料型別不一致導致 UI 異常。使用 Zod 與 React Hook Form 整合,可以在表單提交前完成完整型別驗證,若驗證失敗則直接回傳錯誤訊息,避免不合法資料送到後端。
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type LoginForm = z.infer<typeof LoginSchema>;
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({ resolver: zodResolver(LoginSchema) });
const onSubmit = (data: LoginForm) => {
// data 已經通過型別與 runtime validation
console.log("Login data:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input
{...register("password")}
type="password"
placeholder="Password"
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">登入</button>
</form>
);
}
總結
- 開啟
strict系列 是提升 TypeScript 程式安全的第一步,能在編譯期即捕捉大多數錯誤。 - 使用
readonly、as const讓資料不可變,減少意外寫入的風險。 - 類型守衛、條件型別 與 辨別式聯合型別 為縮窄型別、建立安全狀態機提供了強大的工具。
- Runtime validation(如 Zod)必須結合在所有外部資料入口,才能彌補編譯期無法檢查的漏洞。
- 避免
any、忽略null、未同步更新型別等常見陷阱,並以 ESLint、嚴格的錯誤處理與分層設計 為最佳實踐。
透過上述概念與範例,你可以在 開發過程 即建立安全、穩定且易於維護的 TypeScript 程式碼基礎。未來在面對更複雜的系統或多人協作時,這套「型別安全 + runtime 驗證」的雙重防護將成為你最可靠的防線。祝開發順利,寫出更安全、更穩定的程式!