ข้ามไปยังเนื้อหา

Nullability & never

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 ที่ให้ผลตอบแทนสูงสุดที่คุณทำได้

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 ออก

วงเล็บนั้นสมควรมี 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 }));
Open in TS Playground

รันดูแล้ว ทุก 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 it
console.log(retries); // 5
let attempts = 0;
attempts ??= 5; // 0 is not nullish -- ??= leaves it alone
console.log(attempts); // 0

||/||= ยังเป็นเครื่องมือที่ถูกต้อง เมื่อคุณต้องการจริง ๆ ให้ “falsy value อะไรก็ถือว่าหายไป” (เคสที่ตั้งใจแบบนี้ก็มีจริง พบได้บ่อย) bug เกิดจากการหยิบมันมาใช้ตามความเคยชิน ทั้งที่ type จริง ๆ ยอมให้ 0, "", หรือ false เป็น value ที่ valid ได้ — ซึ่งการที่ ?./?? โฟกัสที่ nullability เป็นตัวเตือนให้ตรวจสิ่งนี้ก่อน

การปรับปรุง 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 TS18048
interface 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
}

! แบบ postfix บอก compiler ว่า “เชื่อฉัน อันนี้ไม่ null”:

const el = document.getElementById("app")!; // asserts non-null
el.innerHTML = "hi"; // no error — but crashes if #app doesn't exist

! ลบ check จริงทิ้งโดยไม่มีการ verify ตอน runtime เลย บางครั้งคุณรู้มากกว่า compiler จริง ๆ — แต่ทุก ! คือจุดที่คุณ override safety net ไว้ และเป็นจุดที่การเปลี่ยนแปลงในอนาคตทำให้พังเงียบ ๆ ได้ เลือก check จริง (if (!el) throw ...) ที่ narrow อย่างซื่อสัตย์ดีกว่า เก็บ ! ไว้สำหรับเคสที่พิสูจน์ได้

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 number
else r.error; // narrowed to string

ตอนนี้ caller ต้อง จัดการทั้งสอง branch — compiler ไม่ยอมให้ลืม นี่คือ discriminated union (field ok เป็น tag) ซึ่งพาเราไปสู่เครื่องมือสุดท้าย

เมื่อคุณ 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"]
never เป็นหลักฐาน exhaustiveness

ทริก 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 TS2345
type 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 TS1360
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;
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 พัง ตรงจุดที่ควรพังพอดี

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 ใน branch else/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 ต่างกันมาก

ก่อนเชื่อ logic การจัดการ null หรือ exhaustiveness ที่ generate มา ตรวจสอบ:

  • || ถูกใช้ให้ default กับ field ที่ type รวม 0, "", หรือ false เป็น value ที่ valid จริง — นี่คือหนึ่งใน bug ที่ AI generate บ่อยที่สุด เพราะ || กับ ?? ดูใช้แทนกันได้ตอนมองผ่าน ๆ
  • if/else if chain บน 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 ที่เจอทีหลัง

strictNullChecks เปลี่ยนอะไร?
?? ต่างจาก || อย่างไรในการให้ fallback?
ความเสี่ยงของ non-null assertion ! คืออะไร?
ทริก exhaustiveness ของ never ช่วยอย่างไรเมื่อเพิ่ม variant ใหม่?
โค้ดนี้พิมพ์อะไร และทำไม?
interface ServerConfig { timeout?: number }
function withDefaults(cfg: ServerConfig) {
  const timeout = cfg.timeout || 30;
  return timeout;
}
console.log(withDefaults({ timeout: 0 }));
โค้ดนี้ compile ผ่านไหม ทั้งที่ triangle ไม่เคยถูกจัดการ?
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");
}