title: 為什麼你的 TypeScript 程式碼還是在正式環境當掉
published: true
description: 一份實用的 TypeScript 心智模型圖解指南。了解為什麼型別會在執行時消失、結構型別系統如何運作,以及如何撰寫乾淨、型別安全、在正式環境永不當機的程式碼。
tags: typescript, javascript, beginners, discuss
cover_image: https://i.imgur.com/9mMLA0t.png

你花了一整個下午幫專案補上型別。每個變數都有介面,每個函式都有明確的回傳型別,編輯器裡也完全沒有紅色波浪底線。你覺得自己無敵了。

然後你部署到正式環境。

二十分鐘後,錯誤追蹤系統跳出這樣的訊息:

TypeError: Cannot read properties of undefined (reading 'toUpperCase')

等等,怎麼會這樣?你明明寫的是 TypeScript!TypeScript 不是應該讓這種錯誤完全不可能發生嗎?

如果這件事也發生在你身上,歡迎加入這個大家庭。幾乎每個開發者都會經歷一段時期,覺得 TypeScript 像個過度熱心的副駕駛,總是在你旁邊不停碎念,卻又莫名其妙地放任真正的 bug 直接溜進正式環境。

問題通常不在你的程式碼。問題幾乎總是在你的心智模型。

大多數初學者把 TypeScript 當成是硬裝在 JavaScript 上的 Java 或 C#。但 TypeScript 的運作方式和你用過的任何傳統語言都不一樣。今天我們要來看看 TypeScript 實際上是怎麼「思考」的、為什麼你的型別甚至在應用程式啟動前就已經消失,以及如何徹底停止和編譯器對抗。


1. 幻影型別系統:型別消除

這裡有一個關於 TypeScript 最重要的事實:

當你的程式碼在執行時,TypeScript 並不存在。

當你執行 tsc(TypeScript 編譯器)時,它會做兩件完全獨立的事:

  1. 檢查你的程式碼是否有型別錯誤。
  2. 把所有的 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 或自訂的型別守衛函式。


2. 重點在形狀,不在名稱:結構型別系統

如果你來自 Java、C# 或 C++,接下來這個概念可能會讓你大開眼界。

在傳統語言中,型別系統是名目型別。一個型別的身分由它明確的名稱或宣告決定。

而在 TypeScript 中,型別系統是結構型別(通常也稱為編譯時期的鴨子型別)。一個型別的身分完全由它內部的形狀決定。

結構型別 vs 名目型別

看一個具體例子:

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 就會允許它。

理解這個差異,可以幫你省下好幾個小時的抓頭時間。


3. 型別光譜:any、unknown 與 never

當開發者卡在頑固的型別錯誤前,最容易想到的偷懶方式就是 any。

// 「我放棄了」按鈕
const user: any = fetchUserData();

使用 any 並不能解決你的型別問題。它只是告訴編譯器:「別做你的工作了。把這個變數以及所有碰到它的東西都關掉安全機制。」

它會像病毒一樣擴散。一旦某個變數是 any,任何呼叫它的函式都會失去自動完成和型別檢查。

相反地,現代 TypeScript 提供了一組清楚的工具光譜:

型別光譜:any vs unknown vs never

1. unknown:安全的頂層型別

當你真的不知道某個值是什麼時(例如 API 回應、使用者輸入或解析後的 JSON),請使用 unknown,不要用 any。

unknown 可以接受任何值,但在你證明它是什麼之前,不會讓你對它做任何事:

function parsePayload(input: unknown) {
  // ❌ 錯誤:'input' 的型別是 'unknown'。
  // console.log(input.trim());

  // ✅ 安全:先證明它是字串(型別縮小)
  if (typeof input === 'string') {
    console.log(input.trim()); 
  }
}

2. 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 裡標出錯誤,甚至還沒進到測試階段。


4. 用可辨識聯集消滅一堆可選旗標

看看這個管理網路請求狀態的常見模式:

// 「什麼都可能存在」的反模式
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 會自動在每個分支內縮小物件型別。


5. 別再用 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 讓你兩全其美:它會驗證你的物件是否符合約束,同時不會丟失資料的特定字面型別和屬性。


這個心智轉換,會改變一切

一旦你內化了這五個核心概念:

  1. 型別在執行時會完全消失。 對外部邊界請使用 Zod 之類的工具或自訂守衛進行驗證。
  2. TypeScript 檢查的是形狀,不是名稱。 接受結構相容性,不要和它對著幹。
  3. 避免使用 any。 用 unknown 來強化安全性,用 never 來捕捉未處理的邊界情況。
  4. 使用可辨識聯集。 讓非法狀態在數學上根本無法被表示。
  5. 優先使用 satisfies,不要濫用 as。 在保留字面值精確度的同時抓出真正的 bug。

突然之間,你不再把 TypeScript 當成敵人。你和編譯器之間的拉鋸戰結束了,它會變成你用過最銳利的結對程式設計夥伴。

回頭看我自己的學習歷程,最難的轉變,是把「型別在執行時也可信」這個習慣徹底改掉。直到有一個痛苦的週五深夜部署事故,這個教訓才真正烙印下來。

我很好奇:是哪一個特定的 TypeScript 錯誤,或是哪一個心智模型轉換,花了你最久才真正搞懂?你有沒有因為型別消除而讓執行時 bug 溜進正式環境?歡迎在下方分享你的戰鬥故事或最喜歡的型別模式。聽其他工程師如何理清自己的心智模型,永遠都是我們一起進步最好的方式之一。


原文出處:https://dev.to/smtahosin/why-your-typescript-code-still-crashes-in-production-2bf4


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝49   💬2  
689
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登