Worked review 1: a fetch-and-render service
นี่คือรีวิวเต็มบทแรกจากทั้งหมดสี่บท แต่ละบทให้โปรแกรมสไตล์ AI แบบเต็ม ให้ลองหา bug ที่ปลูกไว้ด้วยนิสัยจากสี่บทที่ผ่านมา แล้วค่อยดู output จริงของเครื่องมือจริง กับ version ที่แก้แล้วซึ่ง compile และรันได้ถูกต้องจริง ๆ บทนี้เป็น service เล็ก ๆ ที่ fetch user record แล้ว render ข้อความต้อนรับ — boundary หนึ่ง floating promise หนึ่ง non-null assertion หนึ่ง และ default value หนึ่ง ทั้งหมดในราว 35 บรรทัด
โปรแกรม
หัวข้อที่มีชื่อว่า “โปรแกรม”Review this before reading further
// @runinterface User { id: number; name: string; nickname?: string }
async function fetchUserJson(id: number): Promise<string> { // stands in for a real fetch(`/api/users/${id}`).then(r => r.text()) return id === 1 ? '{"id": 1, "name": "Ada"}' : '{"id": 2, "name": "Grace", "nickname": "Amazing Grace"}';}
async function fetchUser(id: number): Promise<User> { const raw = await fetchUserJson(id); return JSON.parse(raw) as User; // BUG 1: as -- shape never validated}
async function logAccessAsync(id: number): Promise<void> { await new Promise<void>((resolve) => setTimeout(resolve, 5)); console.log(`access logged for user ${id}`);}
async function renderProfile(id: number): Promise<string> { logAccessAsync(id); // BUG 2: floating promise -- missing await const user = await fetchUser(id); const displayName = user.nickname!.toUpperCase(); // BUG 3: non-null assertion on optional field return `Welcome, ${displayName}`;}
interface RenderOptions { retries?: number }
function getRetryCount(options: RenderOptions): number { return options.retries || 3; // BUG 4: || instead of ?? -- explicit 0 silently becomes 3}
async function main(): Promise<void> { console.log("retries for explicit 0:", getRetryCount({ retries: 0 })); console.log(await renderProfile(2)); console.log(await renderProfile(1)); // user 1 has no nickname -- crashes}
main(); กด Check types: tsc --strict รายงาน ไม่มี error เลย กด Run: มันพิมพ์สองบรรทัดแล้ว crash
Real output:
retries for explicit 0: 3Welcome, AMAZING GRACETypeError: Cannot read properties of undefined (reading 'toUpperCase') at renderProfile (...)สังเกตสิ่งที่หายไปจาก output นี้ทั้งหมด: access logged for user 2 ไม่เคยพิมพ์เลย timer 5ms ของ logAccessAsync(2) ไม่เคยมีโอกาสยิงเลย เพราะ process crash ก่อน — อันตรายที่ floating promise สร้างไว้พอดี: side effect ของมันหายไปได้เฉย ๆ โดยไม่มีอะไรบอกว่ามันควรจะเกิดขึ้น
ลองหา bug ก่อนอ่านต่อ
หัวข้อที่มีชื่อว่า “ลองหา bug ก่อนอ่านต่อ”bug ที่ปลูกไว้สี่ตัว หนึ่ง boundary หนึ่ง async สอง nullability ลองบอกให้ครบทั้งสี่ — พร้อมเลขบรรทัดและว่าอะไรผิดจริง ๆ — ก่อนเปิดเฉลยข้างล่าง
Bug 1 — บรรทัด 12, JSON.parse(raw) as User
Symptom: fetchUser compile ราวกับว่าคืน User จริงเสมอ แต่ไม่มีอะไรเช็คว่า JSON ที่ parse ออกมามี id กับ name จริง
Evidence: มันทำงานได้กับทั้งสอง id ในโปรแกรมนี้แค่เพราะ payload ปลอมทั้งคู่ shape ถูกต้อง — ทันทีที่ boundary function คืนอย่างอื่น (หน้า HTML 404, error object, record ที่ไม่ครบจาก backend ที่ไม่เสถียร) as User ก็ยัง compile ผ่าน แล้วยังส่งค่าที่ผู้เรียกจะเชื่อว่าไว้ใจได้ให้อยู่ดี
Fix: type guard จริง ที่เช็คกับค่าที่ parse ออกมาก่อนจะเชื่อ:
interface User { id: number; name: string; nickname?: 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";}Bug 2 — บรรทัด 21, logAccessAsync(id); ไม่มี await
Symptom: side effect ของการ log การเข้าถึงถูกยิงแล้วลืมทันที renderProfile เดินหน้าไปบรรทัดถัดไปโดยไม่รอมันเลย
Evidence: real output ข้างบนพิสูจน์ตรง ๆ — access logged for user 2 ไม่เคยพิมพ์เลย เพราะ process crash ตอนเรียก renderProfile ครั้งถัดไปก่อนที่ timer 5ms จะมีโอกาสยิง ใน server ที่รันนานกว่านี้ มันก็หาย log แบบเงียบ ๆ ได้พอ ๆ กันโดยไม่ต้อง crash เลย แค่ลำดับที่โชคร้าย
Fix: await มัน
// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version belowasync function renderProfile(id: number): Promise<string> { await logAccessAsync(id); // ...}Bug 3 — บรรทัด 23, user.nickname!.toUpperCase()
Symptom: nickname ถูก type เป็น string | undefined ด้วยเหตุผล — ไม่ใช่ทุก user จะมี — และ ! บอก checker ให้ไม่สนใจสิ่งนั้นเลย
Evidence: การ crash จริง payload ของ user 1 ไม่มี nickname user.nickname! เป็น undefined ตอน runtime แล้ว .toUpperCase() throw TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Fix: fallback ไปที่สิ่งที่มีอยู่เสมอ อย่าง name:
// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version belowconst displayName = (user.nickname ?? user.name).toUpperCase();Bug 4 — บรรทัด 30, options.retries || 3
Symptom: ผู้เรียกที่ส่ง retries: 0 มาตรง ๆ (แปลว่า “ห้าม retry เลย”) จะได้ 3 แบบเงียบ ๆ แทน
Evidence: บรรทัดแรกของ real output ข้างบน — retries for explicit 0: 3 ไม่ใช่ 0
Fix: ?? ผ่านไปเฉพาะ null/undefined ไม่ใช่ทุกค่า falsy:
// @skip-verify partial snippet, illustrates the fix only -- see the full fixed version belowfunction getRetryCount(options: RenderOptions): number { return options.retries ?? 3;}Version ที่แก้แล้ว
หัวข้อที่มีชื่อว่า “Version ที่แก้แล้ว”// @runinterface User { id: number; name: string; nickname?: 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";}
async function fetchUserJson(id: number): Promise<string> { return id === 1 ? '{"id": 1, "name": "Ada"}' : '{"id": 2, "name": "Grace", "nickname": "Amazing Grace"}';}
async function fetchUser(id: number): Promise<User> { const raw = await fetchUserJson(id); const parsed: unknown = JSON.parse(raw); if (!isUser(parsed)) throw new Error(`invalid user payload for id ${id}`); return parsed;}
async function logAccessAsync(id: number): Promise<void> { await new Promise<void>((resolve) => setTimeout(resolve, 5)); console.log(`access logged for user ${id}`);}
async function renderProfile(id: number): Promise<string> { await logAccessAsync(id); const user = await fetchUser(id); const displayName = (user.nickname ?? user.name).toUpperCase(); return `Welcome, ${displayName}`;}
interface RenderOptions { retries?: number }
function getRetryCount(options: RenderOptions): number { return options.retries ?? 3;}
async function main(): Promise<void> { console.log("retries for explicit 0:", getRetryCount({ retries: 0 })); console.log(await renderProfile(2)); console.log(await renderProfile(1));}
main();Real output:
retries for explicit 0: 0access logged for user 2Welcome, AMAZING GRACEaccess logged for user 1Welcome, ADAทุกบรรทัด log โผล่มาแล้ว ตามลำดับที่ถูกต้อง และ user 1 — ที่ไม่มี nickname — fallback ไปที่ name แทนที่จะ crash
เครื่องมือจับอะไรได้บ้าง
หัวข้อที่มีชื่อว่า “เครื่องมือจับอะไรได้บ้าง”tsc --strict บน version ที่มี bug: ไม่มี error เลย ทั้งใน version มี bug และ version ที่แก้แล้ว — รีวิวทั้งบทนี้มองไม่เห็นเลยสำหรับ compiler อย่างเดียว
typed rule ของ typescript-eslint รันกับ version ที่มี bug:
21:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator @typescript-eslint/no-floating-promises23:23 error Forbidden non-null assertion @typescript-eslint/no-non-null-assertion30:26 error Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator @typescript-eslint/prefer-nullish-coalescing
✖ 3 problems (3 errors, 0 warnings)สามในสี่ bug ถูกจับโดยตรงและเจาะจง Bug 1 — as User cast ที่ boundary — คือตัวเดียวที่ไม่มีเครื่องมือไหนในบันไดของคอร์สนี้จับได้ ต้องอ่าน boundary เองและรันโค้ดจริงถึงจะเจอ