The AI bug catalog
ทุก pattern ด้านล่างเขียนให้เหมือน TypeScript ที่ AI assistant เขียนจริง ๆ compile ด้วย tsc --strict (TypeScript 7.0.2) แล้ว — จุดไหนที่ bug เป็นอาการตอน runtime ไม่ใช่ diagnostic — รันด้วย node --experimental-strip-types แล้ว quote ตรง ๆ ไม่ได้เขียนจากความจำ ทุกหัวข้อจบด้วย Caught by บอกว่าเครื่องมือตัวไหนจับได้จริง หรือบอกตรง ๆ ว่าไม่มีอะไรจับเลย
24 pattern นี้จัดกลุ่มเหมือน Review Checklist ที่ตามมาหลังบทนี้: boundary กับ unsafe cast, nullability กับ falsy check, async กับ control flow, generics กับ variance, erasure กับ config, และ indexing กับ key
Zero type errors, wrong order
async function saveAuditLog(message: string): Promise<void> { await new Promise<void>((resolve) => setTimeout(resolve, 10)); console.log("saved:", message);}
function handleRequest(id: number): string { saveAuditLog(`request ${id} handled`); // missing await -- a floating promise return `response-${id}`;}
console.log(handleRequest(1)); pattern แรกนี้ควรลองรันเองก่อนอ่านต่อ — playground ด้านบน compile ผ่านโดย ไม่มี error เลย ใต้ --strict แล้วพิมพ์ response-1 ก่อน saved: request 1 handled เพราะ await ที่หายไปทำให้ handleRequest return ก่อนที่ log call จะ settle ทุกอย่างด้านล่างเป็นกฎเดียวกัน: โค้ดจริง output จริง ไม่มีข้อยกเว้น
Boundaries & unsafe casts
หัวข้อที่มีชื่อว่า “Boundaries & unsafe casts”1. as บน JSON.parse
หัวข้อที่มีชื่อว่า “1. as บน JSON.parse”as User compiles; the shape was never checked
interface User { id: number; name: string }
const raw = '{"id": 1}'; // "name" is missing from the actual payloadconst user = JSON.parse(raw) as User;console.log(user.name.toUpperCase()); JSON.parse คืน any; as User เขียนทับด้วย type ที่ checker ไม่เคยตรวจกับค่าที่ parse ออกมาจริงเลย
Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')Fix: เปลี่ยน assertion เป็น runtime check จริง — เขียน type guard เอง หรือใช้ schema library:
interface User { id: number; name: string }
function isUser(value: unknown): value is User { if (typeof value !== "object" || value === null) return false; const v = value as Record<string, unknown>; return typeof v.id === "number" && typeof v.name === "string";}
const raw = '{"id": 1}';const parsed: unknown = JSON.parse(raw);if (isUser(parsed)) { console.log(parsed.name.toUpperCase());} else { console.log("invalid user shape");}Caught by: ไม่มีอะไรจับเลย — as คือ assertion ไม่ใช่ check ไม่มีเครื่องมือไหนในลิสต์ของคอร์สนี้ตรวจว่า type ที่ assert ไว้ตรงกับ shape จริงตอน runtime หรือเปล่า ดู Typing APIs & Boundaries
2. any รั่วจาก library ที่ไม่มี type
หัวข้อที่มีชื่อว่า “2. any รั่วจาก library ที่ไม่มี type”// @runfunction loadConfigFromUntypedLib(): any { // stands in for a real untyped JS dependency with no .d.ts -- TypeScript can't check its shape return { port: "3000" };}
const config = loadConfigFromUntypedLib();const nextPort = config.port + 1; // any -- no error, but config.port is actually a string hereconsole.log(nextPort);console.log(typeof nextPort);พอ value ถูก type เป็น any ทุก operation บนมัน — property access, arithmetic, assignment ต่อไป — จะไม่ถูกตรวจเลย และความแม่นยำที่หายไปนั้นจะไหลผ่านทุก expression ที่แตะมันด้วย
Symptom — real output:
30001string"3000" + 1 คือการต่อ string ไม่ใช่บวกเลข — nextPort เป็น string "30001" ไม่ใช่ number 3001 และ type system ไม่ทันสังเกตเลย
Fix: ให้ boundary มี return type จริง แล้ว validate ก่อนเชื่อ:
interface LibConfig { port: number }
function loadConfigFromUntypedLib(): LibConfig { const raw: unknown = { port: "3000" }; const r = raw as Record<string, unknown>; if (typeof r.port !== "number") throw new Error("expected numeric port"); return { port: r.port };}Caught by: typed rule no-unsafe-member-access ของ typescript-eslint โดยตรง — config.port คือ member access บนค่าที่ type เป็น any ดู Typing APIs & Boundaries
3. ! บนค่าจาก API
หัวข้อที่มีชื่อว่า “3. ! บนค่าจาก API”// @runinterface Session { user?: { name: string } }
function renderGreeting(session: Session): string { return `Hello, ${session.user!.name.toUpperCase()}`;}
console.log(renderGreeting({}));Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'name')Fix: เช็ค ไม่ใช่ assert:
interface Session { user?: { name: string } }
function renderGreeting(session: Session): string { if (!session.user) return "Hello, guest"; return `Hello, ${session.user.name.toUpperCase()}`;}
console.log(renderGreeting({}));Caught by: no-non-null-assertion ของ typescript-eslint โดยตรง — แต่มันแค่จับว่ามี ! อยู่ ไม่ได้บอกว่าตัวนี้ปลอดภัยหรือเปล่า ดู Nullability, never & Errors
4. @ts-ignore แทนการแก้จริง
หัวข้อที่มีชื่อว่า “4. @ts-ignore แทนการแก้จริง”// @runinterface Point { x: number; y: number }
function distance(a: Point, b: Point): number { // @ts-ignore -- suppresses "Property 'z' does not exist on type 'Point'" return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2 + a.z);}
console.log(distance({ x: 0, y: 0 }, { x: 3, y: 4 }));@ts-ignore ปิดปาก error ที่กำลังจะขึ้นบรรทัดถัดไป — ตรงนี้เป็น error จริง: Point ไม่มี z comment นี้เปลี่ยน compile-time error ให้กลายเป็น undefined แบบเงียบ ๆ
Symptom — real output:
NaNa.z เป็น undefined; undefined บวกกับ number ได้ NaN แล้ว Math.sqrt(NaN) ก็ได้ NaN
Fix: ลบทั้ง suppression และการเข้าถึง field ที่ผิด:
interface Point { x: number; y: number }
function distance(a: Point, b: Point): number { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);}
console.log(distance({ x: 0, y: 0 }, { x: 3, y: 4 }));Caught by: ban-ts-comment ของ typescript-eslint โดยตรง บน comment ปิด error เอง; tsc จับ bug จริงทันทีที่ลบ @ts-ignore ออก (error TS2339: Property 'z' does not exist on type 'Point'.) ดู Why TypeScript
5. Record<string, any> แทน schema
หัวข้อที่มีชื่อว่า “5. Record<string, any> แทน schema”// @runtype Flags = Record<string, any>;
function isEnabled(flags: Flags, name: string): boolean { return flags[name];}
const flags: Flags = { darkMdoe: true }; // typo -- "darkMdoe" not "darkMode"console.log(isEnabled(flags, "darkMode"));Record<string, any> รับ key อะไรก็ได้ สะกดยังไงก็ได้ ค่าอะไรก็ได้ — typo ใน object literal กับการเรียกที่สะกดถูก ทั้งคู่ “ถูกต้อง” เท่ากันในสายตาของ type
Symptom — real output:
undefinedFix: ตั้งชื่อ key จริง:
interface FeatureFlags { darkMode: boolean; betaBanner: boolean }
function isEnabled(flags: FeatureFlags, name: keyof FeatureFlags): boolean { return flags[name];}
const flags: FeatureFlags = { darkMode: true, betaBanner: false };console.log(isEnabled(flags, "darkMode"));ด้วย FeatureFlags key ที่ typo (darkMdoe) จะกลายเป็น compile error เอง (TS2561/excess property check) และ isEnabled(flags, "drakMode") จะพังด้วย TS2345 บน parameter ที่เป็น keyof FeatureFlags
Caught by: ไม่มีอะไรจับ bug นี้โดยตรง — no-explicit-any ของ typescript-eslint จับ any ข้างใน Record เอง แต่ไม่จับ typo ที่มันปล่อยผ่าน ดู Typing APIs & Boundaries
6. catch (e) { e.message }
หัวข้อที่มีชื่อว่า “6. catch (e) { e.message }”// @expect-error TS18046function parseAmount(input: string): number { try { return JSON.parse(input); } catch (e) { console.log(e.message); return 0; }}tsc output — real:
error TS18046: 'e' is of type 'unknown'.ตั้งแต่มี useUnknownInCatchVariables (ส่วนหนึ่งของกลุ่ม strict) ค่าที่ catch มา type เป็น unknown ไม่ใช่ any — เข้าถึง .message โดยไม่ narrow ก่อนเป็น compile error ไม่ใช่ระเบิดตอน runtime
Fix: narrow ก่อนใช้:
// @runfunction parseAmount(input: string): number { try { return JSON.parse(input); } catch (e) { console.log(e instanceof Error ? e.message : String(e)); return 0; }}
console.log(parseAmount("{not json"));Real output:
Expected property name or '}' in JSON at position 1 (line 1 column 2)0Caught by: tsc โดยตรง — error TS18046 ไม่ต้อง config อะไรเพิ่มนอกจาก --strict ดู Typing APIs & Boundaries
Nullability & falsy checks
หัวข้อที่มีชื่อว่า “Nullability & falsy checks”7. optional + if (x) มอง 0 ที่ถูกต้องว่าหายไป
หัวข้อที่มีชื่อว่า “7. optional + if (x) มอง 0 ที่ถูกต้องว่าหายไป”// @runinterface Product { stockCount?: number }
function availability(product: Product): string { if (product.stockCount) { return `${product.stockCount} in stock`; } return "unknown availability"; // meant for "field not provided", but 0 hits it too}
console.log(availability({ stockCount: 0 }));stockCount: 0 คือค่าที่มีอยู่จริง ถูกต้องสมบูรณ์ — “มีของเหลือศูนย์ชิ้น” — แต่ if (product.stockCount) มองมันเหมือน undefined เป๊ะ
Symptom — real output:
unknown availabilityที่ควรพูดว่า “out of stock” ไม่ใช่ “unknown” — field นี้ถูกส่งมาแล้ว แต่โค้ดแยกไม่ออกระหว่าง “ไม่ได้ส่งมา” กับ “ส่งมาเป็นศูนย์”
Fix: เช็คว่ามีอยู่จริงแบบตรง ๆ:
interface Product { stockCount?: number }
function availability(product: Product): string { if (product.stockCount === undefined) return "unknown availability"; return product.stockCount > 0 ? `${product.stockCount} in stock` : "out of stock";}
console.log(availability({ stockCount: 0 }));Caught by: ไม่มีอะไรจับใน default rule set ของคอร์สนี้ — --strict ไม่มีความเห็นกับการเช็ค truthiness บน number | undefined และ typed rule strict-boolean-expressions ที่จะจับได้เป็น opt-in ไม่ได้อยู่ใน 6 typed rule ที่คอร์สนี้ติดตั้ง ดู Narrowing
8. || แทน ??
หัวข้อที่มีชื่อว่า “8. || แทน ??”0 retries silently becomes 3
interface Settings { retries?: number }
function getRetries(settings: Settings): number { return settings.retries || 3;}
console.log(getRetries({ retries: 0 })); retries: 0 แปลตรง ๆ ว่า “ไม่มี retry เลย” — แต่ 0 || 3 ได้ 3 เพราะ || ผ่านไปทุกค่าที่ falsy ไม่ใช่แค่ null/undefined
Symptom — real output:
3Fix: ?? ผ่านไปเฉพาะ null/undefined เท่านั้น:
interface Settings { retries?: number }
function getRetries(settings: Settings): number { return settings.retries ?? 3;}
console.log(getRetries({ retries: 0 }));Real output:
0Caught by: prefer-nullish-coalescing ของ typescript-eslint โดยตรง บน settings.retries || 3 ดู Nullability, never & Errors
9. readonly ตื้น
หัวข้อที่มีชื่อว่า “9. readonly ตื้น”Readonly<T> stops at the top level
interface Address { city: string }interface UserProfile { name: string; address: Address }
function freezeProfile(profile: UserProfile): Readonly<UserProfile> { return profile;}
const profile: Readonly<UserProfile> = freezeProfile({ name: "Ada", address: { city: "London" } });profile.address.city = "Paris"; // Readonly<T> is shallow -- nested objects stay mutableconsole.log(profile.address.city); Readonly<UserProfile> ทำให้แค่ property ของ UserProfile เองเป็น readonly — address เองถูก assign ใหม่ไม่ได้ แต่ property ของ address ยัง mutable เหมือนเดิมทุกประการ
Symptom — real output:
Parisการ assign ผ่านทั้งตอน compile และตอน runtime แบบเงียบ ๆ — ไม่มีอะไรใน Readonly<T> ถูกละเมิดเลย เพราะ Readonly<T> ไม่เคยพูดถึง nested object ของ T เลยตั้งแต่แรก
Fix: DeepReadonly<T> แบบ recursive (และ Object.freeze ทุกระดับ ถ้าต้องการบังคับตอน runtime ด้วย):
// @expect-error TS2540interface Address { city: string }interface UserProfile { name: string; address: Address }
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };
function freezeDeep<T extends object>(obj: T): DeepReadonly<T> { Object.freeze(obj); for (const key of Object.keys(obj) as (keyof T)[]) { const value = obj[key]; if (value && typeof value === "object") freezeDeep(value as object); } return obj as DeepReadonly<T>;}
const profile = freezeDeep<UserProfile>({ name: "Ada", address: { city: "London" } });profile.address.city = "Paris";tsc output — real:
error TS2540: Cannot assign to 'city' because it is a read-only property.Caught by: ไม่มีอะไรจับสำหรับ Readonly<T> ธรรมดา — tsc จับการ mutate ที่ nested ได้ก็ต่อเมื่อโค้ดเลือกใช้ DeepReadonly<T> แบบ recursive ตามข้างบนเท่านั้น ดู Typing APIs & Boundaries
10. object ที่ mutable ถูกแชร์เป็น default export ของ module
หัวข้อที่มีชื่อว่า “10. object ที่ mutable ถูกแชร์เป็น default export ของ module”// @runexport const defaultSettings = { retries: 3, timeoutMs: 1000 };
function loadFeatureA(overrides: Partial<typeof defaultSettings> = {}) { Object.assign(defaultSettings, overrides); // mutates the SHARED default object return defaultSettings;}
function loadFeatureB() { return defaultSettings; // sees whatever loadFeatureA last mutated}
loadFeatureA({ retries: 0 });console.log(loadFeatureB());defaultSettings คือ object เดียว ที่ทุกคนที่ import ใช้ร่วมกัน Object.assign(defaultSettings, overrides) ไม่ได้สร้างสำเนาต่อ call — มันแก้ object เดียวกันที่ทุก import ในอนาคตจะเห็นด้วย
Symptom — real output:
{ retries: 0, timeoutMs: 1000 }loadFeatureB ที่ไม่เคยขอ override อะไรเลย ก็รายงาน retries: 0 ด้วย — override เฉพาะกิจของ feature A รั่วไปหา consumer อื่นของ module เดียวกันทั้งหมด
Fix: factory function ที่คืน object ใหม่ทุกครั้งที่เรียก:
// @runfunction createDefaultSettings() { return { retries: 3, timeoutMs: 1000 };}
function loadFeatureA(overrides: Partial<ReturnType<typeof createDefaultSettings>> = {}) { return { ...createDefaultSettings(), ...overrides };}
function loadFeatureB() { return createDefaultSettings();}
console.log(loadFeatureA({ retries: 0 }));console.log(loadFeatureB());Real output:
{ retries: 0, timeoutMs: 1000 }{ retries: 3, timeoutMs: 1000 }Caught by: ไม่มีอะไรจับ — นี่คือ bug ของ shared mutable module state ซึ่ง type checker มองไม่เห็นเลย (type ของ defaultSettings ไม่เปลี่ยน) และ typed lint rule ทุกตัวในลิสต์ของคอร์สนี้ก็มองไม่เห็นเหมือนกัน ดู Typing APIs & Boundaries
Async & control flow
หัวข้อที่มีชื่อว่า “Async & control flow”11. ลืม await (floating promise)
หัวข้อที่มีชื่อว่า “11. ลืม await (floating promise)”demo หลักด้านบนสุดของหน้านี้คือ pattern นี้เอง — ดูที่นั่น saveAuditLog(...) โดยไม่มี await compile ผ่านสะอาด แล้ว return ก่อนที่ log line ของตัวมันเองจะรันด้วยซ้ำ
Fix: await มัน หรือทำให้ผู้เรียกเป็น async ตลอดสาย:
async function saveAuditLog(message: string): Promise<void> { await new Promise<void>((resolve) => setTimeout(resolve, 10)); console.log("saved:", message);}
async function handleRequest(id: number): Promise<string> { await saveAuditLog(`request ${id} handled`); return `response-${id}`;}
handleRequest(1).then((r) => console.log(r));Caught by: no-floating-promises ของ typescript-eslint โดยตรง — typed rule ที่จับ expression statement ที่คืน Promise โดยไม่มี await, ไม่มี .catch, และไม่มี void operator ดู Performance & Pitfalls
12. Promise.all บน array ที่ widen แล้ว ไม่ใช่ tuple
หัวข้อที่มีชื่อว่า “12. Promise.all บน array ที่ widen แล้ว ไม่ใช่ tuple”// @expect-error TS2339interface User { name: string }
function fetchUser(): Promise<User> { return Promise.resolve({ name: "Ada" }); }function fetchCount(): Promise<number> { return Promise.resolve(42); }
async function loadDashboard() { const tasks = [fetchUser(), fetchCount()]; // inferred Promise<User | number>[], not a tuple const [user, count] = await Promise.all(tasks); console.log(user.name); // User | number -- the positional types were lost}ถ้าส่ง inline Promise.all([fetchUser(), fetchCount()]) เก็บ type ของแต่ละตำแหน่งไว้ได้ แต่พอสร้างเป็น const tasks = [...] ก่อน array จะถูก infer เป็น Promise<User | number>[] — array type แบบเดียวกันหมด ไม่ใช่ tuple — แล้ว Promise.all บนมันจะคืน (User | number)[] ทิ้งไปว่าตำแหน่งไหนเป็น type อะไร
tsc output — real:
error TS2339: Property 'name' does not exist on type 'number | User'. Property 'name' does not exist on type 'number'.Fix: ส่ง tuple แบบ inline เพื่อเก็บตำแหน่งไว้:
interface User { name: string }
function fetchUser(): Promise<User> { return Promise.resolve({ name: "Ada" }); }function fetchCount(): Promise<number> { return Promise.resolve(42); }
async function loadDashboard() { const [user, count] = await Promise.all([fetchUser(), fetchCount()]); console.log(user.name, count);}
loadDashboard();Caught by: tsc โดยตรง (TS2339) — แต่จับแบบ reactive เท่านั้น คือต่อเมื่อโค้ดพยายามใช้ field ที่ type ที่ widen แล้วไม่มีอีกต่อไป ไม่มีอะไรเตือนตอนที่ tasks ถูกสร้างขึ้นครั้งแรกเลย ดู Performance & Pitfalls
13. switch ที่ดูครบทุก case แต่ไม่มี never check
หัวข้อที่มีชื่อว่า “13. switch ที่ดูครบทุก case แต่ไม่มี never check”A forgotten case, no error, wrong answer
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function describeShape(shape: Shape): string { let label = "unknown shape"; switch (shape.kind) { case "circle": label = "circle"; break; case "square": label = "square"; break; // "triangle" forgotten -- no default, no never check, no error } return label;}
console.log(describeShape({ kind: "triangle", base: 3, height: 4 })); function นี้ return “อะไรบางอย่าง” เสมอ เลยไม่มี “missing return” ให้ tsc จับ — case ที่ลืมก็แค่ตกไปที่ label default แบบเงียบ ๆ
Symptom — real output:
unknown shapeFix: branch default ที่ assign ค่าที่เหลือให้ตัวแปร type เป็น never — นี่คือสิ่งที่ทำให้ case ที่ลืมในอนาคตกลายเป็น compile error จริง:
// @expect-error TS2322type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function describeShape(shape: Shape): string { switch (shape.kind) { case "circle": return "circle"; case "square": return "square"; default: { const exhaustiveCheck: never = shape; return exhaustiveCheck; } }}ตอนที่ case triangle ยังหายอยู่ ตัว never check เองก็ compile ไม่ผ่านทันที — error TS2322: Type '{ kind: "triangle"; ...}' is not assignable to type 'never'. — เป็นสัญญาณตรงที่ reviewer อยากได้พอดี ทันทีที่ union โตขึ้น
Caught by: switch-exhaustiveness-check ของ typescript-eslint โดยตรง — มันจับ case "triangle" ที่หายไปได้จาก switch เอง โดยไม่ต้องเขียน never check ด้วยมือเลย; tsc อย่างเดียวจะจับ case ที่ลืมในอนาคตได้ก็ต่อเมื่อมี never check แบบข้างบนอยู่แล้วเท่านั้น ดู Nullability, never & Errors
14. this หลุดตอนส่ง method เป็น callback
หัวข้อที่มีชื่อว่า “14. this หลุดตอนส่ง method เป็น callback”// @runclass Counter { count = 0; increment(): void { this.count++; }}
const counter = new Counter();const handler = counter.increment; // detached from its instance[1, 2, 3].forEach(handler);console.log(counter.count);counter.increment พอถูกอ่านออกมาจาก instance ก็เป็นแค่ function value เฉย ๆ — ส่งมันให้ forEach จะเรียกมันโดย this ผูกกับอะไรก็ตามที่ forEach ใช้ข้างในเอง ไม่ใช่ counter
Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'count')Fix: arrow-function class field capture this แบบ lexical ตั้งแต่ตอนสร้าง:
// @runclass Counter { count = 0; increment = (): void => { this.count++; };}
const counter = new Counter();const handler = counter.increment;[1, 2, 3].forEach(handler);console.log(counter.count);Real output:
3Caught by: unbound-method ของ typescript-eslint โดยตรง — typed rule ที่จับ counter.increment ทันทีที่ถูกอ้างถึงโดยไม่ถูกเรียกหรือ bind ดู this, Polymorphism & Binding
Generics & variance
หัวข้อที่มีชื่อว่า “Generics & variance”15. Array<Dog> ส่งเข้าที่ที่ต้องการ Array<Animal>
หัวข้อที่มีชื่อว่า “15. Array<Dog> ส่งเข้าที่ที่ต้องการ Array<Animal>”Dog[] accepted as Animal[], then corrupted
class Animal { speak(): string { return "..."; } }class Dog extends Animal { bark(): string { return "woof"; } }class Cat extends Animal { speak(): string { return "meow"; } }
function addACat(animals: Animal[]): void { animals.push(new Cat()); // perfectly legal for Animal[]}
const dogs: Dog[] = [new Dog(), new Dog()];addACat(dogs); // compiles -- Dog[] is assignable to Animal[]for (const d of dogs) { console.log(d.bark());} Dog[] assignable เป็น Animal[] ได้ เพราะ array (และ method push ของมัน ที่เขียนแบบ shorthand ใน lib.d.ts) ถูกตรวจแบบ bivariant ผ่อนปรนเดียวกับที่ section variance ของคอร์สนี้สอนไว้กับ Stack<T> ที่เขียนเอง — นี่คือ Array ในตัวที่ชนหลุมเดียวกันเป๊ะ
Symptom — real output:
woofwoofTypeError: d.bark is not a functionaddACat push Cat เข้าไปในสิ่งที่ dogs ยังเชื่อว่าเป็น array ของ Dog ล้วน ๆ — element ตัวที่สามทำให้ .bark() ครั้งถัดไปพังทันที
Fix: ถ้า function แค่ต้องการอ่าน ให้รับ readonly Animal[] — type แบบ covariant ที่ไม่มี push ให้ใช้ประโยชน์:
class Animal { speak(): string { return "..."; } }class Dog extends Animal { bark(): string { return "woof"; } }class Cat extends Animal { speak(): string { return "meow"; } }
function describeAll(animals: readonly Animal[]): void { for (const a of animals) console.log(a.speak());}
const dogs: Dog[] = [new Dog(), new Dog()];describeAll(dogs);for (const d of dogs) { console.log(d.bark());}Caught by: ไม่มีอะไรจับ — นี่คือ unsoundness ที่ตั้งใจและมี document ไว้ชัดเจนอันเดียวกับที่ section variance ของคอร์สนี้แสดงบน class ที่เขียนเอง (array covariance บวก method-shorthand bivariance) ดู Generic Classes & Interfaces — Variance
16. type predicate ที่ return true แบบไม่มีเงื่อนไข
หัวข้อที่มีชื่อว่า “16. type predicate ที่ return true แบบไม่มีเงื่อนไข”isAdmin always says yes
interface Admin { role: "admin"; permissions: string[] }
function isAdmin(user: unknown): user is Admin { return true; // always claims true -- the shape is never actually checked}
function grantAccess(user: unknown): void { if (isAdmin(user)) { console.log(user.permissions.join(", ")); // tsc trusts the predicate completely }}
grantAccess({ name: "definitely not an admin" }); signature ของ type predicate ที่ผู้ใช้เขียนเอง (user is Admin) คือสิ่งเดียวที่ checker ดู — มันไม่เคยตรวจ body เลยว่าพิสูจน์คำกล่าวอ้างนั้นได้จริงหรือเปล่า
Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'join')Fix: implement runtime check จริงที่ตรงกับ shape ที่กล่าวอ้าง:
// @runinterface Admin { role: "admin"; permissions: string[] }
function isAdmin(user: unknown): user is Admin { return typeof user === "object" && user !== null && (user as Record<string, unknown>).role === "admin" && Array.isArray((user as Record<string, unknown>).permissions);}
function grantAccess(user: unknown): void { if (isAdmin(user)) { console.log(user.permissions.join(", ")); } else { console.log("access denied"); }}
grantAccess({ name: "definitely not an admin" });Real output:
access deniedCaught by: ไม่มีอะไรจับ — type predicate ถูกเชื่อแบบไม่มีเงื่อนไขโดยทุก check ที่ตามมา ไม่มีเครื่องมือไหนในลิสต์ของคอร์สนี้ประเมินว่า body ของ predicate พิสูจน์สิ่งที่ signature กล่าวอ้างจริงหรือเปล่า ดู Narrowing
17. interface ที่มี implementer เดียว กับ generic ที่ใช้ครั้งเดียว
หัวข้อที่มีชื่อว่า “17. interface ที่มี implementer เดียว กับ generic ที่ใช้ครั้งเดียว”interface UserRecord { id: number; name: string }
interface Repository<T> { save(item: T): void;}
class UserRepository implements Repository<UserRecord> { save(item: UserRecord): void { console.log("saving", item); }}
new UserRepository().save({ id: 1, name: "Ada" });Repository<T> มี implementer เดียวตลอดกาล และ T ถูกใช้แค่ตำแหน่งเดียว — generic interface นี้ไม่ได้ให้ polymorphism หรือ reuse อะไรเลย มันเป็นแค่ indirection ที่ AI assistant เติมเข้ามาเพราะ “repository ต้อง implement interface” เป็น shape ที่คุ้นเคย ไม่ใช่เพราะ codebase นี้ต้องการมันจริง ๆ
Symptom: ไม่มี crash ไม่มี diagnostic — ต้นทุนคือผู้อ่านต้องเปิดสอง declaration (Repository<T> กับ UserRepository) เพื่อเข้าใจว่า class เดียวทำอะไร แล้ว implementer ตัวที่สองก็ไม่เคยโผล่มาให้เหตุผลกับ abstraction นี้เลย
Fix: ลบ interface ทิ้ง เก็บแค่ class ที่ concrete:
interface UserRecord { id: number; name: string }
class UserRepository { save(item: UserRecord): void { console.log("saving", item); }}
new UserRepository().save({ id: 1, name: "Ada" });Caught by: ไม่มีอะไรจับโดยตรง — รายงาน unused-exports ของ knip โผล่ Repository เป็น export ที่ถูกอ้างถึงจากที่เดียว ซึ่งเป็นสัญญาณที่ควรไปดู แต่ไม่มีเครื่องมือไหนบอกตรง ๆ ว่า “generic parameter ตัวนี้ไม่ได้ให้ reuse จริง” ดู Generic Functions & Constraints
18. Omit บน union ยุบเหลือ {}
หัวข้อที่มีชื่อว่า “18. Omit บน union ยุบเหลือ {}”ShapeWithoutKind accepts anything
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number };
type ShapeWithoutKind = Omit<Shape, "kind">; // Omit does not distribute over a union
const bogus: ShapeWithoutKind = { anything: "whatever", nonsense: 123 };console.log(bogus); Omit<T, K> คือ Pick<T, Exclude<keyof T, K>> และ keyof บน union คือ intersection ของ key ของแต่ละ member — ตรงนี้มีแค่ "kind" ที่ทั้งสอง branch มีร่วมกัน Exclude<"kind", "kind"> ได้ never เลย Pick<Shape, never> ได้ {} — object type ว่างที่รับ object อะไรก็ได้
Symptom — real output:
{ anything: 'whatever', nonsense: 123 }ShapeWithoutKind ควรจะแปลว่า “Shape ที่ไม่มี tag kind” — { radius: number } | { side: number } — แต่กลับแปลว่า “object อะไรก็ได้” แบบเงียบ ๆ และ tsc ก็ยอมรับ shape ที่ไม่เกี่ยวกันเลยโดยไม่บ่นอะไร
Fix: ตัวช่วยแบบ distributive ที่รัน Omit ทีละ union member ตามที่ผู้เขียนตั้งใจไว้จริง ๆ:
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number };
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
type ShapeWithoutKind = DistributiveOmit<Shape, "kind">;
const s1: ShapeWithoutKind = { radius: 5 };const s2: ShapeWithoutKind = { side: 4 };console.log(s1, s2);ด้วย naked type parameter T ใน conditional ตอนนี้ union กระจายจริง — ส่ง bogus object ตัวเดียวกันจากข้างบนเข้ามาจะถูกปฏิเสธ: error TS2353: Object literal may only specify known properties, and 'anything' does not exist in type 'ShapeWithoutKind'.
Caught by: ไม่มีอะไรจับ — Omit/Pick ที่กระจายบน union ไม่ได้แบบเงียบ ๆ เป็นกับดักที่รู้กันดีแต่ไม่มี diagnostic ทางเดียวที่จะสังเกตได้คืออ่าน (หรือ probe ด้วย Equal check) ว่า alias นั้น resolve เป็นอะไรจริง ๆ ดู The Utility Types
19. recursive type ที่ลึกเกินไป
หัวข้อที่มีชื่อว่า “19. recursive type ที่ลึกเกินไป”// @expect-error TS2589type BuildTuple<N extends number, R extends unknown[] = []> = R["length"] extends N ? R : BuildTuple<N, [...R, unknown]>;
type Deep = BuildTuple<1200>;tsc output — real:
error TS2589: Type instantiation is excessively deep and possibly infinite.recursive conditional type แบบนี้มี depth limit ที่ compiler บังคับไว้ — บท recursive-types ของคอร์สนี้วัดขอบเขตจริงไว้แล้ว (tail-recursive form ผ่านได้ถึง 999 ระดับ พังที่ 1000; form แบบ naive นี้พังเร็วกว่านั้นมาก)
Fix: อยู่ใต้ limit ที่วัดไว้ให้ไกลพอ หรือปรับโครงสร้างให้ไม่ recurse แบบไม่จำกัดบน input ที่ผู้ใช้ควบคุมได้:
type BuildTuple<N extends number, R extends unknown[] = []> = R["length"] extends N ? R : BuildTuple<N, [...R, unknown]>;
type Safe = BuildTuple<200>;declare const t: Safe;console.log(t.length);Caught by: tsc โดยตรง (TS2589) ไม่ต้อง config อะไร — แต่จับก็ต่อเมื่อ depth เกินไปแล้วจริง ๆ ไม่มีอะไรเตือนตอนที่ recursion กำลังไต่เข้าใกล้ limit เลย ดู Recursive Types
Erasure & config
หัวข้อที่มีชื่อว่า “Erasure & config”20. enum ใน codebase ที่เป็น erasable syntax
หัวข้อที่มีชื่อว่า “20. enum ใน codebase ที่เป็น erasable syntax”// @runenum Direction { Up, Down, Left, Right }
function move(direction: Direction): string { return Direction[direction];}
console.log(move(Direction.Up));ตัวนี้ compile ผ่านใต้ tsc --strict โดย ไม่มี error เลย — enum เป็น TypeScript ธรรมดามาก ปัญหาจะโผล่มาก็ต่อเมื่อมีอะไรอื่นที่ไม่ใช่ tsc พยายามรันไฟล์นี้ตรง ๆ เพราะ numeric enum ต้องการโค้ดที่ emit จริง (object พร้อม reverse mapping) ไม่ใช่แค่ annotation ที่ถูกลบทิ้ง
Symptom — real output (node --experimental-strip-types บนไฟล์ข้างบนตรง ๆ):
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeFix: object ที่เป็น as const บวก union type ที่ derive จากมัน เป็น erasable — ไม่ต้องมี runtime support พิเศษเลย เพราะมันเป็นแค่ object literal ธรรมดา:
// @runconst Direction = { Up: "Up", Down: "Down", Left: "Left", Right: "Right" } as const;type Direction = typeof Direction[keyof typeof Direction];
function move(direction: Direction): string { return direction;}
console.log(move(Direction.Up));Real output:
UpCaught by: ไม่มีอะไรจับผ่าน tsc --strict หรือ typed lint rule ไหนที่คอร์สนี้ติดตั้ง — มีแค่การรันไฟล์จริง (node --experimental-strip-types หรือ compile ด้วย --erasableSyntaxOnly) เท่านั้นที่เผยความล้มเหลวออกมา ดู Objects, Arrays & Tuples
21. parameter property ใต้ strip-types
หัวข้อที่มีชื่อว่า “21. parameter property ใต้ strip-types”// @runclass Point2 { constructor(public readonly px: number, public readonly py: number) {}}
const pt = new Point2(3, 4);console.log(pt.px, pt.py);parameter property (public readonly px: number ข้างใน constructor signature) เป็น shorthand เฉพาะของ TypeScript สำหรับประกาศ field และ assign มันใน constructor body — ต้อง emit โค้ดจริงสำหรับการ assign นั้น เหมือนกับ enum มันเลย erasable ไม่ได้
Symptom — real output:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only modeFix: ประกาศ field แล้ว assign แบบตรง ๆ:
// @runclass Point2 { readonly px: number; readonly py: number; constructor(px: number, py: number) { this.px = px; this.py = py; }}
const pt = new Point2(3, 4);console.log(pt.px, pt.py);Real output:
3 4Caught by: ไม่มีอะไรจับผ่าน tsc --strict — ช่องว่างเดียวกับ pattern 20: compile ผ่านสะอาด แล้วพังก็ต่อเมื่อมีอะไรอื่นที่ไม่ใช่ tsc (node --experimental-strip-types, esbuild, swc) พยายามรันมันตรง ๆ ดู Build & Emit
22. const enum reverse mapping กับ boundary ข้ามไฟล์
หัวข้อที่มีชื่อว่า “22. const enum reverse mapping กับ boundary ข้ามไฟล์”// @expect-error TS2476const enum LogLevel { Info, Warn, Error }
function label(level: LogLevel): string { return LogLevel[level];}tsc output — real:
error TS2476: A const enum member can only be accessed using a string literal.const enum ถูก inline ที่ทุกจุดใช้งานแล้ว erase ทิ้งไปทั้งหมด — ไม่เหลือ runtime object ให้ทำ reverse lookup (LogLevel[level]) เลย ซึ่งเป็นสิ่งที่ TS2476 รายงานพอดี คุณสมบัติ “ไม่มี runtime object เหลือ” อันเดียวกันนี้เองที่ทำให้ single-file transpiler (esbuild, swc, และ tsc --isolatedModules ในกรณีทั่วไป) รองรับ const enum ที่ import จากไฟล์อื่นไม่ได้: การ inline ค่าของมันต้องเห็น declaration ของ enum เอง ซึ่งการ compile ทีละไฟล์ไม่มีข้อมูลนั้น
Fix: object แบบ as const — pattern เดียวกับ pattern 20 ที่ erasable และ inline ได้ปลอดภัย:
// @runconst LogLevel = { Info: 0, Warn: 1, Error: 2 } as const;type LogLevel = typeof LogLevel[keyof typeof LogLevel];
function label(level: LogLevel): string { return level === LogLevel.Warn ? "WARN" : "OTHER";}
console.log(label(LogLevel.Warn));Real output:
WARNCaught by: tsc โดยตรง (TS2476) สำหรับ reverse-mapping form ข้างบน; ความล้มเหลวข้ามไฟล์/single-file transpiler เป็นข้อจำกัดของ build tool ไม่ใช่สิ่งที่ tsc --strict เองรายงาน ดู Build & Emit
23. import แทน import type ใต้ verbatimModuleSyntax
หัวข้อที่มีชื่อว่า “23. import แทน import type ใต้ verbatimModuleSyntax”// @skip-verify two-file demo (./types.ts) plus --verbatimModuleSyntax; real tsc output quoted belowimport { UserRecord } from "./types.js"; // value-style import of a type-only symbol
export function greet(user: UserRecord): string { return `Hello, ${user.name}`;}พอเปิด verbatimModuleSyntax ทุก import จะถูก emit ตามที่เขียนไว้เป๊ะ — import { UserRecord } แบบธรรมดาสำหรับชื่อที่ใช้เป็น type อย่างเดียว จะกลายเป็น runtime import จริงของสิ่งที่อาจไม่มีอยู่แม้แต่เป็น value export ด้วยซ้ำ
tsc output — real (compile ไฟล์นี้พร้อมไฟล์ข้าง ๆ types.ts ที่ export interface UserRecord โดยเปิด --verbatimModuleSyntax):
error TS1484: 'UserRecord' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.Fix: ระบุว่า import นี้เป็น type-only:
// @skip-verify two-file demo (./types.ts) plus --verbatimModuleSyntaximport type { UserRecord } from "./types.js";
export function greet(user: UserRecord): string { return `Hello, ${user.name}`;}Caught by: tsc โดยตรง (TS1484) ทันทีที่เปิด verbatimModuleSyntax; consistent-type-imports ของ typescript-eslint จับ mistake เดียวกันได้ก่อนล่วงหน้าแม้ flag จะปิดอยู่ก็ตาม ดู tsconfig Deep Dive
Indexing & keys
หัวข้อที่มีชื่อว่า “Indexing & keys”24. Object.keys(obj).forEach(k => obj[k])
หัวข้อที่มีชื่อว่า “24. Object.keys(obj).forEach(k => obj[k])”// @expect-error TS7053interface Config { host: string; port: number }
function logAll(config: Config): void { Object.keys(config).forEach((key) => { console.log(key, config[key]); });}Object.keys คืน string[] เสมอ ไม่ว่า type จริงของ object จะเป็นอะไร — Config ไม่มี index signature ที่รับ string แบบเปะปะ เลยเอามัน index เข้าไปแบบนั้นไม่ sound เท่าที่ checker พิสูจน์ได้ และมันก็บอกอย่างนั้นเลย
tsc output — real:
error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Config'. No index signature with a parameter of type 'string' was found on type 'Config'.Fix: assert array ของ key กลับเป็น (keyof Config)[] — ปลอดภัยตรงนี้เพราะ Object.keys คืนแค่ enumerable key ของ Config เองเท่านั้น:
interface Config { host: string; port: number }
function logAll(config: Config): void { (Object.keys(config) as (keyof Config)[]).forEach((key) => { console.log(key, config[key]); });}
logAll({ host: "localhost", port: 3000 });Caught by: tsc โดยตรง (TS7053 ส่วนหนึ่งของ noImplicitAny ใต้ --strict) — เป็นหนึ่งในไม่กี่entryในแคตาล็อกนี้ที่ tsc ปฏิเสธตรง ๆ โดยไม่ต้องใช้เครื่องมือเพิ่ม ดู Typing APIs & Boundaries
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”24 patterns, sorted by who actually catches them:
tsc --strict, directly -> 6 (TS18046), 12 (TS2339, reactively), 19 (TS2589), 22 (TS2476), 23 (TS1484, only with verbatimModuleSyntax), 24 (TS7053)typescript-eslint (typed) -> 2 (no-unsafe-member-access), 3 (no-non-null-assertion), 4 (ban-ts-comment), 8 (prefer-nullish-coalescing), 11 (no-floating-promises), 13 (switch-exhaustiveness-check), 14 (unbound-method), 23 (consistent-type-imports, proactively)Only by actually running it -> 20, 21 (node --experimental-strip-types / --erasableSyntaxOnly; tsc --strict is silent)knip (indirect signal only) -> 17 (single-reference export -- a hint, not a direct catch)Nothing -> 1, 5, 7, 9 (for plain Readonly<T>), 10, 15, 16, 18 -- a third of the catalog