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

Everyday Types, Deep

คุณหยิบพวกนี้มาใช้ทุกวัน: string literal, union, object shape, tuple, function โมดูลนี้จะค่อย ๆ เจาะจุดที่คนมักสะดุด — widening, as const, ความต่างระหว่าง union กับ intersection และ control-flow narrowing ที่ทำให้ union ใช้งานได้จริง

ทุกอย่างในนี้ตั้งอยู่บน model จาก Foundations: type คือเซตของ value ถือ lens นี้ไว้ แล้วแต่ละ feature ก็แค่วิธีอธิบายหรือย่อเซตให้เล็กลง

บทเรียนสิ่งที่คุณจะได้เรียนรู้
Literals & wideningliteral type, จะ widen เมื่อไร และ as const
Unions & intersectionsA | B (กว้างกว่า) เทียบ A & B (แคบกว่า) และวิธีทำงานกับแต่ละแบบ
Objects, arrays & tuplesproperty แบบ optional/readonly, index signature, tuple
Functionsoverload, this typing, rest param, ประเด็น void
Narrowingเปลี่ยน union ให้เป็น type เฉพาะด้วย control flow
flowchart LR
  describe["Describe the shape
(literals, unions, objects)"] --> hold["Hold a wide type
(a union of possibilities)"]
  hold --> narrow["Narrow with control flow
(typeof, in, discriminants)"]
  narrow --> use["Use the specific type
safely"]
everyday typing คือ describe แล้ว narrow

คุณ describe ข้อมูลด้วย union และ object type ซึ่งทำให้คุณถือ type ที่ กว้าง อยู่ (มีหลายความเป็นไปได้) จากนั้นก็ narrow — ด้วย typeof, in, discriminant field — ลงมาเหลือ case เฉพาะตัวเดียว แล้ว TypeScript ก็ยอมให้ใช้งานอย่างปลอดภัย เข้าใจ loop นี้แล้วคุณก็เข้าใจ TypeScript ในชีวิตประจำวัน

ทุกบทในโมดูลนี้โผล่มาในรูปเดียวกัน: literal union สำหรับ value ที่อนุญาต, object type สำหรับ request, discriminated union สำหรับ response และ switch ที่ narrow มัน

// @run
type Method = "GET" | "POST"; // literal union -- describe
interface ApiRequest {
readonly method: Method;
path: string;
}
type ApiResponse =
| { kind: "ok"; status: 200; data: unknown }
| { kind: "error"; status: 400 | 404 | 500; message: string };
function describe(res: ApiResponse): string {
switch (res.kind) {
case "ok":
return `OK: ${JSON.stringify(res.data)}`;
case "error":
return `Error ${res.status}: ${res.message}`;
default: {
const _exhaustive: never = res; // exhaustiveness -- a new kind fails to compile here
return _exhaustive;
}
}
}
const req: ApiRequest = { method: "GET", path: "/users" };
console.log(req.method, req.path);
console.log(describe({ kind: "ok", status: 200, data: { id: 1 } }));
console.log(describe({ kind: "error", status: 404, message: "not found" }));
// GET /users
// OK: {"id":1}
// Error 404: not found

Describe, then narrow

type Method = "GET" | "POST"; // literal union -- describe
interface ApiRequest {
readonly method: Method;
path: string;
}
type ApiResponse =
| { kind: "ok"; status: 200; data: unknown }
| { kind: "error"; status: 400 | 404 | 500; message: string };
function describe(res: ApiResponse): string {
switch (res.kind) {
case "ok":
return `OK: ${JSON.stringify(res.data)}`;
case "error":
return `Error ${res.status}: ${res.message}`;
default: {
const _exhaustive: never = res; // exhaustiveness -- a new kind fails to compile here
return _exhaustive;
}
}
}
const req: ApiRequest = { method: "GET", path: "/users" };
console.log(req.method, req.path);
console.log(describe({ kind: "ok", status: 200, data: { id: 1 } }));
console.log(describe({ kind: "error", status: 404, message: "not found" }));
Open in TS Playground
flowchart LR
  lit["Literals & widening
pin down exact values"] --> ui["Unions & intersections
combine (OR) or compose (AND)"]
  ui --> oat["Objects, arrays & tuples
shape the data"]
  oat --> fn["Functions
shape the behavior"]
  fn --> nar["Narrowing
collapse the union at use-time"]
ทุกบทในโมดูลนี้คือหนึ่งขั้นของ pipeline เดียวกัน

มองห้าบทนี้เป็น pipeline เดียว ไม่ใช่ห้าเรื่องแยกกัน: คุณตรึง value ที่ชัดเจนด้วย literal รวม (OR) หรือประกอบ (AND) มันด้วย union กับ intersection สร้างรูปร่างข้อมูลด้วย object, array และ tuple สร้างรูปร่างพฤติกรรมด้วย function และสุดท้ายยุบทุกอย่างกลับลงมาด้วย narrowing ตรงจุดที่คุณใช้ value จริง ๆ กระโดดไปใช้ any หรือ string แบบหลวม ๆ ในขั้นไหนก็ตัดวงจรนี้ทันที — ผลตอบแทนของทั้งโมดูลจะเห็นก็ต่อเมื่อทุกขั้นแม่นยำ และ switch/if สุดท้าย narrow ลงมาเหลือของจริง

แนวทางได้อะไรเสียอะไร
type หลวม ๆ (string, object, any)เขียนไว เข้าใจไม่ต้อง model ล่วงหน้าไม่มี autocomplete ไม่มี exhaustiveness bug โผล่ตอน runtime แทนที่จะเป็น compile time
literal union + discriminated unioncompiler บังคับให้ handle ทุก case (never exhaustiveness)ต้องประกาศ type ล่วงหน้ามากขึ้น เพิ่ม case ใหม่คือการแก้ที่ตั้งใจและมองเห็นได้ทุกจุดที่ใช้
object shape ด้วย interface/typeอธิบายตัวเอง ตรวจทุก call siteมี ceremony เกินจำเป็นสำหรับ object เล็ก ๆ ที่ใช้ครั้งเดียว
narrowing ด้วย control flowไม่มีต้นทุนตอน runtime อ่านเหมือน JS ปกติดีเท่าที่เช็คที่คุณเขียนเท่านั้น — ข้อจำกัดของ narrowing (บทหลัง) อาจทำให้มั่นใจผิด ๆ
  • describe type แล้วคือปลอดภัยแล้ว type ปกป้องคุณแค่ตรงจุดที่ compiler ยังมองเห็นมันอยู่ — switch/if ที่ narrow union ตรงจุดที่คุณใช้ต่างหากที่จับ bug ได้จริง
  • type กว้างกว่าปลอดภัยกว่าเสมอ ใช้ string แทน literal union ทำให้ compiler จับ typo อย่าง "colour" ไม่ได้เลย — แค่ย้าย bug ไปโผล่ที่ production
  • interface กับ type ต่างกันอย่างมีนัยสำคัญสำหรับ object ธรรมดา สำหรับ object shape ทั้งสองใกล้เคียงกันมาก ต่างกันจริงแค่เรื่อง declaration merging, union หรือ mapped type

ก่อนเชื่อ function ที่ generate มาแทน union type ให้เช็คสิ่งเหล่านี้:

  • switch บน discriminated union ที่ไม่มี default: never guard — วันที่มี case ใหม่เพิ่มเข้ามาจะเงียบ ๆ ไม่ exhaustive อีกต่อไป
  • function ที่ switch/if chain ไม่ครอบคลุมทุก branch ด้วย return — inferred return type จะแอบเพิ่ม | undefined ที่โผล่เป็น error ทีหลังตอนใช้งานจริง
  • parameter ที่ type เป็น string หรือ object ทั้งที่มี literal union หรือ interface เฉพาะเจาะจงให้ใช้ — สัญญาณว่าคน (หรือ AI) ไม่ได้ model value ที่อนุญาตจริง ๆ

🐛 หา bug ในโค้ดนี้

type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(s: Shape) {
if (s.kind === "circle") return Math.PI * s.radius ** 2;
if (s.kind === "square") return s.side ** 2;
}
const s: Shape = { kind: "circle", radius: 2 };
console.log(area(s).toFixed(2));
เฉลย

ทั้งสอง if ไม่ได้เขียนเป็น switch แบบ exhaustive TypeScript เลยพิสูจน์ไม่ได้ว่าทุก Shape return เป็นตัวเลข — inferred return type ของ area คือ number | undefined diagnostic จริงบน area(s).toFixed(2) คือ TS2532: Object is possibly 'undefined' ทางแก้คือ pattern discriminated union จากบท narrowing: switch บน kind ที่มี default branch assign เข้า never เพื่อให้ case ที่หายไปเป็น compile error แทนที่จะเป็น undefined แบบเงียบ ๆ

จังหวะที่เกิดซ้ำ ๆ ของ TypeScript ในชีวิตประจำวันคืออะไร?
model พื้นฐานอันไหนที่ร้อยโมดูลนี้เข้าด้วยกัน?
ทำไม area(s).toFixed(2) compile ไม่ผ่าน ทั้งที่ if สองอันครอบคลุมทุก case ของ Shape แล้ว?
function area(s: Shape) {
  if (s.kind === "circle") return Math.PI * s.radius ** 2;
  if (s.kind === "square") return s.side ** 2;
}
ถ้าเพิ่ม member ใหม่ { kind: "triangle"; ... } เข้าไปใน Shape แต่ไม่เพิ่ม case ใหม่ จะเกิดอะไรขึ้น?
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    default:
      const _exhaustive: never = s;
      return _exhaustive;
  }
}
ทำไม parameter แบบ string หลวม ๆ มักแย่กว่า literal union อย่าง "GET" | "POST"?