📘 Total TypeScript Essentials
🧩 型別塑形
🎯 什麼情境該想到我
當你的型別變成一個「選配欄位袋(bag of optionals)」——一堆 ?: 欄位,而且沒人說得清哪些欄位會同時出現的時候。
⚙️ 怎麼用(步驟 / 公式)
病徵:太鬆的型別
type State = {
status: 'loading' | 'success' | 'error'
error?: string
data?: string
}問題有兩層:
- 存取時型別不準——判斷完
status === 'error',state.error依然是string | undefined。 - 能造出不可能發生的值——
{ status: 'loading', error: '...', data: '...' }完全合法。
解法:把欄位綁到它真正屬於的那個狀態
type State =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: string }- 選一個判別欄位(discriminant):一個字面量型別、且每個成員都不同的共同屬性(這裡是
status)。 - 把每個狀態拆成獨立的物件型別。
- 只把該狀態真正需要的資料放進去。
現在判斷完 status === 'error',TS 的**收窄(narrowing)**會讓 state.error 直接是 string,不用再檢查 undefined。
成員多了可以各自抽成具名 type alias 再組成聯集,維持可讀性。
心法
讓不可能的狀態變成編不過的程式碼,而不是靠註解或紀律去記「loading 的時候不要看 error」。
4. ★ 窮盡性檢查:讓「漏處理」變成編譯錯誤
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.side ** 2;
default: {
// ★ 加了新的 kind 卻忘了處理 → s 不再是 never → 這一行編譯錯誤
const _exhaustive: never = s;
throw new Error(`unhandled shape: ${JSON.stringify(_exhaustive)}`);
}
}
}★ 這是 TS 相對 Go 的一個實質優勢。
Go 的 switch 漏一個 case 只會在執行期走到 default;
TS 用 never 讓編譯器幫你檢查——加一個新的變體,所有沒處理它的地方全部編譯失敗。
實務價值:新增一種訂單狀態、一種事件類型、一種錯誤種類時,
編譯器會列出所有需要更新的地方,而不是等到線上才發現漏了一處。
更簡潔的寫法(把它抽成工具函式):
export function assertNever(x: never): never {
throw new Error(`unexpected: ${JSON.stringify(x)}`);
}
// default: return assertNever(s);5. ★ 讓「不合法的狀態」根本無法被表示
這是判別聯集最有價值的用法,而且它解決的是設計問題不是語法問題:
// ❌ 用可選欄位:四種欄位組合,但只有三種是合法的
type Request = {
status: "loading" | "success" | "error";
data?: Data; // ★ status 是 success 時才該有
error?: Error; // ★ status 是 error 時才該有
};
// → 每次用 data 都要 `if (req.data)`,而且 {status:"success"} 沒有 data 也編得過
// ✅ 判別聯集:不合法的組合根本不存在
type Request =
| { status: "loading" }
| { status: "success"; data: Data } // ★ 保證有 data
| { status: "error"; error: Error }; // ★ 保證有 error
if (req.status === "success") {
render(req.data); // ★ 不用檢查,型別已經保證了
}★ “Make illegal states unrepresentable.”
每個可選欄位(?)都是一次「我沒想清楚」的自白——
它讓合法狀態的數量變成 2n,而其中大部分是無意義的。
6. ★ 實務上最常用的三個場景
① API 回應
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
const r = await fetchUser(id);
if (!r.ok) return handle(r.error);
use(r.value); // ★ 這裡 value 保證存在★ 這是 Go 的 (T, error) 在 TS 裡的對應,而且比 try/catch 更能讓型別系統參與。
② 狀態機(見 狀態模式)
③ 事件 / Action(Redux 的 action 就是判別聯集,見 命令模式)
7. 判別欄位的選擇
| 做法 | 評價 |
|---|---|
kind / type / status 的字串字面量 | ★ 最常用,可序列化、好讀、好 debug |
success: true / false 布林 | 只有兩種時可以,多於兩種就不行 |
用 in 運算子判斷欄位存在 | ⚠️ 可行但脆弱,重構時容易壞 |
class + instanceof | 不可序列化(JSON 來回會失去原型),跨網路邊界不能用 |
★ 要跨網路或存進資料庫 → 一定用字串字面量的判別欄位,因為它 JSON 化之後還在。
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 判別欄位必須是字面量型別。宣告成
kind: string就失去縮窄能力——
用as const或直接在 type 裡寫字面量(見 工具-as-const與推論寬窄)。 - ★
default裡的never檢查在strict關閉時無效。要開strictNullChecks(見 工具-tsconfig基準設定)。 - 從外部來的資料不會自動符合型別。
JSON.parse回傳any——
★ 邊界上一定要驗證(zod / valibot / 手寫 type guard),否則型別只是自我安慰。 - 變體太多會很囉嗦。超過七八個變體時,考慮用「共同欄位 + 一個小的判別聯集」而不是全部攤平。
- 不要為了「以後可能有第二種」而先做成聯集。一種就是一種。
- Go 沒有這個。Go 的對應做法是 struct + 一個
Kind欄位 + 註解說明「哪些欄位在哪些 Kind 下有效」——
沒有編譯期保證。跨語言的 API 設計要注意這個落差(見 訪問者模式)。 - 窮盡性檢查只對「聯集」有效,對
enum或string沒用。
🔗 相關工具
- 工具-as-const與推論寬窄 —— 判別欄位為什麼要是字面量
- 工具-satisfies與型別標註 —— 保留字面量型別又檢查形狀
- 工具-tsconfig基準設定 —— strict 是前提
- 工具-型別推導還是解耦 —— 型別該推導還是該明寫
- 訪問者模式 —— ★ TS 用判別聯集完勝訪問者模式
- 狀態模式 —— 讓不合法的狀態轉換無法被表示
- 命令模式 —— Redux action 就是判別聯集
- Total TypeScript Essentials
🎯 推論與標註
🎯 什麼情境該想到我
當你「幫變數標了型別,結果存取它的 key 反而壞掉」的時候。
⚙️ 怎麼用(步驟 / 公式)
三條規則,記這個就夠
| 寫法 | 誰贏 | 後果 |
|---|---|---|
const x: T = {...} | 變數贏 | 值通過檢查,然後值的型別被丟掉,只剩 T |
const x = {...} | 值贏 | 保留精確推論,但沒有任何約束 |
const x = {...} satisfies T | 兩者兼得 | 用 T 檢查,但保留值的推論結果 |
為什麼標註會壞事
const config: Record<string, Color> = {
foreground: { r: 255, g: 255, b: 255 },
border: "transparent",
};
config.foreground.r; // ❌ 錯:TS 只知道值是 Color,不知道 foreground 存在標註之後 TS 只記得「這是一個 Record<string, Color>」——它不知道有哪些 key,也不知道每個 key 是 Color 的哪一支。
換成 satisfies
const config = {
foreground: { r: 255, g: 255, b: 255 },
border: "transparent",
} satisfies Record<string, Color>;
config.foreground.r; // ✅
config.border.toUpperCase(); // ✅ TS 知道這支是 string
config.primary = 123; // ✅ 仍然會被擋下來satisfies 也會收窄
常被誤解成「不影響值的型別」,其實會:
const album = { format: "Vinyl" } satisfies Album;
// album.format 推論為 "Vinyl",不是 "CD" | "Vinyl" | "Digital"所以要把它傳給只吃 "Vinyl" 的函式時,satisfies 可行、標註不行。
4. ★ 三種寫法的完整對照
type Config = Record<string, { url: string; retries: number }>;
// ① 標註(annotation)—— 檢查了,但★ 具體資訊被吃掉
const a: Config = { prod: { url: "...", retries: 3 } };
a.prod; // ★ 可以,但 a.typo 也「可以」(因為型別是 Record<string, ...>)
// ② as(斷言)—— ★ 不檢查,只是叫編譯器閉嘴
const b = { prod: { url: "...", retries: "3" } } as Config; // ★ retries 是字串也不報錯!
// ③ satisfies —— ★ 檢查形狀,同時保留具體推論
const c = { prod: { url: "...", retries: 3 } } satisfies Config;
c.prod; // ✅
c.typo; // ★ 編譯錯誤:Property 'typo' does not exist| 檢查形狀 | 保留具體型別 | 錯誤時會報 | |
|---|---|---|---|
: Type 標註 | ✅ | ❌ 被拓寬成標註的型別 | ✅ |
as Type 斷言 | ❌ | ⚠️ 變成斷言的型別 | ❌ 危險 |
★ satisfies Type | ✅ | ✅ | ✅ |
★
as不是型別轉換,是「我保證,別檢查」。
它是 TS 裡最容易製造執行期錯誤的語法——看到as就該問「為什麼需要它」。
(少數正當用途:as const、縮窄unknown、跟型別定義不完整的第三方庫打交道。)
5. ★ 最有價值的組合:satisfies + keyof typeof
const routes = {
home: { path: "/", auth: false },
profile: { path: "/me", auth: true },
admin: { path: "/admin", auth: true },
} satisfies Record<string, { path: string; auth: boolean }>;
type RouteName = keyof typeof routes; // ★ "home" | "profile" | "admin"
function go(name: RouteName) { ... }
go("home"); // ✅
go("hom"); // ★ 編譯錯誤★ 這個組合解決了「兩份清單要同步維護」的問題:
型別RouteName從實作自動推導出來——加一個路由,型別自動跟著更新。
不用維護一個type RouteName = "home" | "profile" | "admin"的重複定義。
Go 沒有這個能力:Go 得手動維護 const 列舉與 map 兩份,加一筆忘了另一邊也不會報錯。
6. 三個高頻實務場景
① 設定物件的預設值
const defaults = { timeout: 30_000, retries: 3 } satisfies Partial<Options>;
const opts = { ...defaults, ...userOpts }; // ★ 見 [[建造者模式]]② 策略表
const handlers = {
create: (p: CreatePayload) => { ... },
update: (p: UpdatePayload) => { ... },
} satisfies Record<string, (p: any) => void>;
type Action = keyof typeof handlers; // ★ 自動推導→ 見 策略模式
③ 顏色/常數表
const theme = {
primary: "#0af",
danger: "#f33",
} satisfies Record<string, `#${string}`>; // ★ 檢查是合法的 hex 形狀
theme.primary; // ★ 型別是 "#0af"(字面量),不是 string7. ★ 什麼時候該用「標註」而不是 satisfies
用標註 : T | 用 satisfies T |
|---|---|
| 函式參數與回傳值(★ 這是 API 契約,應該明寫) | 常數/設定物件 |
| 希望隱藏具體實作細節 | 希望保留具體型別供後續推導 |
| 變數之後會被重新賦值成別的值 | 值是固定的 |
★ 判準:這個型別是「契約」還是「檢查」?
契約(別人依賴它)→ 標註;只是想確認我寫對了 → satisfies。
(更完整的討論見 工具-型別推導還是解耦。)
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★
satisfies需要 TypeScript 4.9+。舊專案要先升版。 - ★ 不要用
satisfies取代所有標註。函式簽章應該明寫型別——
那是 API 契約,讓它被推導會讓改動意外地變成 breaking change(見 工具-型別推導還是解耦)。 satisfies不會讓值變成不可變。要不可變要配as const(見 工具-as-const與推論寬窄):const x = { a: 1 } as const satisfies Config; // ★ 兩個一起用as的正當用途很少。看到它就該問:是型別定義不夠好,還是我在騙編譯器?
as unknown as T幾乎一定是設計問題。- ★ 執行期沒有型別。
satisfies只在編譯期檢查;外部資料(API 回應、JSON)仍然要執行期驗證
(zod / valibot),否則型別只是裝飾。 - 推導出來的型別會出現在錯誤訊息與 hover 裡。巨大的物件字面量會讓型別提示變得難讀——
這時明確標註反而更好維護。
🔗 相關工具
- 工具-as-const與推論寬窄 —— 另一半:怎麼控制推論的寬窄
- 工具-型別推導還是解耦 —— ★ 什麼時候該推導、什麼時候該明寫
- 工具-判別聯集 —— 判別欄位需要字面量型別
- 工具-tsconfig基準設定 —— 前置設定
- 策略模式 ——
satisfies+keyof typeof的策略表 - 建造者模式 —— TS 用物件字面量取代 Builder
- Total TypeScript Essentials
🎯 什麼情境該想到我
當你「明明傳的就是那個字串,TS 卻說 string 不能指派給 "rock" | "country"」的時候。
⚙️ 怎麼用(步驟 / 公式)
先理解為什麼會被推寬——TS 是照「還能不能被改」推的
| 宣告 | 推論結果 | 原因 |
|---|---|---|
let x = "rock" | string | let 可以重新指派,所以推寬以容納未來的值 |
const x = "rock" | "rock" | const 不能重新指派,可以安全地推成字面量 |
const o = { s: "rock" } | { s: string } | ⚠️ 物件屬性即使在 const 下仍可變,所以照樣推寬 |
第三列是最常踩的坑:你用了 const,但物件裡面的屬性還是被推成 string。
三種修法,按情境挑
// 1. 標註變數:可以重新指派,但限制在聯集內
let genre: AlbumGenre = "rock";
// 2. 標註物件:整體受約束(但會抹掉精確推論)
const attrs: AlbumAttributes = { status: "on-sale" };
// 3. as const:整個結構凍成唯讀的字面量
const attrs = { status: "on-sale" } as const;as const 拿來做 JS 風格的列舉
這是它最有價值的用法之一——一份來源,同時給你執行期的值與型別:
const albumTypes = {
CD: "cd",
VINYL: "vinyl",
DIGITAL: "digital",
} as const;
type AlbumType = (typeof albumTypes)[keyof typeof albumTypes];
// "cd" | "vinyl" | "digital"比起手寫一份 type AlbumType = "cd" | "vinyl" | "digital",這樣不會有兩份會走鐘的來源。
4. ★ 推論寬窄的完整規則表
let a = "hello"; // string ★ let → 拓寬
const b = "hello"; // "hello" ★ const → 字面量
const o = { k: "v" }; // { k: string } ★ 物件屬性「仍然」被拓寬
const o2 = { k: "v" } as const; // { readonly k: "v" }
const arr = [1, 2]; // number[] ★ 陣列被拓寬
const t = [1, 2] as const; // readonly [1, 2] ★ 變成元組(tuple)★ 最容易踩的一條:
const只讓「變數本身」不可重新賦值,不讓「內部屬性」保持字面量。
所以const o = { kind: "circle" }的o.kind型別是string,
拿去當判別聯集的判別欄位就失效了(見 工具-判別聯集)。
5. ★ 三個 as const 真正必要的場景
① 判別聯集的判別欄位
const action = { type: "increment", by: 1 };
dispatch(action); // ★ 錯誤:type 是 string,不符合 Action 聯集
const action = { type: "increment", by: 1 } as const;
dispatch(action); // ✅② 從常數表推導型別(★ 最有價值的用法)
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number]; // ★ "admin" | "editor" | "viewer"
function can(r: Role) { ... }
can("admin"); // ✅
can("admn"); // ★ 編譯錯誤
// 執行期也能用同一份資料
if (ROLES.includes(input as Role)) { ... }★ 「一份資料,同時給執行期用和給型別用」 —— 這是 TS 最實用的型別技巧之一。
不用維護type Role = ...和const ROLES = [...]兩份會不同步的清單。
③ 元組(tuple)
function useToggle(): [boolean, () => void] { ... }
// 內部 return [v, fn] as const ← ★ 否則會被推成 (boolean | (() => void))[]6. ★ as const vs satisfies:該用哪個
| 你要的 | 用 |
|---|---|
| 保留字面量 + 變成 readonly | as const |
| 檢查形狀符合某個型別 + 保留字面量 | satisfies T |
| ★ 兩者都要 | as const satisfies T(順序不能反) |
const theme = {
primary: "#0af",
danger: "#f33",
} as const satisfies Record<string, `#${string}`>;
// ★ 既檢查了是合法 hex,又保留了 "#0af" 這個字面量,而且是 readonly7. readonly 的邊界(常見誤解)
const c = { a: 1 } as const;
c.a = 2; // ★ 編譯錯誤
// ★ 但執行期完全沒有保護
const d = c as { a: number };
d.a = 2; // ★ 編譯通過,而且真的改掉了★
readonly是純編譯期的。執行期要真正凍結要用Object.freeze()——
而Object.freeze只凍一層(巢狀物件仍可改)。兩者是互補的,不是替代:
as const給開發期的保護,Object.freeze給執行期的。
8. ★ 與 Go 的對照
Go 沒有字面量型別,const ROLES = []string{...} 無法產生型別。
Go 的作法是:
type Role string
const (
RoleAdmin Role = "admin"
RoleEditor Role = "editor"
)★ 但 Go 的這個做法不防止 Role("typo") —— 它只是個字串別名。
TS 的 typeof ROLES[number] 在這一點上是嚴格更強的。
(跨語言 API 設計時要知道這個落差,見 工具-判別聯集。)
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★
as const會讓整個結構變readonly(遞迴)。
之後要push、sort、splice就過不了——需要可變副本時用[...arr]展開。 - ★ 不要對「會變的資料」用
as const。它是給常數、設定、列舉用的,不是給執行期資料用的。 as const不是as。前者是推論指示(安全),後者是型別斷言(危險)。名字像但完全不同。- 巨大的
as const物件會讓型別提示與錯誤訊息很難讀,編譯也會變慢。 - ★ 執行期沒有保護(見上)。安全性相關的不可變要靠
Object.freeze或結構設計。 as const satisfies T的順序不能反(satisfies T as const不合法)。- 從 API 回來的資料不會有字面量型別。
JSON.parse是any——
邊界上要驗證 + narrow,as const幫不上忙。
🔗 相關工具
- 工具-satisfies與型別標註 —— ★ 兩者怎麼搭配
- 工具-判別聯集 —— 判別欄位為什麼需要字面量
- 工具-型別推導還是解耦 —— 推導的邊界
- 工具-tsconfig基準設定 —— strict 是前提
- 值物件 —— 不可變性的設計思路
- Total TypeScript Essentials
🗂 型別怎麼組織
🎯 什麼情境該想到我
當你猶豫「這個型別要從既有型別挖出來(Pick/keyof/typeof),還是老實再寫一份?」的時候。
⚙️ 怎麼用(步驟 / 公式)
先認識工具
| 語法 | 做什麼 |
|---|---|
keyof T | 把 T 的 key 抽成聯集 |
typeof v | 從值取出它的型別 |
T[K] | 索引存取:取出某個屬性的型別 |
Pick<T, K> / Omit<T, K> | 挑出/排除某些屬性 |
interface A extends B | 繼承 |
⭐ 判準:兩邊是不是同一個關注點?
該推導 —— 共用同一個關注點
const albumTypes = { CD: "cd", VINYL: "vinyl" } as const;
type AlbumType = (typeof albumTypes)[keyof typeof albumTypes];值和型別本來就是同一件事的兩面。解耦的話你要維護兩份會走鐘的來源。
該解耦 —— 關注點不同
// ❌ 誘人但耦合:UI 元件綁死資料層的型別
type AvatarImageProps = Pick<User, "imageUrl" | "name">;
// ✅ 各自寫清楚
type AvatarImageProps = { imageUrl: string; name: string };User 是資料型別、AvatarImage 是 UI 元件——責任不同、改變的理由也不同。用 Pick 之後,這個元件不只依賴 User 的形狀,還依賴它存在於 db.ts 這件事。
★ 核心判準:這個型別是「契約」還是「衍生」
| 契約(contract) | 衍生(derived) | |
|---|---|---|
| 誰依賴它 | ★ 外部(別的模組、別的團隊、別的服務) | 只有自己 |
| 改動的意義 | ★ breaking change,要有意識地做 | 跟著來源走就好 |
| 該怎麼寫 | ★ 明確標註 | ★ 從來源推導 |
| 例子 | 公開 API 的參數與回傳、跨服務的 DTO | 內部的中間型別、元件的 props |
★ 一句話:「改了實作,這個型別跟著變,是我要的嗎?」
是 → 推導;不是(那會默默破壞下游)→ 明寫。
★ 三個「應該推導」的場景
① 從實作推導列舉(見 工具-satisfies與型別標註)
const routes = { home: "/", admin: "/admin" } satisfies Record<string, string>;
type RouteName = keyof typeof routes; // ★ 加路由自動更新,不用維護兩份② 從 schema 推導型別(★ 實務上最有價值的一個)
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0),
});
type User = z.infer<typeof UserSchema>; // ★ 型別從驗證器推導
// ★ 一份定義,同時給「執行期驗證」和「編譯期型別」
const user = UserSchema.parse(await res.json());★ 這解決了 TS 最根本的弱點:編譯期型別在執行期不存在。
手寫type User+ 手寫驗證 = 兩份會不同步的定義;從 schema 推導 = 一份定義,永遠同步。
同理:sqlc(從 SQL 產型別)、OpenAPI codegen、Prisma、GraphQL codegen。
③ 從函式推導
type Params = Parameters<typeof myFunc>;
type Result = Awaited<ReturnType<typeof fetchUser>>;★ 三個「應該明寫」的場景
① 公開函式的簽章
// ❌ 讓回傳型別被推導 → 改實作可能意外改變公開 API
export function getUser(id: string) {
return { id, name: "x" }; // ★ 多加一個內部欄位就變成 API 的一部分
}
// ✅ 明寫契約
export function getUser(id: string): User { ... }② 需要隱藏實作細節時 —— 標註成介面,讓呼叫端不能依賴你多回傳的東西。
③ ★ 讓型別錯誤出現在「對的地方」
// ❌ 不標註:錯誤出現在「使用的地方」,離源頭很遠
const config = { retries: "3" };
doWork(config); // ★ 這裡才報錯
// ✅ 標註:錯誤出現在「定義的地方」
const config: Config = { retries: "3" }; // ★ 這裡就報錯★ 標註的位置決定了錯誤訊息的位置——這是很實用的一條。
衍生型別的工具(不要複製貼上)
type PublicUser = Omit<User, "password">;
type UserUpdate = Partial<Omit<User, "id">>;
type UserCreate = Pick<User, "email" | "password">;
type UserId = User["id"];★ 但衍生也會過頭。
Partial<Omit<Pick<User,"a"|"b">,"c">>沒有人看得懂,
hover 出來的提示也是一大坨。超過兩層就給它一個名字。
★ 和 Go 的對照
Go 沒有型別層的運算——沒有 Omit、Partial、keyof。對應做法是定義多個 struct(手動維護、會不同步),
或用 go:generate 產生(sqlc、oapi-codegen)。
★ 和 TS 的 z.infer 是同一個問題的兩種解法。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
- ★ 公開 API 一定要明寫。被推導的回傳型別會讓「內部重構」意外變成 breaking change。
- ★ 過度衍生會讓型別無法閱讀。三層以上的
Partial<Omit<...>>要拆開命名。 - ★ 型別關聯太緊也是一種耦合。
type B = A讓 A 改動直接波及 B——
如果 B 在語義上是獨立的概念(只是「現在剛好一樣」),就該分開寫。
判準:「A 改了,B 應該跟著改嗎?」 應該 → 關聯;不一定 → 分開。 z.infer的代價是執行期的驗證成本。熱路徑上的大物件驗證要量。- 推導出來的型別出現在錯誤訊息裡會很長,會讓錯誤難讀、編譯變慢。
- 不要為了「少打字」而推導。可讀性與契約的穩定性遠比打字量重要。
- Go 沒有這些,跨語言的共用型別要靠 codegen 或 schema(Protobuf、OpenAPI)。
🔗 相關工具
- 工具-satisfies與型別標註 —— 檢查形狀又保留推導
- 工具-as-const與推論寬窄 —— 控制推論寬窄
- 工具-判別聯集 —— 衍生型別常用在聯集上
- 工具-tsconfig基準設定 —— strict 是前提
- 工具-資料編碼與演進 —— 跨服務的型別要靠 schema 而非語言型別
- 工具-讓LLM產出可驗證的產物 —— 同樣是「一份 schema 兩邊用」的思路
- Total TypeScript Essentials
⚙️ 專案設定
🎯 什麼情境該想到我
當你「開一個新的 TS 專案,對著空白的 tsconfig.json 發呆」的時候。
⚙️ 怎麼用(步驟 / 公式)
第一步:先貼這份基準(適用多數應用)
{
"compilerOptions": {
/* Base Options */
"skipLibCheck": true, // 跳過 .d.ts 的型別檢查,加快編譯
"target": "es2022", // 隨時間往上調
"esModuleInterop": true, // CJS 與 ESM 的互通性
"allowJs": true, // 允許引入 .js
"resolveJsonModule": true, // 允許引入 .json
"moduleDetection": "force", // 所有 .ts 都當成 module,不是 script
"isolatedModules": true, // 每個檔案都能被獨立轉譯
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true // ⭐ 見下方
}
}第二步:問自己四個問題,決定要補什麼
| 問題 | 要加的設定 |
|---|---|
| 用 tsc 轉譯嗎? | "module": "NodeNext",並設 outDir、sourceMap、verbatimModuleSyntax |
| 不是用 tsc(esbuild/Babel 等)? | "module": "Preserve" + "noEmit": true |
| 要做函式庫嗎? | "declaration": true;monorepo 再加 "composite": true 與 "declarationMap": true |
| 程式跑在 DOM 嗎? | 是 → "lib": ["dom", "dom.iterable", "es2022"];否 → ["es2022"] |
⭐ noUncheckedIndexedAccess 是最值得單獨提的一項
它讓索引存取回傳 T | undefined,逼你處理「取不到」的情況。strict: true 不含這一項,但沒有它,arr[999] 會被當成一定有值——這正是最典型的執行期爆炸。
順帶:import type 的意義不只是好看
純型別的 import 在編譯後會被移除(elide)。問題是模組可能有副作用(例如頂層的 console.log):
import type { Album } from "./album"; // 整行都會被移除
import { type Album, createAlbum } from "./album"; // 只移除型別部分verbatimModuleSyntax 就是要求你把這件事講清楚,避免「引入被悄悄拿掉、副作用沒跑」這種難查的問題。
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
target會過期。書自己就說「等你讀到這本書時,可能該指定更新的版本了」——這份設定要定期回顧。skipLibCheck: true是速度與嚴謹的取捨:它跳過依賴的.d.ts檢查,所以第三方型別的錯不會被你發現。多數專案划算,但要知道自己放棄了什麼。noUncheckedIndexedAccess導入既有專案會炸出一堆錯。新專案直接開;舊專案要排時間。- 框架的 starter 通常已經設好了(Next.js、Vite…),不要無腦覆蓋掉——先看它為什麼那樣設。
🔗 相關工具
- 工具-建構的先決條件 —— 專案開場該先確立的事,設定檔是其中最容易被跳過的一項
- 工具-防禦式編程 ——
noUncheckedIndexedAccess就是把防禦式思維交給編譯器執行