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

Practical Mastery

ตอนนี้คุณรู้แล้วว่า type system ทำงานอย่างไร โมดูลนี้ว่าด้วยการใช้ type system ให้ดี — คือ judgment ที่แยกคนที่ผ่าน compiler ได้ ออกจากคนที่ ship TypeScript ที่ maintain ได้จริง

แกนหลักคือความจริงที่ไม่ค่อยสบายใจนัก: compiler รู้แค่สิ่งที่คุณบอกเท่านั้น type คือ model ของโปรแกรมคุณ และ model ผิดได้ ทั้ง any, cast ที่ผิด หรือ JSON blob จาก network ล้วนโกหก checker ได้ ความเชี่ยวชาญคือการรู้ว่า model เจอกับความจริงตรงไหน แล้วปกป้อง boundary นั้น

บทเรียนสิ่งที่คุณจะได้เรียนรู้
Typing APIs & boundariesunknown vs any, validate ข้อมูลจากภายนอก, branded type
Nullability & neverstrictNullChecks, Result type, exhaustiveness ด้วย never
Performance & pitfallsทำไม tsc ช้าลง, footgun ที่พบบ่อย, เมื่อไรควร simplify
Migration strategyย้าย JS codebase ไป TS โดยไม่ต้อง rewrite ทีเดียว
flowchart LR
  world["untyped world
network, JSON, any, casts"] -->|validate at the edge| edge["boundary
(guards, schemas)"]
  edge -->|trusted types| inside["your typed code
(safe to trust)"]
เกาะที่มี type ในทะเลที่ไม่มี type

ทุกอย่างในนี้เป็นรูปแบบหนึ่งของ validate at the edge, trust inside ผลักความไม่แน่นอนไปไว้ที่ boundary ของโปรแกรม เปลี่ยนให้เป็น type จริงตรงนั้น แล้วปล่อยให้ทั้งภายในได้รับการการันตี ทำ boundary ให้ถูก แล้ว type system จะตอบแทนคุณในทุกที่ที่เหลือ

ก่อนจะเข้าเรื่อง boundary, nullability, และ migration ลองมาดู boundary ที่คุณข้ามวันละหลายสิบครั้งโดยไม่รู้ตัวก่อน: การอ่าน object ของตัวเองกลับมาด้วย Object.keys กับ Object.entries ทั้งคู่ถูก type แบบระมัดระวังกว่าที่คนส่วนใหญ่คิด และเหตุผลก็คือ structural typing ตัวเดียวกับที่คุณเจอมาตลอดทั้งคอร์ส

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
interface Point { x: number; y: number }
declare const p: Point;
const keys = Object.keys(p);
const check1: Equal<typeof keys, string[]> = true; // not ("x" | "y")[]
const entries = Object.entries(p);
const check2: Equal<typeof entries, [string, any][]> = true; // not [string, number][] !

Object.keys(p) เป็น string[] ไม่ใช่ ("x" | "y")[] — และนี่ไม่ใช่ TypeScript พลาดโอกาส แต่ถูกต้องตาม structural typing เพราะ binding ที่ type เป็น Point สามารถมี object ที่มี property เพิ่ม อยู่จริงตอน runtime ได้:

interface Point { x: number; y: number }
const wide = { x: 1, y: 2, z: 3 };
const p: Point = wide; // ✅ wide has every Point property, plus more — structural subtyping allows this
for (const key of Object.keys(p)) {
// key: string — at runtime p (really wide) can have a "z" key the Point type never mentioned
}

ถ้า Object.keys สัญญาว่าจะได้ ("x" | "y")[] สัญญานั้นจะเท็จทันทีที่ส่ง wide เข้าไป string[] คือ type เดียวที่เป็นจริงเสมอ

Object.entries คือความประหลาดใจที่คมกว่า: บน plain object type ที่ไม่มี index signature lib type จะ fallback เป็น [string, any][] ไม่ใช่ [string, number][] แม้ว่าทุก property จะเป็น number จริง ๆ any ตัวนั้นเงียบและแพร่กระจาย ตรงกับรูปแบบ “compile ผ่านสะอาด แต่โกหกตอน runtime” ที่คอร์สนี้ย้ำอยู่ตลอด

Object.entries degrades to any on a plain interface

interface Prices { apple: number; banana: number; currency: string }
function totalPrice(prices: Prices): number {
let total = 0;
for (const [item, price] of Object.entries(prices)) {
total += price; // price is `any` — Object.entries degrades value types on a plain interface
}
return total;
}
console.log(totalPrice({ apple: 3, banana: 2, currency: "USD" }));
Open in TS Playground

มีสอง pattern ที่ดึงความแม่นยำที่ Object.keys ให้ไม่ได้กลับคืนมา:

  • keyof typeof บน object ที่เป็น const — เมื่อคุณเป็นเจ้าของ object และรู้ว่าไม่มี key แอบแฝงเพิ่ม as const บวก keyof typeof จะให้ literal union กลับมา
  • generic helper พร้อม cast ชัดเจน — Object.keys(obj) as (keyof T)[] เมื่อคุณยืนยันได้เอง (ปกติเพราะคุณสร้าง obj เอง) ว่าไม่มี key เพิ่ม
const Direction = { Up: "UP", Down: "DOWN", Left: "LEFT", Right: "RIGHT" } as const;
type Direction = (typeof Direction)[keyof typeof Direction];
declare const d: Direction; // "UP" | "DOWN" | "LEFT" | "RIGHT"
function keysOf<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[]; // you are asserting: obj has no extra own keys
}
const dirKeys = keysOf(Direction); // ("Up" | "Down" | "Left" | "Right")[]

ทั้งสอง pattern คือคุณเองที่ personally ยืนยันการันตีที่ compiler หาเองไม่ได้ — ซึ่งเป็นธีมของทั้งโมดูลนี้ในเวอร์ชันย่อ

เมื่อชุด key ไม่รู้ล่วงหน้าจนกว่าจะ runtime — cache, ตัวนับ, lookup table ที่สร้างจาก input ของ user — คุณจะเลือกระหว่าง Record<string, T> (plain object) กับ Map<K, V> มันดูเหมือนใช้แทนกันได้ แต่ไม่ใช่

Record<string, T> คือ plain JavaScript object แปลว่า string ทุกตัว เป็น key ที่ valid ในสายตา type system — รวมถึง "__proto__", "constructor", และ "toString" ชื่อที่ชนกับ prototype chain ของ object เอง type system ไม่มีแนวคิดเรื่อง “key อันตราย” เลย ดังนั้น code ที่ดูธรรมดามาก อาจเป็นช่องโหว่ prototype-pollution จริงได้ ถ้า key มาจาก input ที่ไม่น่าเชื่อถือ:

function merge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
for (const key in source) {
const value = source[key];
if (typeof value === "object" && value !== null) {
const existing = target[key];
if (typeof existing !== "object" || existing === null) target[key] = {};
merge(target[key] as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key] = value;
}
}
return target;
}
const payload: Record<string, unknown> = JSON.parse('{"__proto__":{"polluted":"yes"}}');
merge({}, payload);
console.log(({} as Record<string, unknown>).polluted); // every plain object now has this

A type-checked recursive merge with real prototype pollution

function merge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
for (const key in source) {
const value = source[key];
if (typeof value === "object" && value !== null) {
const existing = target[key];
if (typeof existing !== "object" || existing === null) target[key] = {};
merge(target[key] as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key] = value;
}
}
return target;
}
const payload: Record<string, unknown> = JSON.parse('{"__proto__":{"polluted":"yes"}}');
merge({}, payload);
console.log(({} as Record<string, unknown>).polluted); // every plain object now has this
Open in TS Playground

โค้ดนี้ผ่าน --strict สะอาด — ไม่มี diagnostic เลยสักตัว — และมันรั่ว "yes" ไปยัง {} literal ทุกตัวที่เหลือใน process จริง ๆ เพราะ recursive merge เดินเข้าไปที่ target["__proto__"] ซึ่ง JavaScript resolve ผ่าน prototype accessor ไปยัง Object.prototype ตัวจริงที่ใช้ร่วมกันทั้งหมด Map<K, V> ไม่มี bug class นี้เลย: key ของ Map เก็บอยู่ใน internal slot ไม่ใช่ property ของ object ดังนั้น map.set("__proto__", x) ก็แค่ entry ธรรมดา ๆ ไม่มีความหมายพิเศษอะไร

อีกช่องว่างคือ noUncheckedIndexedAccess โดย default การอ่าน index ของ Record จะให้ value type ตรง ๆ โดยไม่มี undefined — แม้แต่ key ที่คุณไม่เคยตั้งเลย:

const counts: Record<string, number> = { apples: 3 };
function double(n: number): number { return n * 2; }
const result = double(counts["bananas"]); // "bananas" was never set — compiles anyway

โค้ดนี้ compile ผ่านสะอาดภายใต้ default flags ของคอร์สนี้ และคืน NaN ตอน runtime (ตรวจแล้วจริง: node --experimental-strip-types บนไฟล์นี้พิมพ์ NaN) การเปิด noUncheckedIndexedAccess ปิดช่องว่างนี้พอดี — การอ่านเดียวกันกลายเป็น number | undefined ดังนั้นการส่งเข้า double จะเป็น compile error:

// @skip-verify requires --noUncheckedIndexedAccess, not part of this course's default flags
const counts: Record<string, number> = { apples: 3 };
function double(n: number): number { return n * 2; }
const result = double(counts["bananas"]); // "bananas" was never set

ตรวจแยกต่างหากแล้ว: node_modules/typescript/bin/tsc --ignoreConfig --strict --noUncheckedIndexedAccess --target es2025 --lib es2025 counts.ts รายงาน TS2345: Argument of type 'number | undefined' is not assignable to parameter of type 'number'. ตรงบรรทัดนี้เป๊ะ — โค้ดเดิม --strict เดิม เพิ่มแค่ flag เดียว แล้ว bug จริงก็กลายเป็น compile error แทนที่จะเป็น NaN Map<string, number> ให้ความซื่อสัตย์แบบเดียวกันฟรี ๆ: .get() ถูก type เป็น V | undefined เสมอ ไม่ว่าจะเปิด flag หรือไม่ เพราะ Map ไม่เคยแกล้งทำเป็นว่าทุก key มีอยู่จริง

ทุกเครื่องมือใน section นี้กำลังตอบคำถามเดียวกัน: compiler รู้จริง ๆ ว่า key บน value นี้มีอะไรบ้างแค่ไหน? Object.keys/Object.entries รู้แค่ว่า value เป็น อย่างน้อย type ที่ประกาศไว้ จึงตอบแบบซื่อสัตย์ที่สุดที่กว้างที่สุด (string[], [string, any][]) Record<string, T> ไม่รู้เลยว่า string ไหนปลอดภัย จึงรับ "__proto__" เท่ากับ "apples" noUncheckedIndexedAccess คือ compiler ยอมรับว่าพิสูจน์ไม่ได้ว่า key เคยถูกตั้งค่า จึงไม่แกล้งทำเป็นรู้ ทั้งหมดนี้ไม่ใช่ compiler กวนใจ — มันคือ compiler ที่แน่ใจเท่าที่ type ที่คุณให้มาจริง ๆ สนับสนุนได้เท่านั้น ซึ่งคือธีมทั้งโมดูลในระดับที่ลึกลงไปอีกขั้นก่อนที่บทอื่นจะเอาไปใช้

แนวทางข้อดีต้นทุน
Record<string, T>plain object: JSON-serializable, destructure/spread ได้เป็นธรรมชาติ, ใช้ได้กับทุก API ที่ต้องการ objectstring ทุกตัว type-check ผ่านในฐานะ key รวมถึง "__proto__"/"constructor"; Object.keys/Object.entries เสียความแม่นยำของ key/value
Map<K, V>key เป็น type อะไรก็ได้ (ไม่ใช่แค่ string), iterate ตามลำดับ insertion จริง, .get() ซื่อสัตย์เป็น V | undefined, ไม่ชนกับ prototype chainไม่ JSON-serializable โดยตรง ต้องแปลงเอง, ไม่มี object-literal syntax, API จำนวนมากยังคาดหวัง plain object
interface/type ที่มีชื่อและ key รู้ล่วงหน้าแม่นยำครบทุก key, keyof T ใช้ได้, excess-property check จับ typo บน literal ได้ใช้ได้เฉพาะเมื่อชุด key คงที่ตอน compile — ไม่รองรับ key แบบ dynamic/มาจาก user
  • Object.keys(obj) ควรถูก type เป็น (keyof T)[] และ TypeScript แค่ทำงานหยาบไป ไม่ใช่ความหยาบ — string[] คือ type เดียวที่ยังจริงอยู่เสมอ เมื่อ value จริงตอน runtime ของ obj อาจมี property เพิ่มที่ type ที่ structurally-compatible กันไม่ได้พูดถึง ซึ่งเป็นไปได้เสมอ
  • Object.entries บน object ที่มี type ให้ value ที่มี type ตามด้วย จริงเฉพาะเมื่อ object มี index signature เท่านั้น interface/type แบบ property คงที่ทำให้ value type กลายเป็น any แบบเงียบ ๆ
  • Record<string, T> กับ Map<string, T> ใช้แทนกันได้สำหรับ lookup ที่ key เป็น string ทั้งสองต่างกันตรงจุดที่สำคัญที่สุด: key ที่ไม่น่าเชื่อถือ Record สืบทอดอันตรายทุกอย่างของการเป็น JavaScript object จริง (prototype chain) ส่วน Map ไม่มี
  • การเปิด noUncheckedIndexedAccess ส่วนใหญ่เป็นแค่ noise สำหรับ Record ที่คุณควบคุมอยู่แล้ว มันจะ fire เฉพาะจุดที่ key อาจไม่มีอยู่จริง ๆ เท่านั้น — ซึ่งสำหรับอะไรก็ตามที่สร้างจาก user input, config, หรือ partial data คือการอ่าน Record ส่วนใหญ่ในทางปฏิบัติ

ก่อนเชื่อ code ที่ generate มาแล้วใช้ plain object เป็น lookup table ตรวจสอบ:

  • ผลลัพธ์จาก Object.keys(obj) หรือ Object.entries(obj) ถูกใช้ราวกับว่า value type แม่นยำ — ตรวจว่า type ของ obj มี index signature จริงหรือเป็นแค่ interface แบบ property คงที่ที่ value กลายเป็น any แบบเงียบ ๆ
  • Record<string, T> (หรือ plain object) ที่สร้างจาก key ที่ user ควบคุมได้ — request body, query param, config ที่ upload มา — โดยไม่มีการเช็ค __proto__, constructor, หรือ prototype โดยเฉพาะใน merge/clone/defaults helper แบบเขียนมือ
  • จุดที่ Map จะกำจัด bug class ทั้งชุดได้ฟรี (key แบบ dynamic ที่อาจไม่น่าเชื่อถือ) แต่กลับใช้ plain object เพียงเพราะความเคยชิน
  • การอ่าน Record โดยไม่มี noUncheckedIndexedAccess และไม่มี fallback (?? default) สำหรับ key ที่พิสูจน์ไม่ได้ว่ามีอยู่จริง

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

interface Prices { apple: number; banana: number; currency: string }
function totalPrice(prices: Prices): number {
let total = 0;
for (const [item, price] of Object.entries(prices)) {
total += price;
}
return total;
}
console.log(totalPrice({ apple: 3, banana: 2, currency: "USD" }));
เฉลย

โค้ดนี้ compile ผ่านไม่มี error เลยภายใต้ --strict และคืนค่า "5USD" ตอน runtime จาก function ที่ประกาศ return type เป็น number Object.entries บน Prices (plain interface ไม่มี index signature) type price เป็น any ดังนั้น total += price type-check ผ่านไม่ว่า price จะเป็นอะไรจริง ๆ ตอน runtime total เริ่มที่ 0 บวก 3 กับ 2 (เป็น number ทั้งคู่ ไม่มีปัญหา) แล้วบวก string "USD" — + ของ JavaScript ระหว่าง number กับ string จะ concatenate กัน ได้ string "5USD" ซึ่ง function คืนค่านี้ออกไปทั้งที่ประกาศ return type เป็น number วิธีแก้คือ iterate เฉพาะ key ที่เป็นตัวเลขอย่างชัดเจน ((["apple", "banana"] as const).reduce((sum, k) => sum + prices[k], 0)) หรือแยก prices กับ metadata ไว้คนละ object เพื่อไม่ให้ Object.entries ถูกใช้กับ shape ที่ type ปนกัน

ความจริงหลักของโมดูลนี้คืออะไร?
กลยุทธ์ที่วนกลับมาเสมอสำหรับจัดการโลกที่ไม่มี type คืออะไร?
project compile ผ่านโดยไม่มี error เลย การันตีอะไร?
keys มี type อะไร?
interface Point { x: number; y: number }
declare const p: Point;
const keys = Object.keys(p);
โค้ดนี้ compile ผ่านภายใต้ --strict ไหม แล้วพิมพ์อะไร?
function merge(target: Record<string, unknown>, source: Record<string, unknown>) { /* recursive merge */ }
const payload: Record<string, unknown> = JSON.parse('{"__proto__":{"polluted":"yes"}}');
merge({}, payload);
console.log(({} as Record<string, unknown>).polluted);
ข้อไหนถูกต้องเกี่ยวกับ Record<string, T> กับ Map<K, V> สำหรับ key แบบ dynamic ที่อาจไม่น่าเชื่อถือ?