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

Nullability & never

flag ที่มีค่ามากที่สุดตัวเดียวคือ strictNullChecks (เป็นส่วนหนึ่งของ strict) ถ้าไม่เปิด null และ undefined assign เข้า ทุก type ได้ — billion-dollar mistake แบบเต็มพิกัด พอเปิด จะกลายเป็น type ของตัวเองที่คุณต้องจัดการอย่างชัดเจน

// เมื่อเปิด strictNullChecks:
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 เปิด flag นี้ไว้ ถ้าคุณรับ codebase ที่ยังไม่ได้เปิดมา การเปิดคือ 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 (?? ให้ fallback มา)
}

?. short-circuit เป็น undefined ถ้าฝั่งซ้าย nullish และ type ก็สะท้อนสิ่งนั้น (ผลลัพธ์รวม undefined) ?? ให้ fallback เฉพาะกรณี null/undefined (ต่างจาก || ที่ trigger กับ 0 และ "" ด้วย) และ narrow type โดยตัดส่วน nullish ออก

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

const el = document.getElementById("app")!; // assert ว่าไม่ null
el.innerHTML = "hi"; // ไม่ error — แต่ crash ถ้า #app ไม่มีอยู่

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

exception ที่ throw มองไม่เห็นจาก type system — signature ของ function ไม่เคยบอกว่าอาจ throw อะไร Result type ทำให้ failure ชัดเจนและ type-checked:

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; // narrow เป็น number
else r.error; // narrow เป็น 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 เป็น never ตรงนี้ — ครบทุกเคสแล้ว
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
`strictNullChecks` เปลี่ยนอะไร?
`??` ต่างจาก `||` อย่างไรในการให้ fallback?
ความเสี่ยงของ non-null assertion `!` คืออะไร?
ทริก exhaustiveness ของ `never` ช่วยอย่างไรเมื่อเพิ่ม variant ใหม่?