Typing APIs & Boundaries
ทุก bug เข้ามาที่ boundary
หัวข้อที่มีชื่อว่า “ทุก bug เข้ามาที่ boundary”ภายใน code ของคุณเอง compiler คอยดูแลให้ bug แอบเข้ามาตรงที่ typed code เจอกับโลกที่ไม่มี type: response จาก network, localStorage, form, third-party library, JSON.parse ตรง edge เหล่านั้น การการันตีของ TypeScript ดีได้แค่เท่าที่คุณจัดการกับข้อมูลที่เข้ามา
การตัดสินใจแรกคือจะเลือก “ไม่รู้ type นี้” แบบไหน: any หรือ unknown
any ปิด checker ส่วน unknown เก็บ checker ไว้
หัวข้อที่มีชื่อว่า “any ปิด checker ส่วน unknown เก็บ checker ไว้”// @expect-error TS18046declare const input: string;
const a: any = JSON.parse(input);a.user.name.toUpperCase(); // compiles — and may explode at runtime
const u: unknown = JSON.parse(input);u.user.name; // ❌ Error: 'u' is of type 'unknown'.any opts out จากการ type check — เข้าถึงอะไรก็ได้ และการการันตีทั้งหมดถูกปิด unknown คือ top type ที่ซื่อสัตย์: รับ value อะไรก็ได้ แต่ห้ามคุณทำอะไรกับ value นั้นจนกว่าจะ พิสูจน์ ว่าเป็นอะไร เลือก unknown ที่ทุก boundary และมอง any เป็น code smell ที่คุณกำลังกำจัดออกไป
สังเกตว่า JSON.parse ถูก type ให้ return any — ผลลัพธ์จึงแอบวางยา downstream ทั้งหมด เว้นแต่คุณจะ assign เป็น unknown ทันทีแล้ว validate
catch clause ก็คือ boundary เดียวกันในอีกหน้ากาก ด้วย useUnknownInCatchVariables — ซึ่งเป็นส่วนหนึ่งของ strict จึงเปิดเป็นค่าเริ่มต้นตั้งแต่ TypeScript 6.0 — ค่าที่ catch ได้เป็น unknown ไม่ใช่ any ดังนั้น err.message ต้องผ่าน err instanceof Error ก่อน
เปลี่ยน unknown ให้เป็น type ที่เชื่อถือได้
หัวข้อที่มีชื่อว่า “เปลี่ยน unknown ให้เป็น type ที่เชื่อถือได้”การ narrow unknown ต้องใช้ runtime check ที่ compiler เข้าใจ สำหรับ shape ง่าย ๆ ใช้ type guard ที่เขียนเอง:
declare const input: string;
type User = { id: number; name: string };
function isUser(v: unknown): v is User { return ( typeof v === "object" && v !== null && "id" in v && typeof (v as any).id === "number" && "name" in v && typeof (v as any).name === "string" );}
const data: unknown = JSON.parse(input);if (isUser(data)) { data.name.toUpperCase(); // ✅ data is User here}พอเป็นของจริง type guard ที่เขียนเองเริ่มน่าเบื่อและ drift ออกจาก type คำตอบมาตรฐานคือ schema/parser library (zod, valibot และเพื่อน ๆ) ที่ declaration เดียวเป็น ทั้ง runtime validator และ static type:
// @skip-verify zod is not a dependency of this course — shape shown for reference onlydeclare const input: string;import { z } from "zod";
const User = z.object({ id: z.number(), name: z.string() });type User = z.infer<typeof User>; // static type derived from the schema
const data = User.parse(JSON.parse(input));// data is typed User AND was checked at runtime — throws if invalidsource of truth เดียว, check ตอน runtime, infer ตอน compile time นี่คือ boundary ที่ทำถูก
flowchart LR raw["unknown (JSON, network, storage)"] -->|schema.parse / type guard| typed["trusted type (User)"] typed --> use["use freely no more checks needed"]
boundary ที่ไม่ใช่แค่ JSON.parse
หัวข้อที่มีชื่อว่า “boundary ที่ไม่ใช่แค่ JSON.parse”JSON.parse ได้รับความสนใจไปหมด แต่มันเป็นแค่หนึ่งในหลาย ๆ จุดที่ typed code สัมผัสกับสิ่งที่ compiler ไม่ได้ตรวจ อีกสี่จุดที่เจอในเกือบทุก codebase จริง — แต่ละจุด type แบบซื่อสัตย์ แต่ข้ามผ่านได้ง่ายมาก:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
const port = process.env.PORT;const check1: Equal<typeof port, string | undefined> = true; // env vars might not be set
const saved = localStorage.getItem("theme");const check2: Equal<typeof saved, string | null> = true; // missing key -> null, not undefined
const params = new URLSearchParams("?page=2");const page = params.get("page");const check3: Equal<typeof page, string | null> = true; // missing param -> null too
// A minimal shape mirroring @types/express's Request<...> generic (ReqBody defaults to `any`)// named ExpressRequest -- plain "Request" would merge with the DOM lib's global Request interfaceinterface ExpressRequest<ReqBody = any> { body: ReqBody }declare const req: ExpressRequest;req.body.user.name.toUpperCase(); // compiles -- Express's req.body is `any` by default, same disease as JSON.parseprocess.env.X เป็น string | undefined — Node ไม่มีทางรู้เลยว่า environment variable ตัวไหนมีอยู่จริงตอน runtime localStorage.getItem และ URLSearchParams.get ทั้งคู่ return T | null (ไม่ใช่ undefined) เมื่อ key หาย — ความต่างจริงที่พลาดง่ายมาก ถ้าคุณเขียน === undefined แทน == null และ req.body ใน typings @types/express ที่ใช้กันแพร่หลาย default ให้ type parameter ReqBody เป็น any จึงสืบทอดปัญหาทุกอย่างของ JSON.parse มา เว้นแต่ route handler จะ narrow หรือ validate มันเองอย่างชัดเจน
bug คลาสสิกที่เกิดจากสิ่งนี้: อ่าน process.env.PORT แล้วใช้เป็นตัวเลขโดยไม่เช็คว่า parse สำเร็จ:
const port = Number(process.env.PORT); // process.env.PORT is string | undefinedconsole.log(`listening on port ${port}`); // compiles clean -- port might be NaNA type-checked env var read that quietly becomes NaN
const port = Number(process.env.PORT); // process.env.PORT is string | undefinedconsole.log(`listening on port ${port}`); // compiles clean -- port might be NaN รันดูโดยไม่ตั้ง PORT จะพิมพ์ listening on port NaN — ไม่มี compile error เลยสักตัว แต่เป็น production bug ที่จริงมาก วิธีแก้คือ shape เดียวกับ validate-at-the-edge ของทุก boundary ในบทนี้: guard เล็ก ๆ ที่เขียนเองแปลง “อาจเป็น string” ให้เป็น “port ที่ valid แน่นอน หรือ error ที่ throw ออกมา”:
function requirePort(raw: string | undefined, fallback: number): number { if (raw === undefined) return fallback; const n = Number(raw); if (!Number.isInteger(n) || n <= 0) { throw new Error(`invalid PORT: ${JSON.stringify(raw)}`); } return n;}
const port = requirePort(process.env.PORT, 3000);console.log(`listening on port ${port}`); // port is a validated number, never NaNสำหรับ request body จริง shape เดียวกันนี้ขยายไปเป็น schema ได้ zod sketch จาก section ก่อนหน้าคือคำตอบทั่วไป — schema เดียวเป็นทั้ง guard สไตล์ requirePort และ static type สำหรับ shape ที่ซับซ้อนกว่าตัวเลขตัวเดียวมาก
review boundary ใน PR ที่ AI generate
หัวข้อที่มีชื่อว่า “review boundary ใน PR ที่ AI generate”เพราะ boundary bug มองไม่เห็นจาก tsc — type อาจซื่อสัตย์สมบูรณ์ (string | undefined, any) แต่ code ยังลืมเช็คอยู่ดี — การ review generated code เพื่อความปลอดภัยของ boundary จึงเป็นงาน grep ไม่ใช่งาน compiler สี่คำค้นหาจับได้เกือบทั้งหมด:
# 1. Every place raw external data entersgrep -rn "JSON.parse\|\.json()" src/
# 2. Every place a cast overrides the checkergrep -rn " as \| as any\b" src/
# 3. Every non-null assertion — is it actually provable?grep -rn "!;\|!\." src/
# 4. Every any — did it leak from somewhere upstream?grep -rn ": any\b" src/แต่ละจุดที่เจอต้องพิสูจน์บางอย่างก่อนคุณจะ approve: JSON.parse/.json() ต้องตามด้วย guard หรือ schema ทันที ไม่ใช่ใช้ดิบ ๆ; cast as ต้องมีเหตุผลจากสิ่งที่ compiler infer เองไม่ได้จริง ๆ (ไม่ใช่แค่ “error มันน่ารำคาญ”); ! ต้องพิสูจน์ได้ว่า non-null จริงตรงบรรทัดนั้นเป๊ะ ไม่ใช่แค่ “ปกติแล้ว” non-null; และ any ต้องเป็น boundary marker ที่ตั้งใจและชั่วคราวจริง ๆ ไม่ใช่ value ที่ไหลเข้าไปลึกสามฟังก์ชันแบบเงียบ ๆ generated code มักจะtype ถูกแล้วยังพลาดทุกข้อนี้ได้ — จำไว้ว่า tsc สีเขียวพิสูจน์แค่ internal consistency กับ type ที่เขียนไว้ ไม่ได้พิสูจน์ว่าจุดที่เจอเหล่านี้ถูกจัดการอย่างปลอดภัย
unknown ใน catch: สูตร instanceof/String
หัวข้อที่มีชื่อว่า “unknown ใน catch: สูตร instanceof/String”เพราะ useUnknownInCatchVariables เปิดเป็นค่าเริ่มต้นภายใต้ strict การเข้าถึง value ที่ catch มาโดยไม่ narrow ก่อนเป็น compile error ไม่ใช่แค่เรื่อง style:
// @expect-error TS18046async function loadConfig() { try { JSON.parse("{ invalid"); } catch (e) { console.error("config load failed: " + e.message); // e is unknown, not Error }}JavaScript ให้คุณ throw อะไรก็ได้ — string, plain object, undefined — ดังนั้น compiler ถูกแล้วที่ไม่สมมติว่า e มี .message สูตรมาตรฐานจัดการทั้งเคสทั่วไป (Error จริง) และ fallback (อย่างอื่น) คุ้มค่าที่จะจำไว้เป๊ะ ๆ ตามที่เขียน:
async function loadConfig() { try { JSON.parse("{ invalid"); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error("config load failed: " + message); }}
loadConfig();รันดูแล้วพิมพ์ message ของ SyntaxError ที่มีประโยชน์จริง — config load failed: Expected property name or '}' in JSON at position 2 (line 1 column 3) — สำหรับเคสทั่วไป และ String(e) ยังให้บางอย่างที่อ่านได้สำหรับเคสหายากที่สิ่งที่ throw ไม่ใช่ Error เลย
edge เพิ่มอีกสอง: ไฟล์ JSON กับวันที่
หัวข้อที่มีชื่อว่า “edge เพิ่มอีกสอง: ไฟล์ JSON กับวันที่”JSON import การ import ไฟล์ .json ให้ type ที่ได้มาจากเนื้อหาไฟล์โดยตรง — ไม่ต้องมี schema เพราะ compiler อ่านไฟล์ได้ ภายใต้ --module nodenext (TypeScript 5.7+) การ import ต้องมี attribute type: "json" และใช้ได้แค่ default import เท่านั้น ตรงตามที่ Node บังคับ:
// @skip-verify JSON module import needs resolveJsonModule and a json fileimport config from "./config.json" with { type: "json" };config.port; // typed from the file; named imports are an error(ตรวจแยกนอก harness นี้: tsc --module nodenext --resolveJsonModule กับ config.json จริงที่มี { "port": 8080 } compile ผ่านสะอาดเมื่อ access เป็น config.port ไม่ใช่ config.default.port)
สำหรับ config object ที่เขียนเอง satisfies เก็บ literal type ไว้พร้อมกับเช็ค shape:
const routes = { home: "/", user: "/users/:id" } satisfies Record<string, `/${string}`>;routes.home; // still the literal "/" — not widened to stringวันที่ TypeScript มี type ของ Temporal API อยู่ใน library esnext (lib.esnext.temporal.d.ts — ยังไม่ถูกรวมเข้า es2025 ตรวจสอบแล้วกับ lib file จริงของเวอร์ชัน 7.0.2 ที่ติดตั้งอยู่) เมื่อ boundary ส่ง ISO timestamp มาให้ Temporal.Instant.from(iso) ให้ค่าที่ immutable และระบุ timezone ชัดเจน — เป็น type ภายในที่ดีกว่า Date ที่ mutable มาก และคาดว่า Node 26 จะเปิด Temporal เป็นค่าเริ่มต้น
const instant = Temporal.Instant.from("2026-09-22T00:00:00Z");instant.epochMilliseconds; // type-checksImmutability ตรง boundary
หัวข้อที่มีชื่อว่า “Immutability ตรง boundary”ข้อมูลที่คุณได้จาก boundary — response ที่ parse มา, config object — คือจุดที่เป็นธรรมชาติที่จะอยากได้การันตี: “ไม่มีอะไร downstream แก้ไขสิ่งนี้ได้” TypeScript ให้เครื่องมือหลายตัวตรงนี้ และทุกตัวก็ตื้นกว่าที่มองแวบแรก
readonly บน property ปกป้องแค่ binding ตัวนั้น ไม่ใช่สิ่งที่มันชี้ไปหา:
interface Config { readonly name: string; limits: { max: number };}
function tweak(cfg: Config) { cfg.limits.max = 999; // ✅ compiles -- readonly only protects the "limits" binding itself, not what it points to}Readonly<T> ตื้นแบบเดียวกันเป๊ะ เพราะมันถูกนิยามเป็น { readonly [K in keyof T]: T[K] } — mapping แค่ระดับเดียว ไม่ recurse เข้า nested object:
interface Config { limits: { max: number } }function tweak(cfg: Readonly<Config>) { cfg.limits.max = 999; // ✅ compiles -- Readonly<T> only makes top-level props readonly}Object.freeze ตื้นทั้งสองระดับ — runtime freeze ล็อกแค่ property ระดับบนสุดของ object เอง และ return type ของมัน (Readonly<T>) ก็ตรงกันเป๊ะ:
const cfg = Object.freeze({ name: "prod", limits: { max: 10 } });cfg.limits.max = 999; // type system allows it -- freeze's return type is only shallowly Readonly<T>console.log(cfg.limits.max); // 999 -- the runtime freeze didn't stop this eitheras const คือตัวที่เข้าถึงทุกระดับ nesting จริง ๆ เพราะมันคือคำสั่ง literal-inference ไม่ใช่ mapped type — compiler ทำเครื่องหมาย readonly ทุก property ตอน infer literal type แบบ recursive:
// @expect-error TS2540const cfg = { name: "prod", limits: { max: 10 } } as const;cfg.limits.max = 999; // ❌ as const's readonly does reach nested objects, unlike Readonly<T> or Object.freeze's typeและ array ที่ mutable ธรรมดาจะ assign มาจาก array ที่เป็น readonly ไม่ได้เลย — นี่คือจุดเดียวที่ immutability ถูกบังคับใน structural sense ทั้งสองทิศทาง:
// @expect-error TS4104function sortInPlace(arr: number[]): number[] { return arr.sort();}
const frozen: readonly number[] = [3, 1, 2];sortInPlace(frozen); // ❌ readonly number[] is not assignable to the mutable type number[]สำหรับ immutability แบบลึกจริง ๆ คุณต้องเขียน (หรือ import) recursive mapped type — ตัวอย่างล่วงหน้าของโมดูล type-level-programming:
// @expect-error TS2540type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
interface Config { name: string; limits: { max: number } }
function tweak(cfg: DeepReadonly<Config>) { cfg.limits.max = 999; // ❌ DeepReadonly reaches every nesting level, unlike Readonly<T>}Branded type สำหรับ nominal ID
หัวข้อที่มีชื่อว่า “Branded type สำหรับ nominal ID”structural typing หมายความว่า UserId กับ OrderId ที่เป็น string ทั้งคู่ใช้แทนกันได้ — คุณจึงส่งตัวหนึ่งไปที่ที่คาดหวังอีกตัวได้ เมื่อเรื่องนี้สำคัญ ให้ brand type เหล่านั้นด้วย marker แบบ phantom:
// @expect-error TS2345type UserId = string & { readonly __brand: "UserId" };
function getUser(id: UserId) { /* ... */ }
const raw = "u_123";getUser(raw); // ❌ plain string is not a UserIdgetUser(raw as UserId); // ✅ you deliberately asserted it at the boundarybrand มีอยู่แค่ตอน compile time (ถูก erase ทิ้ง), ไม่มี cost ตอน runtime และบังคับให้ทุก UserId ถูก สร้างอย่างตั้งใจ — โดยปกติคือทันทีหลัง validate นี่คือวิธีได้ nominal safety ในระบบที่เป็น structural
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกเทคนิคในบทนี้กำลังตอบคำถามเดียวกันด้วยความซื่อสัตย์ที่ต่างกัน: “compiler มั่นใจแค่ไหนว่า value นี้เป็นอย่างที่มันอ้าง?” unknown คือ compiler ยอมรับว่าไม่แน่ใจเลย แล้วปฏิเสธไม่ให้คุณเดินหน้าโดยไม่มีหลักฐาน process.env/localStorage/URLSearchParams คือ compiler ที่ไม่แน่ใจอย่างซื่อสัตย์และแม่นยำ (| undefined, | null) แทนที่จะมองโลกในแง่ดี any — บน JSON.parse, บน req.body — คือ compiler ที่ถูกสั่งให้หยุดไม่แน่ใจ ซึ่งไม่เหมือนกับความไม่แน่นอนหายไปจริง และ readonly/Readonly<T>/Object.freeze ก็เป็นภาพสะท้อนของแนวคิดเดียวกัน: แต่ละตัวซื่อสัตย์พอดีกับว่ามันปกป้อง shape ได้แค่ไหนจริง ๆ (binding เดียว, ระดับเดียว, ระดับเดียวตอน runtime) มีแค่ as const หรือ DeepReadonly ที่เขียนเองเท่านั้นที่คู่ควรกับคำกล่าวอ้างที่แข็งแรงกว่า เพราะมันเป็นสองตัวเดียวที่เดินทุกระดับจริง ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| แนวทาง | ข้อดี | ต้นทุน |
|---|---|---|
type guard ที่เขียนเอง (x is T) | ไม่มี dependency ควบคุมการเช็คได้เต็มที่ | น่าเบื่อสำหรับ shape จริง drift จาก type ตามที่ shape เปลี่ยนไป เขียน guard ที่อ่อนกว่าที่อ้างได้ง่าย |
| schema library (แบบ zod) | declaration เดียวเป็นทั้ง validator และ type — drift ไม่ได้ | เพิ่ม dependency; มี runtime cost ของการ validate ทุกครั้งที่ parse |
readonly / Readonly<T> | ไม่มี runtime cost เลย จับการ reassign ระดับบนสุดได้ตอน compile time | ตื้น — object/array ที่ nested ยัง mutable เต็มที่ |
Object.freeze | บังคับจริงตอน runtime ไม่ใช่แค่คำสัญญาตอน compile-time | ตื้นตอน runtime ด้วย และ throw เฉพาะใน strict-mode JS เท่านั้น (ไม่งั้นแค่ no-op เงียบ ๆ) |
as const / DeepReadonly ที่เขียนเอง | เข้าถึงทุกระดับ nesting จริง | as const ใช้ได้แค่กับ literal ที่คุณเขียนตอนนั้นเท่านั้น ไม่ใช่ value ที่ type ไว้แล้ว; DeepReadonly ต้องใช้ type-level programming เขียนให้ดี |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”readonlyproperty หรือReadonly<T>ทำให้ object ทั้งก้อน immutable ทั้งคู่ตื้น — ปกป้องแค่ property binding ระดับเดียว ไม่ใช่อะไรที่ซ้อนอยู่ข้างในObject.freezeแก้สิ่งที่ type ของReadonly<T>แก้ไม่ได้ มันไม่ได้ลึกไปกว่าReadonly<T>ตอน compile-time เลยตอน runtime — freeze object แล้วยัง mutate nested object ข้างในได้อย่างอิสระreq.body,process.env, และlocalStorage.getItem“น่าจะโอเค” เพราะ TypeScript คงจะ flag ปัญหาจริงแล้ว type ของมันซื่อสัตย์อยู่แล้ว (any,string | undefined,string | null) — compiler บอกไปแล้วชัด ๆ ว่ามันไม่รู้แค่ไหน bug อยู่ที่ code ที่ไม่ทำอะไรกับความซื่อสัตย์นั้นtscสีเขียวบน generated code แปลว่า boundary ถูกจัดการแล้ว boundary bug เป็น runtime-only โดยธรรมชาติ (การเช็คที่หายไป ไม่ใช่ type error) ดังนั้น compile ผ่านไม่พิสูจน์อะไรเลยว่าJSON.parse,req.body, หรือ exception ที่ catch มา ถูก validate จริงหรือเปล่า
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ generated code ที่แตะ boundary ตรวจสอบ:
process.env.Xถูกใช้ตรง ๆ ใน arithmetic หรือส่งเข้า function ที่คาดหวังค่าที่ไม่ใช่undefinedโดยไม่มีการเช็คหรือ defaultlocalStorage.getItem(...)หรือparams.get(...)ถูกเทียบด้วย=== undefinedแทนที่จะเป็น== null(หรือเช็ค!== nullจริง) — ทั้งสอง API returnnullเมื่อ value หาย ไม่ใช่undefined.message(หรือ property ไหน) ของ exception ที่ catch มา ถูกเข้าถึงโดยไม่มีinstanceof Errorก่อน หรือใช้@ts-ignore/as anyปิดปากTS18046ที่ตามมาแทนที่จะเพิ่ม check จริง- pattern grep ทั้งสี่ข้างต้น (
JSON.parse/.json(),as,!,: any) ที่ไม่มี validation, guard, หรือเหตุผลชัดเจนใน diff เดียวกัน - helper “deep clone” หรือ “deep freeze” ที่อ้างว่า immutable เต็มที่ แต่จริง ๆ ลึกแค่ระดับเดียว — ตรวจกับ property ที่ nested จริง ไม่ใช่แค่ระดับบนสุด
🐛 หา bug ในโค้ดนี้
async function loadUserSettings() {try { return JSON.parse(localStorage.getItem("settings"));} catch (e) { console.error("failed to load settings: " + e.message); return {};}}เฉลย
bug ของ boundary สองตัวซ้อนกันในสี่บรรทัด แรกสุด localStorage.getItem("settings") return string | null และ JSON.parse ต้องการ string — ถ้า key ไม่เคยถูกตั้งเลย จะ throw TypeError: Cannot read properties of null (หรือแย่กว่านั้น ถ้า type หลวมพอที่จะข้าม check ไปได้ JSON.parse(null) จะ parse string "null" เป็น value null แบบเงียบ ๆ จริง ๆ) ที่สอง ข้างใน catch e เป็น unknown ภายใต้ useUnknownInCatchVariables ดังนั้น e.message compile ไม่ผ่านเลยด้วยซ้ำถ้าไม่มี cast — และถ้า snippet นี้อยู่ในไฟล์ที่ setting หลวมกว่า (หรือมีการเพิ่ม cast เพื่อให้ compile ผ่าน) การ throw ที่ไม่ใช่ Error (ซึ่ง JSON.parse เองไม่สร้าง แต่ code path อื่นเยอะมากสร้างได้) จะพิมพ์ undefined แทนที่จะเป็น message ที่มีประโยชน์ วิธีแก้คือ localStorage.getItem("settings") ?? "{}" ก่อน parse และ e instanceof Error ? e.message : String(e) ใน catch