Practical Mastery
จุดที่ความรู้กลายเป็น judgment
หัวข้อที่มีชื่อว่า “จุดที่ความรู้กลายเป็น judgment”ตอนนี้คุณรู้แล้วว่า type system ทำงานอย่างไร โมดูลนี้ว่าด้วยการใช้ type system ให้ดี — คือ judgment ที่แยกคนที่ผ่าน compiler ได้ ออกจากคนที่ ship TypeScript ที่ maintain ได้จริง
แกนหลักคือความจริงที่ไม่ค่อยสบายใจนัก: compiler รู้แค่สิ่งที่คุณบอกเท่านั้น type คือ model ของโปรแกรมคุณ และ model ผิดได้ ทั้ง any, cast ที่ผิด หรือ JSON blob จาก network ล้วนโกหก checker ได้ ความเชี่ยวชาญคือการรู้ว่า model เจอกับความจริงตรงไหน แล้วปกป้อง boundary นั้น
โมดูลนี้ครอบคลุมอะไรบ้าง
หัวข้อที่มีชื่อว่า “โมดูลนี้ครอบคลุมอะไรบ้าง”| บทเรียน | สิ่งที่คุณจะได้เรียนรู้ |
|---|---|
| Typing APIs & boundaries | unknown vs any, validate ข้อมูลจากภายนอก, branded type |
| Nullability & never | strictNullChecks, 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)"]
ทุกอย่างในนี้เป็นรูปแบบหนึ่งของ validate at the edge, trust inside ผลักความไม่แน่นอนไปไว้ที่ boundary ของโปรแกรม เปลี่ยนให้เป็น type จริงตรงนั้น แล้วปล่อยให้ทั้งภายในได้รับการการันตี ทำ boundary ให้ถูก แล้ว type system จะตอบแทนคุณในทุกที่ที่เหลือ
Object.keys กับ Object.entries รู้น้อยกว่าที่คุณคิด
หัวข้อที่มีชื่อว่า “Object.keys กับ Object.entries รู้น้อยกว่าที่คุณคิด”ก่อนจะเข้าเรื่อง 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 thisfor (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" })); มีสอง 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 หาเองไม่ได้ — ซึ่งเป็นธีมของทั้งโมดูลนี้ในเวอร์ชันย่อ
Record<string, T> vs Map<K, V> สำหรับ key ที่ dynamic
หัวข้อที่มีชื่อว่า “Record<string, T> vs Map<K, V> สำหรับ key ที่ dynamic”เมื่อชุด 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 thisA 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 โค้ดนี้ผ่าน --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 flagsconst 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 มีอยู่จริง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกเครื่องมือใน 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 ที่ต้องการ object | string ทุกตัว 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ส่วนใหญ่ในทางปฏิบัติ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ 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 ปนกัน