Nullability & never
strictNullChecks: setting ที่สำคัญที่สุด
หัวข้อที่มีชื่อว่า “strictNullChecks: setting ที่สำคัญที่สุด”flag ที่มีค่ามากที่สุดตัวเดียวคือ strictNullChecks (เป็นส่วนหนึ่งของ strict) ถ้าไม่เปิด null และ undefined assign เข้า ทุก type ได้ — billion-dollar mistake แบบเต็มพิกัด พอเปิด จะกลายเป็น type ของตัวเองที่คุณต้องจัดการอย่างชัดเจน
// @expect-error TS2345// with strictNullChecks on:function len(s: string) { return s.length; }
let maybe: string | undefined;len(maybe); // ❌ Argument of type 'string | undefined' is not assignable to 'string'error นี่แหละคือประเด็น: compiler บังคับให้คุณจัดการเคส undefined ก่อน จะ dereference ตั้งแต่ TypeScript 6.0 strict — และ strictNullChecks ที่มากับมัน — เปิดเป็นค่าเริ่มต้น โปรเจกต์ต้องตั้งใจ ปิด เอง ถ้าคุณรับ codebase ที่ตั้ง strictNullChecks: false ไว้ การเปิดกลับคือ migration ที่ให้ผลตอบแทนสูงสุดที่คุณทำได้
?. และ ?? เข้าใจ type
หัวข้อที่มีชื่อว่า “?. และ ?? เข้าใจ type”operator สองตัวทำให้ nullability ใช้ง่ายขึ้น และ type system เข้าใจทั้งคู่:
type User = { profile?: { avatar?: string } };
function avatar(u: User) { const url = u.profile?.avatar; // type: string | undefined return url ?? "default.png"; // type: string (?? supplied a fallback)}?. short-circuit เป็น undefined ถ้าฝั่งซ้าย nullish และ type ก็สะท้อนสิ่งนั้น (ผลลัพธ์รวม undefined) ?? ให้ fallback เฉพาะกรณี null/undefined (ต่างจาก || ที่ trigger กับ 0 และ "" ด้วย) และ narrow type โดยตัดส่วน nullish ออก
|| vs ??: 0, "", และ false ก็เป็น value ที่ valid เหมือนกัน
หัวข้อที่มีชื่อว่า “|| vs ??: 0, "", และ false ก็เป็น value ที่ valid เหมือนกัน”วงเล็บนั้นสมควรมี section ของตัวเอง เพราะมันคือหนึ่งใน bug จริงที่เจอบ่อยที่สุดใน codebase ที่เก่ากว่า ?? (เพิ่มมาใน TypeScript 3.7 / ES2020) || fallback เมื่อ value เป็น falsy อะไรก็ได้ — 0, "", false, NaN, null, undefined — ไม่ใช่แค่สองตัวที่ “หายไปจริง ๆ”:
interface ServerConfig { timeout?: number; label?: string; verbose?: boolean }
function withDefaults(cfg: ServerConfig) { const timeout = cfg.timeout || 30; // bug: 0 is a valid timeout, but falls back to 30 const label = cfg.label || "server"; // bug: "" is a valid label, but falls back const verbose = cfg.verbose || true; // bug: false is a valid choice, always becomes true return { timeout, label, verbose };}
console.log(withDefaults({ timeout: 0, label: "", verbose: false }));|| silently overrides every falsy-but-valid value
interface ServerConfig { timeout?: number; label?: string; verbose?: boolean }
function withDefaults(cfg: ServerConfig) { const timeout = cfg.timeout || 30; // bug: 0 is a valid timeout, but falls back to 30 const label = cfg.label || "server"; // bug: "" is a valid label, but falls back const verbose = cfg.verbose || true; // bug: false is a valid choice, always becomes true return { timeout, label, verbose };}
console.log(withDefaults({ timeout: 0, label: "", verbose: false })); รันดูแล้ว ทุก falsy value ที่ตั้งใจเลือกไว้ชัดเจนจะถูกทิ้งอย่างเงียบ ๆ — { timeout: 30, label: 'server', verbose: true } ไม่มีอันไหนตรงกับที่ caller ขอเลย ?? เช็คเฉพาะ null/undefined จึงปล่อย 0, "", และ false ไว้เฉย ๆ:
interface ServerConfig { timeout?: number; label?: string; verbose?: boolean }
function withDefaults(cfg: ServerConfig) { const timeout = cfg.timeout ?? 30; // only null/undefined fall back -- 0 survives const label = cfg.label ?? "server"; // "" survives const verbose = cfg.verbose ?? true; // false survives return { timeout, label, verbose };}
console.log(withDefaults({ timeout: 0, label: "", verbose: false }));// { timeout: 0, label: '', verbose: false }รูปแบบเดียวกันนี้ใช้กับ assignment form ที่เพิ่มมาพร้อม ??: ||= assign เฉพาะเมื่อ value ปัจจุบัน falsy ??= assign เฉพาะเมื่อ nullish
let retries = 0;retries ||= 5; // 0 is falsy -- ||= treats it as "unset" and overwrites itconsole.log(retries); // 5
let attempts = 0;attempts ??= 5; // 0 is not nullish -- ??= leaves it aloneconsole.log(attempts); // 0||/||= ยังเป็นเครื่องมือที่ถูกต้อง เมื่อคุณต้องการจริง ๆ ให้ “falsy value อะไรก็ถือว่าหายไป” (เคสที่ตั้งใจแบบนี้ก็มีจริง พบได้บ่อย) bug เกิดจากการหยิบมันมาใช้ตามความเคยชิน ทั้งที่ type จริง ๆ ยอมให้ 0, "", หรือ false เป็น value ที่ valid ได้ — ซึ่งการที่ ?./?? โฟกัสที่ nullability เป็นตัวเตือนให้ตรวจสิ่งนี้ก่อน
compiler narrow ได้มากกว่าเดิม
หัวข้อที่มีชื่อว่า “compiler narrow ได้มากกว่าเดิม”การปรับปรุง checker สามอย่างตั้งแต่ 5.5 ตัด boilerplate จัดการ null ที่เจอบ่อยออกไป — และจับความผิดพลาดเพิ่มได้อีกหน่อย:
- inferred type predicate (5.5) callback อย่าง
x => x !== undefinedถูก infer เป็นx is Birdให้เอง.filterจึง narrow ได้โดยไม่ต้องเขียน guard เอง ท่า.filter(Boolean) as Bird[]แบบเก่าไม่จำเป็นแล้ว - ตัวแปรที่ไม่เคยถูก assign (5.7) การอ่าน
letที่ไม่มี code path ไหน assign เลยเป็น error แม้จะอ่านจากใน nested function - เช็คที่ truthy หรือ nullish เสมอ (5.6) expression ที่ไม่มีทาง nullish ทางซ้ายของ
??หรือ function / regex literal ที่ถูกใช้เป็นเงื่อนไข เป็น error
// @expect-error TS2454 TS2869 TS18048interface Bird { name: string }declare const names: string[];declare const registry: Map<string, Bird>;
const birds = names.map(n => registry.get(n)).filter(b => b !== undefined);// ^? Bird[] — the predicate is inferred, no cast needed
function demo() { let result: number; function print() { console.log(result); } // ❌ used before being assigned (5.7)}
function limit(value: number, max?: number) { return value < max ?? 100; // ❌ left of ?? is never nullish (5.6) // value < (max ?? 100) is what you meant}non-null assertion ! — ปืนที่บรรจุกระสุน
หัวข้อที่มีชื่อว่า “non-null assertion ! — ปืนที่บรรจุกระสุน”! แบบ postfix บอก compiler ว่า “เชื่อฉัน อันนี้ไม่ null”:
const el = document.getElementById("app")!; // asserts non-nullel.innerHTML = "hi"; // no error — but crashes if #app doesn't exist! ลบ check จริงทิ้งโดยไม่มีการ verify ตอน runtime เลย บางครั้งคุณรู้มากกว่า compiler จริง ๆ — แต่ทุก ! คือจุดที่คุณ override safety net ไว้ และเป็นจุดที่การเปลี่ยนแปลงในอนาคตทำให้พังเงียบ ๆ ได้ เลือก check จริง (if (!el) throw ...) ที่ narrow อย่างซื่อสัตย์ดีกว่า เก็บ ! ไว้สำหรับเคสที่พิสูจน์ได้
การ model error: Result vs throw
หัวข้อที่มีชื่อว่า “การ model error: Result vs throw”exception ที่ throw มองไม่เห็นจาก type system — signature ของ function ไม่เคยบอกว่าอาจ throw อะไร Result type ทำให้ failure ชัดเจนและ type-checked:
declare const input: string;
type Result<T, E> = | { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> { const n = Number(s); if (!Number.isInteger(n) || n < 0) return { ok: false, error: "invalid port" }; return { ok: true, value: n };}
const r = parsePort(input);if (r.ok) r.value; // narrowed to numberelse r.error; // narrowed to stringตอนนี้ caller ต้อง จัดการทั้งสอง branch — compiler ไม่ยอมให้ลืม นี่คือ discriminated union (field ok เป็น tag) ซึ่งพาเราไปสู่เครื่องมือสุดท้าย
Exhaustiveness ด้วย never
หัวข้อที่มีชื่อว่า “Exhaustiveness ด้วย never”เมื่อคุณ switch บน discriminated union never การันตีว่าคุณจัดการครบทุกเคส assign value เป็น never ใน branch default: ถ้ามีการเพิ่ม variant ใหม่ การ assign นั้นจะ compile ไม่ผ่าน
type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.r ** 2; case "square": return s.side ** 2; default: const _exhaustive: never = s; // ✅ s is never here — all cases handled return _exhaustive; }}เพิ่ม variant triangle ทีหลังแล้วบรรทัด default จะ error: Type '{ kind: "triangle" ... }' is not assignable to type 'never' compiler เพิ่งเปลี่ยน “ฉัน update ทุก switch แล้วหรือยัง?” จากการไล่หาด้วยมือให้เป็น build error
flowchart TB u["Shape = circle or square"] --> c1["case circle: handled"] u --> c2["case square: handled"] c1 --> d["default: assign s to never"] c2 --> d d --> ok["compiles = all cases covered"] d --> err["new variant unhandled = compile error"]
Exhaustiveness นอก switch
หัวข้อที่มีชื่อว่า “Exhaustiveness นอก switch”ทริก assign เป็น never เจาะจงกับ branch default ของ switch เท่านั้น code จริงเต็มไปด้วย discriminated union ที่จัดการด้วย if/else if chain แทน — validation pipeline, reducer, router — และ pattern ที่ดูคล้ายกัน “แค่ throw ใน fallback” ให้ exhaustiveness ตอน compile-time เป็นศูนย์เลย แม้จะอ่านเหมือนมี:
type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; throw new Error("unhandled shape"); // "exhaustive" only by convention -- nothing checks it}โค้ดนี้ compile ผ่านตอนนี้ ด้วยสอง variant ที่จัดการครบ ทีนี้ลองเพิ่ม variant triangle เข้า Shape โดยไม่เปลี่ยนอะไรอื่นเลย:
type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; throw new Error("unhandled shape"); // triangle silently falls through to here at runtime}นี่ยัง compile ผ่านโดยไม่มี error เลย ไม่มีอะไรเกี่ยวกับ throw ที่บังคับให้ compiler เช็คว่าทุกเคสถูกจัดการก่อน — มันคือ exhaustiveness ตามธรรมเนียม ไม่ใช่ตามการพิสูจน์ และประเด็นทั้งหมดของทริก never ก็คือธรรมเนียมอย่างเดียวไม่พอ มี pattern สองแบบที่ให้ if/else if chain ได้การการันตีจริงแบบเดียวกับ never default ของ switch:
// @expect-error TS2345type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function assertNever(x: never): never { throw new Error(`unhandled variant: ${JSON.stringify(x)}`);}
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; return assertNever(s); // s is { kind: "triangle"; ... } here -- not never}assertNever รับแค่ never เท่านั้น ดังนั้นการส่ง s ที่ยังไม่ได้จัดการเข้าไปจึงเป็น compile error ทันทีที่มี variant ตกหล่น — ความล้มเหลวแบบเดียวกับที่ switch เวอร์ชัน never จับได้ ตอนนี้ใช้ได้กับ control-flow รูปแบบไหนก็ได้ ถ้าไม่อยากมี runtime helper เลย satisfies never ให้เช็คแบบเดียวกันโดยไม่มี function call เลย เป็นแค่ compile-time ล้วน ๆ:
// @expect-error TS1360type Shape = | { kind: "circle"; r: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function area(s: Shape): number { if (s.kind === "circle") return Math.PI * s.r ** 2; if (s.kind === "square") return s.side ** 2; s satisfies never; // ❌ TS1360: { kind: "triangle"; ... } does not satisfy never throw new Error("unhandled shape");}ทั้งสองแบบเปลี่ยน “ฉัน update ทุก if chain แล้วหรือยัง?” ให้กลับเป็น build error แบบเดียวกับที่ทริก switch ให้ — เวอร์ชันสอง variant ก่อนหน้าของแต่ละแบบ compile ผ่านโดยไม่มี error เลย และการเพิ่ม triangle ที่ยังไม่ได้จัดการเท่านั้นที่ทำให้ build พัง ตรงจุดที่ควรพังพอดี
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”exhaustiveness ของ never ไม่ใช่ language feature ที่ compiler รันให้เอง — มันคือหลักฐานเล็ก ๆ ที่คุณสร้างเองด้วยมือ ครั้งเดียว แล้ว compiler ก็บังเอิญเก่งมากในการตรวจหลักฐาน default: const x: never = s ของ switch กับ assertNever(s) / s satisfies never ของ if chain คือหลักฐานเดียวกัน แค่ใส่ syntax ต่างกัน: “หลังจาก branch ที่มีชื่อทุกอันแล้ว สิ่งที่เหลืออยู่ต้องไม่มีอะไรเลย” throw เพียว ๆ พิสูจน์อะไรไม่ได้เลย — มันแค่ code ที่รัน ไม่มีความสัมพันธ์กับ type checker เลย ซึ่งคือเหตุผลที่มันเงียบยอมให้ variant ตกหล่นได้ ความต่างระหว่าง ?? กับ || ก็คือวินัยแบบเดียวกันในสเกลเล็กลง: รู้ให้แน่ชัดว่า check นั้นตัด value ชุดไหนออกจริง ๆ (null/undefined เทียบกับ “falsy ทุกอย่าง”) แล้วเลือก operator ที่ชุดที่มันตัดตรงกับช่วง value ที่ valid จริงของข้อมูลคุณ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| เทคนิค | ข้อดี | ต้นทุน |
|---|---|---|
?? / ??= | ถือว่ามีแค่ null/undefined เท่านั้นที่หายไป — 0, "", false รอดอยู่ | ไม่ช่วยเมื่อคุณ ต้องการ จริง ๆ ให้ “falsy value อะไรก็ถือว่าหายไป” |
| ` | / | |
switch + never default | บรรทัดเดียว อ่านเป็นธรรมชาติ ตรงกับ shape ของข้อมูล | ใช้ได้เฉพาะ switch statement จริง ๆ เท่านั้น |
assertNever(x: never) helper | ใช้ได้กับ control-flow ทุกรูปแบบ (if chain, early return) แถม throw message ที่มีประโยชน์ตอน runtime ถ้าถูกเรียกจริง | ต้องมี helper function เดียวที่แชร์กันแล้วนำมาใช้ซ้ำทุกที่ |
s satisfies never | ไม่มีต้นทุน runtime เลย ไม่ต้องมี helper function | ไม่ throw เอง — ยังต้องมี throw/return จริงตามหลังสำหรับ runtime fallback path |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”throwใน branchelse/defaultสุดท้าย exhaustive เหมือน default ที่ type เป็นneverไม่ใช่ —throwคือ code ธรรมดาที่ไม่มีความสัมพันธ์กับ type checker เลย การเพิ่ม union variant ใหม่แล้วลืมจัดการ compile ผ่านสะอาดทั้งสองแบบ มีแค่การ assign เข้าneverจริง ๆ,assertNever, หรือsatisfies neverเท่านั้นที่เปลี่ยนช่องว่างนี้ให้เป็น compile error??กับ||ใช้แทนกันได้สำหรับ “ให้ default” ทั้งสองตัดชุด value ต่างกัน:??ตัดแค่null/undefined;||ตัด falsy value ทุกตัว การสลับตัวหนึ่งไปใช้อีกตัวเปลี่ยน behavior ของ0,"", และfalseอย่างเงียบ ๆstrictNullChecksอย่างเดียวป้องกัน bug ที่เกี่ยวกับ null ได้ มันบังคับให้คุณจัดการเคสnull/undefinedใน type เท่านั้น — มันไม่ได้บอกอะไรเลยว่า fallback ของ||,!, หรือ branch “exhaustive” ที่มีแค่throwจัดการเคสนั้นถูกต้องหรือเปล่า- non-null assertion
!ปลอดภัยพอ ๆ กับ null check จริง แค่สั้นกว่า null check จริง (if (x == null) throw ...) ถูก verify กับ runtime value จริงทุกครั้ง ส่วน!ไม่ verify อะไรเลย แค่ลบ type error ทิ้ง ซึ่งมี risk profile ต่างกันมาก
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ logic การจัดการ null หรือ exhaustiveness ที่ generate มา ตรวจสอบ:
||ถูกใช้ให้ default กับ field ที่ type รวม0,"", หรือfalseเป็น value ที่ valid จริง — นี่คือหนึ่งใน bug ที่ AI generate บ่อยที่สุด เพราะ||กับ??ดูใช้แทนกันได้ตอนมองผ่าน ๆif/else ifchain บน discriminated union ที่จบด้วยthrow new Error(...)โดยไม่มีassertNever/ตัวแปรที่ type เป็นnever/satisfies never— มันอ่านเหมือน exhaustive แต่พิสูจน์อะไรไม่ได้เลย และ variant ใหม่ที่เพิ่มทีหลังจะพังเงียบ ๆ ในระดับ type- non-null assertion
!บน value ที่มาจาก boundary (DOM query,Map.get, fetch response) แทนที่จะมาจาก invariant ในเครื่องที่พิสูจน์ได้จริง - function ที่ return type สัญญาว่าเป็น
Result/discriminated union แต่ก็throwใน path บางเส้นด้วย — การผสมทั้งสอง style ของ error handling ปกติแปลว่า type signature กำลังโกหกเกี่ยวกับสิ่งที่ function ทำได้จริง
🐛 หา bug ในโค้ดนี้
type Shape =| { kind: "circle"; r: number }| { kind: "square"; side: number }| { kind: "triangle"; base: number; height: number };
function area(s: Shape): number {if (s.kind === "circle") return Math.PI * s.r ** 2;if (s.kind === "square") return s.side ** 2;throw new Error("unhandled shape");}เฉลย
โค้ดนี้ compile ผ่านไม่มี error เลย แม้ triangle จะไม่เคยถูกจัดการ if/else if chain ดูเหมือน exhaustive — ทุกเคสที่มีชื่อ return ค่า และ fallback ก็ throw — แต่ไม่มีอะไรเกี่ยวกับ statement throw ที่ถูกเช็คกับ member ของ union เลย ถ้า Shape เริ่มต้นมีแค่ circle กับ square แล้ว triangle ถูกเพิ่มทีหลัง (การแก้ไขที่เจอบ่อยมากในโลกจริง) area จะ compile ผ่านเงียบ ๆ แล้ว throw "unhandled shape" ตอน runtime สำหรับทุก triangle โดยไม่มี warning ที่ไหนใน build เลย วิธีแก้คือแทนที่ throw เปล่า ๆ ด้วย return assertNever(s) (helper เล็ก ๆ ที่ type เป็น (x: never) => never) หรือเพิ่ม s satisfies never; ก่อน throw ทันที — แบบไหนก็เปลี่ยนเคส triangle ที่ตกหล่นให้เป็น compile error ทันทีที่ถูกเพิ่มเข้ามา แทนที่จะเป็นเรื่องเซอร์ไพรส์ตอน runtime ที่เจอทีหลัง