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

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

// @run
interface 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();
Open in TS Playground

กด Check types: tsc --strict รายงาน ไม่มี error เลย กด Run: มันพิมพ์สองบรรทัดแล้ว crash

Real output:

retries for explicit 0: 3
Welcome, AMAZING GRACE
TypeError: 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 ที่ปลูกไว้สี่ตัว หนึ่ง 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 below
async 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 below
const 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 below
function getRetryCount(options: RenderOptions): number {
return options.retries ?? 3;
}
// @run
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";
}
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: 0
access logged for user 2
Welcome, AMAZING GRACE
access logged for user 1
Welcome, 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-promises
23:23 error Forbidden non-null assertion @typescript-eslint/no-non-null-assertion
30: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 เองและรันโค้ดจริงถึงจะเจอ

version ที่มี bug ของโปรแกรมนี้ compile ผ่านโดยไม่มี tsc --strict error เลย bug ตัวไหนตัวเดียวที่ไม่มีเครื่องมือไหนในบันไดของบทนี้ (tsc หรือ typed eslint) จับได้?
ใน real output 'access logged for user 2' ไม่เคยพิมพ์เลย ทั้งที่ logAccessAsync(2) ถูกเรียกแล้ว ทำไม?
ทำไม getRetryCount({ retries: 0 }) คืน 3 ใน version ที่มี bug แทนที่จะเป็น 0?
version ที่แก้แล้วเปลี่ยน JSON.parse(raw) as User เป็นการเรียก function isUser มันเพิ่มอะไรจริง ๆ ที่ assertion ไม่มี?