FE Day 3 TypeScript 基礎:型別系統與 tsconfig
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第三篇,前兩天把工具鏈蓋好了,從今天起我們把注意力轉到「寫程式本身」:TypeScript 的型別系統、tsconfig.json 的關鍵設定、以及「為什麼 React 專案幾乎都把 strict 打開」。讀完這一篇,你應該能用 TypeScript 描述函式參數、元件 props、API 回傳的資料形狀,並且在編譯器報錯時知道怎麼讀訊息。整篇大約三十分鐘,動手做約二十分鐘。
引言
後端工程師對「型別」一點都不陌生:Python 有 type hint(def add(a: int, b: int) -> int)、Java 與 Go 從一開始就是強型別。TypeScript 把這個觀念搬進 JavaScript:寫的時候要標型別,編譯器會在「執行前」就把型別錯誤找出來,執行階段反而更穩。這也是為什麼大型前端專案(以及 Next.js、React、Vue 3 的核心原始碼)幾乎都改用 TypeScript 重寫——型別即契約,能減少一半以上的溝通成本。
今天的目標有四個:第一,理解 TypeScript 的「結構型子型化」(structural typing)跟 Java「名字型子型化」(nominal typing)的差別;第二,把 tsconfig.json 的關鍵選項(strict、target、module、moduleResolution、jsx、noUncheckedIndexedAccess)逐一說明;第三,學會用型別註記描述基本資料(原始型別、陣列、元組、物件、函式);第四,認識 interface 與 type 的差異,並知道什麼時候該用哪個。最後我們會把以上觀念整合成一份「預約系統」的型別檔,這份檔案會從 Day 4 一直被沿用到 Day 45,先把基礎打好。
TypeScript 的型別哲學:結構型 vs 名字型
Java 與 C# 的子型化是「名字型」:兩個 class 名稱不同,就算欄位一模一樣也不算同一個型別。TypeScript 走的是「結構型」:只要形狀(structure)一樣,就被視為相容。後端工程師第一次接觸會覺得「這也太鬆了吧?」,但實際寫 React 元件時非常方便——你只要描述「這個 props 應該長什麼樣」,呼叫端只要照著填就好,不必繼承某個 base class。
舉個例子:
// src/types.ts
// TypeScript 的「結構型子型化」示範
type User = {
id: number;
name: string;
};
type Admin = {
id: number;
name: string;
role: "owner" | "editor";
};
function greet(user: User) {
console.log(`Hello, ${user.name}`);
}
const admin: Admin = { id: 1, name: "Hao", role: "owner" };
// TypeScript 不會報錯:admin 的形狀包含 User 的所有欄位
greet(admin);
// 輸出:Hello, Hao
這個例子裡 Admin 多了 role 欄位,但因為它「擁有 User 所有的欄位」,所以 greet(admin) 是合法的。如果用 Java 的觀念看會覺得奇怪(Admin 不是 User 的子類別),但在 TypeScript 的世界裡,這就是它鼓勵的設計:型別只看結構,不看名字。後端寫過 Go 介面的工程師會覺得熟悉——Go 的介面也是結構型(duck typing),不需要明確宣告實作。
結構型的好處在 React 元件設計上特別明顯。假設你寫了一個 Avatar 元件,呼叫端不需要先把 user 宣告成某個 base class 再繼承,只要它「至少有 id 跟 name」就能用。這種設計讓元件的依賴維持在「最低必要欄位」而不是「特定類別」,重構時更不容易壞。
tsconfig.json 的關鍵選項
昨天我們用 Vite 範本建立專案時,TypeScript 幫我們生成了一份 tsconfig.json。今天把它拆得更細,理解這些選項背後的設計理念,能讓你在新專案、monorepo、SSR 環境之間切換時不會卡住。
| 選項 | 推薦值 | 為什麼 |
|---|---|---|
target |
ES2022 |
對應 Node 22/24 與主流瀏覽器(Chrome 94+、Safari 16+、Firefox 93+) |
lib |
["ES2022", "DOM", "DOM.Iterable"] |
DOM 函式庫讓你能用 document、fetch 等瀏覽器 API |
module |
ESNext |
與 type: "module" 配合,輸出原生 ESM |
moduleResolution |
bundler |
對應 Vite、Next.js、webpack 5+ 的解析規則;可以省略副檔名 |
jsx |
react-jsx |
React 17+ 自動 runtime;不需要每支檔案 import React |
strict |
true |
打開後啟用 strictNullChecks、noImplicitAny 等 8 個子選項 |
noUncheckedIndexedAccess |
true |
陣列取值會回傳 T | undefined,逼你處理空值 |
exactOptionalPropertyTypes |
true |
field?: string 不再允許被設成 undefined 顯式值 |
其中 strict: true 是最重要的一個開關。打開後,TypeScript 會啟用 8 個子選項:noImplicitAny(不允許隱式 any)、strictNullChecks(null/undefined 要明確處理)、strictFunctionTypes(函式參數嚴格比對)、strictBindCallApply(bind/call/apply 型別嚴格)、strictPropertyInitialization(class 屬性必須初始化)、noImplicitThis(this 必須有明確型別)、useUnknownInCatchVariables(catch 變數預設 unknown)、alwaysStrict(輸出嚴格模式)。每一條都對應一個常見的 bug 模式。
用 strictNullChecks 示範嚴格模式
沒打開 strictNullChecks 之前,TypeScript 允許 string 變數被設成 null;打開之後,會被報錯:
// src/strict-null.ts
// strictNullChecks = false 時,這段不報錯
// strictNullChecks = true 時,這段會報錯
function getLength(s: string): number {
return s.length;
}
// 編譯錯誤:Argument of type 'string | null' is not
// assignable to parameter of type 'string'.
getLength(null);
對後端工程師來說,這個行為跟 Java 對 null 的嚴格要求一致。打開 strict 之後,TypeScript 會逼你在呼叫前先檢查:
// src/strict-null.ts(修法)
function getLength(s: string | null): number {
if (s === null) return 0;
return s.length;
}
// 現在這個呼叫合法
getLength(null);
// 輸出:0
這個小習慣能擋掉一大堆「Cannot read property of undefined」這種經典 runtime 錯誤。後端寫過 Kotlin 或 Swift 的工程師會立刻有共鳴。
用 noUncheckedIndexedAccess 防呆
noUncheckedIndexedAccess 是 4.1 版加入的選項,預設關閉。把它打開之後,陣列取值會被推論成 T | undefined,強迫你處理「這個索引可能不存在」的情境:
// src/unchecked-index.ts
const items = ["apple", "banana", "cherry"];
// 沒打開 noUncheckedIndexedAccess:first 是 string
// 打開之後:first 是 string | undefined
const first = items[0];
console.log(first?.toUpperCase());
// 輸出:APPLE(沒有報錯,但加上 ?. 避免 undefined 時炸掉)
乍看之下很囉嗦,但在真實應用裡非常實用:後端 API 回傳的陣列可能是空的、查詢參數的解析結果可能沒有對應值、字典取值永遠可能回傳 undefined。打開這個選項之後,你的程式碼會「天生」對空值有防呆。後端工程師寫過 Optional、Maybe、Result 的人會發現:這個選項背後的概念跟那些東西一模一樣,只是表現在陣列與物件存取上。
原始型別、陣列、元組、物件
TypeScript 的型別註記語法非常貼近日常寫法。後端工程師可以把它想成「在變數後面掛一個標籤,告訴編譯器『這個變數只接受這些形狀的值』」。下面把最常用的幾種型別整理成對照表與範例。
| 型別 | 語法 | 範例 |
|---|---|---|
| 布林值 | boolean |
let active: boolean = true; |
| 數字 | number |
let count: number = 42; |
| 字串 | string |
let name: string = "Hao"; |
| 陣列 | T[] 或 Array<T> |
let ids: number[] = [1, 2, 3]; |
| 元組 | [T1, T2] |
let pair: [string, number] = ["age", 30]; |
| 物件 | { field: T } |
let user: { id: number; name: string }; |
| 函式 | (a: T) => U |
let add: (a: number, b: number) => number; |
| 聯合型別 | T1 | T2 |
let status: "ok" | "error"; |
| 可空 | T | null |
let value: string | null = null; |
這張表先記住,後續的範例會反覆用到。其中「聯合型別」(union types)是 React props 設計裡最常出現的技巧:「這個欄位可以是字串、也可以是數字」、「這個狀態可以是 loading、success、error」——這種「列舉幾種可能」的設計,TypeScript 用 | 連起來,編譯器會幫你檢查「所有分支都處理過」。對後端工程師來說,union 比 enum 更像「Python 的字面量聯合(Literal)」——輕量、明確、無須額外宣告。
物件型別:內聯 vs type alias
簡單的物件可以直接寫內聯型別,但只要同一個形狀出現第二次,就該抽出來用 type:
// src/user.ts
// 簡單的物件型別:先用內聯,第二次出現就抽 type
type User = {
id: number;
name: string;
email: string;
createdAt: Date;
};
function createUser(input: { name: string; email: string }): User {
return {
id: Math.floor(Math.random() * 1_000_000),
name: input.name,
email: input.email,
createdAt: new Date(),
};
}
const u = createUser({ name: "Hao", email: "hao@example.com" });
console.log(u.id, u.name);
// 輸出:123456 Hao(id 隨機產生)
這段展示兩個觀念:第一,type User = { ... } 是 TypeScript 的「型別別名」,可以把複雜形狀命名後重複使用;第二,函式參數可以混用「內聯型別」與「type alias」,簡單的留內聯、複雜的抽出去。後端工程師可以把它理解成「Python 的 TypedDict 或 Pydantic 的 BaseModel」。
陣列與元組的差別
陣列是「同型別的多個值」,元組是「固定長度、每個位置型別已知」的結構。後端工程師可以把它想成「List vs tuple(Python)」:
// src/arrays.ts
// 陣列 vs 元組:長度與位置的差異
const numbers: number[] = [1, 2, 3];
const first: number = numbers[0];
// 元組:固定 [name, age] 的順序
const profile: [string, number] = ["Hao", 30];
const [name, age] = profile;
console.log(name, age);
// 輸出:Hao 30
// 元組 vs 陣列的實務差異:
// - 陣列:用 map / filter / forEach
// - 元組:用解構(destructuring)拿到對應位置的值
實務上 React 的 useState 回傳的就是元組:[value, setValue]。其他時候陣列比較常用,元組則出現在「需要明確幾個值」的情境(例如經緯度、HTTP status + 訊息、Python 常見的 (status_code, body) 回傳)。
interface 與 type 的差別
TypeScript 提供兩種描述物件型別的語法:interface 與 type。兩者 90% 的功能重疊,但有幾個細節差異值得記住:
// src/shapes.ts
// 兩種描述「使用者」型別的寫法
interface UserI {
id: number;
name: string;
}
type UserT = {
id: number;
name: string;
};
// 兩者在這個情境完全等價
const a: UserI = { id: 1, name: "Hao" };
const b: UserT = { id: 2, name: "Min" };
// interface 的特性:可以被「合併」(declaration merging)
interface UserI {
email: string;
}
// 此時 UserI 變成 { id, name, email }
const c: UserI = { id: 3, name: "Hao", email: "x@y.com" };
幾個判斷原則:第一,描述物件或類別的「形狀」時,interface 與 type 都可以,看團隊慣例;第二,需要做「宣告合併」(同一個名字多次宣告,欄位會合併)時只能用 interface;第三,需要「聯合型別」(A | B)、「工具型別」(Pick<User, "id">)或「映射型別」時只能用 type。本系列一律用 type,因為 React 的 props 通常會用到 Pick、Omit 等工具型別,type 用起來比較一致。
選用 optional 欄位
React props 大部分時候都有「可選欄位」(onClick?: () => void)。TypeScript 用 ? 標記:
// src/button-props.ts
// React 按鈕元件的 props 型別:大部分欄位是可選的
type ButtonProps = {
label: string; // 必填
onClick?: () => void; // 可選:沒傳就不綁事件
variant?: "primary" | "secondary" | "ghost"; // 可選 + 聯合型別
disabled?: boolean; // 可選:預設 false
};
function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
return { label, hasOnClick: !!onClick, variant, disabled };
}
console.log(Button({ label: "送出" }));
// 輸出:{ label: '送出', hasOnClick: false, variant: 'primary', disabled: false }
注意兩件事:第一,onClick?: () => void 表示呼叫端可以不傳,也可以傳函式;第二,函式本體裡用解構預設值 variant = "primary",這是 TypeScript 處理「optional 欄位」的標準慣例。後端工程師可以把它對應到 Python 的 Optional[Callable[[], None]]。另外,當 exactOptionalPropertyTypes 打開時,呼叫端不能傳 undefined 給 optional 欄位——必須「不傳」或「傳值」,這對 React props 的語意更精準。
完整實作:為預約系統建立型別檔
把以上觀念整合起來,建立一個「預約系統」的型別檔。這個檔案會從 Day 4 一直被沿用到 Day 45,先把基礎打好:
// src/types/booking.ts
// 預約系統的核心型別:會被前端所有頁面與 API 層引用
// 預約狀態:列舉四種合法值,編譯器會檢查 switch 是否窮舉
export type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed";
// 服務品項:對應 Web 系列 FastAPI 的 Service 模型
export type Service = {
id: number;
name: string;
durationMinutes: number;
price: number;
};
// 預約單:對應 Web 系列的 Booking 模型
export type Booking = {
id: number;
customerName: string;
customerPhone: string;
serviceId: number;
startAt: string; // ISO 8601,例如 "2026-03-20T10:00:00+08:00"
status: BookingStatus;
notes?: string; // 顧客備註(可選)
createdAt: string;
updatedAt: string;
};
// 建立預約的輸入:省略 id / createdAt / updatedAt / status
export type BookingCreateInput = {
customerName: string;
customerPhone: string;
serviceId: number;
startAt: string;
notes?: string;
};
// API 回應的包裝:對齊 FastAPI 的標準回應
export type ApiResponse<T> = {
data: T;
meta?: { total?: number; page?: number };
};
export type ApiError = {
code: string;
message: string;
details?: Record<string, string>;
};
這段展示了四個實務技巧:第一,用字串聯合型別("pending" | "confirmed" | ...)表達「列舉值」,比 enum 更輕量;第二,把「建立輸入」與「完整資料」分開(BookingCreateInput 沒有 id / 時間戳記 / 狀態),呼應後端 Pydantic 的 Create vs Read schema;第三,ApiResponse<T> 用泛型包裝所有 API 回應,未來接 FastAPI 時可以一個型別對應所有 endpoint;第四,ApiError 把錯誤格式統一,UI 層就能用一致的方式處理錯誤訊息(Day 37 會展開)。
存檔後跑 pnpm typecheck,TypeScript 應該靜默通過。接著可以寫一支測試函式,確認型別真的會把關:
// src/types/booking.test-draft.ts
// 故意寫錯的版本:體驗編譯器的把關能力
import type { Booking, BookingCreateInput } from "./booking";
const ok: BookingCreateInput = {
customerName: "王小明",
customerPhone: "0912345678",
serviceId: 1,
startAt: "2026-03-20T10:00:00+08:00",
};
// 故意寫錯:phone 是空字串,serviceId 是負數
// 編譯器會報「Type '""' is not assignable to type 'string | undefined'」
// 但因為型別系統不檢查「值的合理性」,要靠 zod / valibot 等 runtime 驗證
const bad: BookingCreateInput = {
customerName: "",
customerPhone: "",
serviceId: -1,
startAt: "not-an-iso-date",
};
這段示範 TypeScript 的「邊界」:它能檢查「型別不符」(例如傳字串給 number),但不能檢查「值的合理性」(例如電話號碼格式、日期是否合法)。後者要靠 Zod、Valibot 等 runtime 驗證庫,Day 12 與 Day 21 會展開。
常見錯誤與踩雷
第一次用 TypeScript 寫程式,最常踩的雷有三個。第一個是「any 滿天飛」。很多新手遇到編譯錯誤第一反應是加 : any,結果 TypeScript 的保護全部失效。正確做法是先讀懂錯誤訊息,多半是「少標一個型別」、「忘記處理 null」這種小事;真的要逃脫型別檢查時,用 unknown(強迫你先檢查才能用)比 any 安全。
第二個是「把 interface 與 type 混用」。同一個專案裡,有人喜歡 interface、有人喜歡 type,結果程式碼風格不一致、增加 onboarding 成本。建議團隊在 README.md 或 STYLE.md 寫明「一律使用 type」,把規則講清楚。本系列一律用 type。
第三個是「忘了開 strict」。很多教學文章為了讓範例簡單,會把 strict: false。但只要專案變大、沒開 strict 帶來的 null/undefined bug 會遠超過「寫 strict 時多寫的兩行程式碼」。建議從 Day 1 就開 strict: true,並把 noUncheckedIndexedAccess、exactOptionalPropertyTypes 也打開,習慣之後寫什麼都會更安心。
效能與實務提醒
TypeScript 的型別檢查在大型專案會變慢。Vite 用 esbuild 做轉譯(不檢查型別),所以 dev server 啟動很快;但 tsc --noEmit 會掃全部檔案做完整檢查,檔案超過幾千個的時候可能要十幾秒。可以考慮用「增量編譯」或「專案參考」(project references)來加速:
{
"compilerOptions": {
"composite": true,
"incremental": true
}
}
incremental: true 會把上次的編譯結果存在 .tsbuildinfo,下一次只檢查有改動的檔案;composite: true 是 monorepo 才用得到,告訴 TypeScript「這個 tsconfig 是某個專案的入口」。本系列規模不大,Day 31 之後進入預約系統才會考慮這個。
另一個實務建議:把 tsconfig.json 拆成多份,對應不同的執行環境。React 前端用「瀏覽器 + ES2022」、Node.js 腳本用「Node + ES2023」、共享型別用「base」。
// tsconfig.json(根目錄,只是「專案參考」的入口)
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
// tsconfig.app.json(瀏覽器端 React)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}
// tsconfig.node.json(Node.js 端,例如 vite.config.ts)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": ["node"],
"noEmit": true
},
"include": ["vite.config.ts"]
}
這份三層架構是 Next.js 預設的結構:根目錄只放 references、app 給瀏覽器、node 給伺服器腳本。Day 17 開始用 create-next-app 會直接生成這套,可以拿來對照。
怎麼讀 TypeScript 的錯誤訊息
第一次看到 TypeScript 報錯的人,常會被那一大段英文嚇到。其實大部分錯誤訊息只告訴你三件事:「出錯的位置」、「預期的型別」、「實際的型別」。只要讀懂這三欄,幾乎所有錯誤都能自己解。下面示範幾個常見訊息的解讀:
- "Type 'string | null' is not assignable to parameter of type 'string'":你傳了一個可能是 null 的值給預期是 string 的參數。修法是先檢查
if (x !== null)再用。 - "Property 'foo' does not exist on type 'Bar'":你在一個物件上存取了一個它沒有的欄位。先確認型別定義,或是用
in運算子先判斷。 - "Object is possibly 'undefined'":因為
noUncheckedIndexedAccess,陣列或字典取值可能是 undefined。修法是用?.或先檢查長度。 - "Argument of type 'X' is not assignable to parameter of type 'Y'":函式呼叫的參數型別不符。要嘛改呼叫端、要嘛改函式簽章、要嘛中間加一層轉換。
讀懂錯誤訊息有一個小技巧:把訊息中的兩個型別抄到一行筆記裡(「我要把 X 放進 Y」),然後問自己「X 真的可以放進 Y 嗎?」。如果不能,要嘛改 X、要嘛改 Y、要嘛在中間做轉換。VS Code 把滑鼠移到紅色底線的變數上,會顯示更完整的推論路徑(Quick Info),這是新手最常忽略的除錯利器。
另一個實務技巧是「看錯誤碼」。TypeScript 5.x 的錯誤訊息前面會帶一個數字編號(例如 TS2322),這個編號對應到官方 issue tracker 裡的具體問題描述。遇到看不懂的錯誤,把 TS2322 丟到搜尋引擎,比直接讀英文訊息更有效率。本系列 Day 4 會介紹更多收窄技巧,讓你可以主動避開這些錯誤,而不是被動地修它。
小結
今天我們把 TypeScript 的基礎打底了:結構型子型化、tsconfig.json 的關鍵選項(含 strict 與 noUncheckedIndexedAccess)、原始型別、陣列、元組、物件、type 與 interface 的差別、optional 欄位的寫法,並建立了一份預約系統的核心型別檔。明天 Day 4 我們會把 TypeScript 推得更深:泛型(ApiResponse<T>、useState<T> 都需要它)、型別收窄(用 typeof、in、instanceof 把「可能是多種型別」的變數縮到一種)、以及工具型別(Pick、Omit、Partial、Readonly、Record)。到時候寫 React 元件時,「型別即契約」的威力就會完全展現出來。
結語
明天,我們會把 TypeScript 的進階語法一次學完:泛型讓你能寫「可重用的型別函式」、型別守衛(type guard)讓你在 runtime 縮小 union、工具型別讓你從現有型別衍生新形狀而不必重寫。Day 4 結束時,你應該能讀懂 React 與 Next.js 原始碼裡那些「型別簽名」,並且知道怎麼把這些技巧套用到自己的專案。Day 5 接著進入 React 入門——元件、props 與 JSX,正式從「型別」跨進「畫面」。
延伸資源
- TypeScript Handbook〈The Basics〉(5.9):
https://www.typescriptlang.org/docs/handbook/2/basic-types.html - TypeScript Handbook〈Everyday Types〉:
https://www.typescriptlang.org/docs/handbook/2/everyday-types.html - TypeScript Handbook〈tsconfig.json〉:
https://www.typescriptlang.org/docs/handbook/tsconfig-json.html - TypeScript 官方 Cheat Sheets(含 React 範例):
https://www.typescriptlang.org/cheatsheets - React 官方 TypeScript 指南:
https://react.dev/learn/typescript
留言
張貼留言