你花了一整個下午幫專案補上型別。每個變數都有介面,每個函式都有明確的回傳型別,編輯器裡也完全沒有紅色波浪底線。你覺得自己無敵了。
然後你部署到正式環境。
二十分鐘後,錯誤追蹤系統跳出這樣的訊息:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
等等,怎麼會這樣?你明明寫的是 TypeScript!TypeScript 不是應該讓這種錯誤完全不可能發生嗎?
如果這件事也發生在你身上,歡迎加入這個大家庭。幾乎每個開發者都會經歷一段時期,覺得 TypeScript 像個過度熱心的副駕駛,總是在你旁邊不停碎念,卻又莫名其妙地放任真正的 bug 直接溜進正式環境。
問題通常不在你的程式碼。問題幾乎總是在你的心智模型。
大多數初學者把 TypeScript 當成是硬裝在 JavaScript 上的 Java 或 C#。但 TypeScript 的運作方式和你用過的任何傳統語言都不一樣。今天我們要來看看 TypeScript 實際上是怎麼「思考」的、為什麼你的型別甚至在應用程式啟動前就已經消失,以及如何徹底停止和編譯器對抗。
這裡有一個關於 TypeScript 最重要的事實:
當你的程式碼在執行時,TypeScript 並不存在。
當你執行 tsc(TypeScript 編譯器)時,它會做兩件完全獨立的事:
type、interface 和型別註記全部徹底移除,輸出成純 JavaScript。
仔細看編譯之後發生了什麼事。你設計的那些漂亮介面呢?不見了。你建立的泛型限制呢?消失了。
在 Node.js 或 Chrome 的 V8 引擎裡,執行的都是原始 JavaScript。執行時完全不記得你寫過哪些型別。
因為初學者以為型別在執行時也存在,所以常會寫出像這樣的程式:
interface User {
id: number;
name: string;
}
function processResponse(data: unknown) {
// ❌ 執行時錯誤:'User' 只是一個型別,
// 但卻被當成值來使用。
if (data instanceof User) {
console.log(data.name);
}
}
JavaScript 的 instanceof 運算子會檢查記憶體中實際物件的原型鏈。但 User 在編譯時就已經被消除掉了!它在 JavaScript 裡沒有留下任何痕跡,所以 instanceof User 對執行時來說完全沒有意義。
這也解釋了為什麼外部資料常常會弄壞你的應用程式:
interface ApiResponse {
username: string;
}
// 你告訴 TypeScript:「相信我,API 會回傳 ApiResponse」
const response = await fetch('/api/user');
const user = (await response.json()) as ApiResponse;
// 如果後端回傳的是 { error: "User not found" },這裡就炸了!
console.log(user.username.toUpperCase());
TypeScript 在編譯時相信了你的註記,但 TypeScript 沒辦法監控網路傳輸。如果後端回傳 null 或 { error: 500 },你的程式在執行時就會當掉。
經驗法則: TypeScript 驗證的是你寫的內容,不是外部世界送進來的內容。對於外部邊界(API、localStorage、使用者輸入),請務必使用執行時驗證工具,例如 Zod 或自訂的型別守衛函式。
如果你來自 Java、C# 或 C++,接下來這個概念可能會讓你大開眼界。
在傳統語言中,型別系統是名目型別。一個型別的身分由它明確的名稱或宣告決定。
而在 TypeScript 中,型別系統是結構型別(通常也稱為編譯時期的鴨子型別)。一個型別的身分完全由它內部的形狀決定。

看一個具體例子:
type Vector2D = {
x: number;
y: number;
};
type Point2D = {
x: number;
y: number;
};
const point: Point2D = { x: 10, y: 20 };
const vector: Vector2D = point; // ✅ 完全合法!
在 Java 或 C# 裡,若沒有明確轉型,把 Point2D 指派給 Vector2D 會在編譯時報錯。因為它們名字不同,所以是不同型別。
在 TypeScript 裡,編譯器看的是藍圖:
point 有沒有 number 型別的 x?有。point 有沒有 number 型別的 y?有。這裡有個讓幾乎所有新手都困惑的問題:
type Options = {
timeout: number;
};
function startServer(opts: Options) {
console.log(`Starting with timeout: ${opts.timeout}`);
}
// 情況 A:直接傳入物件字面值
// ❌ 錯誤:物件字面值只能指定已知屬性,
// 而且 'port' 不存在於型別 'Options' 中。
startServer({ timeout: 5000, port: 8080 });
// 情況 B:傳入既有變數參考
const myConfig = { timeout: 5000, port: 8080 };
startServer(myConfig); // ✅ 合法!沒有錯誤!
為什麼情況 A 會失敗,而情況 B 卻成功,明明傳的屬性一模一樣?
原因在於:TypeScript 會特別對新鮮的物件字面值套用多餘屬性檢查。
當你直接寫內嵌字面值 { timeout: 5000, port: 8080 } 時,TypeScript 會假設你是不是打錯字了(例如把 timeout 寫成 timeouut),所以會嚴格標示任何多出來的欄位。
但當你把它先指派給中介變數 myConfig,TypeScript 就會回到純粹的結構型別檢查。因為 myConfig 滿足有 timeout: number 這個條件,TypeScript 就會允許它。
理解這個差異,可以幫你省下好幾個小時的抓頭時間。
當開發者卡在頑固的型別錯誤前,最容易想到的偷懶方式就是 any。
// 「我放棄了」按鈕
const user: any = fetchUserData();
使用 any 並不能解決你的型別問題。它只是告訴編譯器:「別做你的工作了。把這個變數以及所有碰到它的東西都關掉安全機制。」
它會像病毒一樣擴散。一旦某個變數是 any,任何呼叫它的函式都會失去自動完成和型別檢查。
相反地,現代 TypeScript 提供了一組清楚的工具光譜:

unknown:安全的頂層型別當你真的不知道某個值是什麼時(例如 API 回應、使用者輸入或解析後的 JSON),請使用 unknown,不要用 any。
unknown 可以接受任何值,但在你證明它是什麼之前,不會讓你對它做任何事:
function parsePayload(input: unknown) {
// ❌ 錯誤:'input' 的型別是 'unknown'。
// console.log(input.trim());
// ✅ 安全:先證明它是字串(型別縮小)
if (typeof input === 'string') {
console.log(input.trim());
}
}
never:完整性檢查的底層型別never 代表一種數學上不應該發生的狀態。它是你確保複雜條件邏輯中沒有漏掉任何情況的秘密武器:
type Action =
| { type: 'LOGIN'; username: string }
| { type: 'LOGOUT' }
| { type: 'SIGNUP'; email: string };
function handleAction(action: Action) {
switch (action.type) {
case 'LOGIN':
return `Welcome, ${action.username}`;
case 'LOGOUT':
return 'Goodbye';
case 'SIGNUP':
return `Signed up with ${action.email}`;
default: {
// 如果有人在 Action 裡新增一個動作,卻忘了
// 在這裡加上對應分支,這行就不會通過編譯!
const _exhaustiveCheck: never = action;
return _exhaustiveCheck;
}
}
}
如果明天有另一位開發者把 { type: 'RESET_PASSWORD' } 加進 Action,TypeScript 編譯器會立刻在 handleAction 裡標出錯誤,甚至還沒進到測試階段。
看看這個管理網路請求狀態的常見模式:
// 「什麼都可能存在」的反模式
type RequestState = {
isLoading: boolean;
data?: UserData;
error?: string;
};
這看起來無害,但從數學上來說,這個型別允許 8 種不同組合:
isLoading: true, data: user, error: "Failed"isLoading: false, data: undefined, error: undefined這些組合在現實世界裡根本不合理!但你的程式卻必須防禦性地檢查每個屬性,像是 if (state.data && !state.isLoading && !state.error)。
更好的做法是用 可辨識聯集 來建模你的領域:

type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: UserData }
| { status: 'error'; error: string };
function renderUI(state: RequestState) {
switch (state.status) {
case 'loading':
return '<Spinner />';
case 'error':
// TypeScript 知道這裡一定有 'error'!
return `<ErrorMessage text="${state.error}" />`;
case 'success':
// TypeScript 保證這裡一定有 'data'!
return `<UserProfile user="${state.data.name}" />`;
case 'idle':
return '<WelcomePrompt />';
}
}
只要加上一個字面字串標記(status),程式碼中就能讓不可能的狀態無法表示。TypeScript 會自動在每個分支內縮小物件型別。
as 了(改用 satisfies)在較舊的 TypeScript 程式碼庫裡,你會到處看到 as 關鍵字:
type Theme = 'light' | 'dark';
type Palette = Record<Theme, string>;
// `as` 型別斷言(對編譯器禮貌地說謊)
const colors = {
light: '#ffffff',
dark: '#121212',
} as Palette;
// 沒有精確色碼字串的自動完成!
colors.light; // 型別只是 'string',不是 '#ffffff'
當你使用 as 時,你是在強迫編譯器接受你的宣告。如果你打錯了十六進位色碼,或少寫了一個必要鍵,as 常常會把問題遮住。
在現代 TypeScript(v4.9 以上)中,請改用 satisfies 運算子:
type Theme = 'light' | 'dark';
type Palette = Record<Theme, string>;
const colors = {
light: '#ffffff',
dark: '#121212',
} satisfies Palette;
// 1. 驗證 'colors' 是否符合 Palette 的形狀
// 2. 保留精確的字面值精度!
// colors.light 的型別是 '#ffffff',不是一般的 string!
satisfies 讓你兩全其美:它會驗證你的物件是否符合約束,同時不會丟失資料的特定字面型別和屬性。
一旦你內化了這五個核心概念:
any。 用 unknown 來強化安全性,用 never 來捕捉未處理的邊界情況。satisfies,不要濫用 as。 在保留字面值精確度的同時抓出真正的 bug。突然之間,你不再把 TypeScript 當成敵人。你和編譯器之間的拉鋸戰結束了,它會變成你用過最銳利的結對程式設計夥伴。
回頭看我自己的學習歷程,最難的轉變,是把「型別在執行時也可信」這個習慣徹底改掉。直到有一個痛苦的週五深夜部署事故,這個教訓才真正烙印下來。
我很好奇:是哪一個特定的 TypeScript 錯誤,或是哪一個心智模型轉換,花了你最久才真正搞懂?你有沒有因為型別消除而讓執行時 bug 溜進正式環境?歡迎在下方分享你的戰鬥故事或最喜歡的型別模式。聽其他工程師如何理清自己的心智模型,永遠都是我們一起進步最好的方式之一。
原文出處:https://dev.to/smtahosin/why-your-typescript-code-still-crashes-in-production-2bf4