แคตตาล็อก bug ที่ AI มักเขียน
ทุก pattern ด้านล่างคือโค้ดที่ AI assistant เขียนด้วยความมั่นใจจริง ๆ — หน้าตาเหมือนโค้ดที่ถูกต้อง รันได้กับ input ตัวอย่าง และผิดในแบบที่จะโผล่มาก็ต่อเมื่อใช้ input อื่น, ดิสก์ช้าลง, หรือมี load ต่อเนื่อง แต่ละรายการให้ snippet ที่ผิด, อาการจริงที่จับได้จากการรันจริงบน Node 24.13 (crash, ค่าที่ผิด, หรือ warning ที่ดักจับได้ — ไม่มีการแต่งขึ้นเอง), วิธีแก้, และเครื่องมือใดใน ladder ของคอร์สนี้ที่จับมันได้: node --test, --trace-warnings, พฤติกรรม crash เริ่มต้นของ unhandled rejection, กฎของ eslint-plugin-n/eslint-plugin-promise, npm audit, หรือ “ไม่มี — ต้องใช้คนเท่านั้น” บท Verification Tools จะแปลงลิสต์นี้เป็นตารางที่รันกับ diff จริงได้
Event loop & async
หัวข้อที่มีชื่อว่า “Event loop & async”1. ลืม await การเรียก async function โดยไม่มี await ก็ยังคงรันอยู่ — เพียงแค่มันคืนค่า Promise แทนค่าที่ resolve แล้ว และผู้เรียกได้เดินหน้าต่อไปแล้ว
Output (verified on Node 24)
user: undefinedไม่มี crash ไม่มี warning — แค่ค่าที่ผิดอย่างเงียบ ๆ ซึ่งนั่นแหละคือสิ่งที่ทำให้ pattern นี้อันตราย วิธีแก้คือ async function printUser(id) { const user = await getUser(id); ... } เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (หรือ test ที่ assert รูปร่างค่าที่ resolve จริง) ไม่มี lint rule ที่ติดตั้งใน ladder ของคอร์สนี้ตรวจจับ await ที่หายไปใน JavaScript ธรรมดาที่ไม่มี type information ได้ ดู Async/Await
2. forEach(async ...) Array.prototype.forEach ไม่เคย await ค่าที่ callback คืนกลับมาเลย — มันยิงทุก call แล้วเดินหน้าต่อทันที
async function processAll(ids) { ids.forEach(async function (id) { await new Promise(function (r) { setTimeout(r, 10); }); console.log('done', id); }); console.log('all done'); // BUG: prints before any "done" line, not after}
processAll([1, 2, 3]);all donedone 1done 2done 3ผู้เรียก processAll ไม่มีทางรู้ว่างานเสร็จจริงเมื่อไร — console.log('all done') รันก่อนที่ item ไหนจะถูกประมวลผลด้วยซ้ำ วิธีแก้คือ loop for...of ที่มี await ข้างใน (ทีละตัว) หรือ await Promise.all(ids.map(...)) (พร้อมกัน แต่ยังคง await) เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น ไม่มีกฎใน eslint-plugin-n/eslint-plugin-promise ที่ตรวจจับ async callback ที่ส่งให้ forEach ดู Async/Await
3. Floating promise → unhandled rejection promise ที่ reject แล้วไม่มีใครแนบ .catch (หรือ try/await) ให้ จะทำให้โปรเซส crash โดยดีฟอลต์ตั้งแต่ Node 15
// @expect-throwasync function saveRecord(record) { if (!record.id) throw new Error('missing id'); return record;}
function handleSave(record) { saveRecord(record); // BUG: floating promise, its rejection is never caught console.log('save queued');}
handleSave({});save queuedError: missing id at saveRecord (...)วิธีแก้: saveRecord(record).catch(function (err) { console.error('save failed:', err.message); }); เครื่องมือ: กฎ promise/catch-or-return ของ eslint-plugin-promise ตรวจจับ call ที่คืน promise แล้วไม่มี .catch/.then แนบไว้; ตัว crash เองก็เป็นพฤติกรรมดีฟอลต์ของ Node อยู่แล้ว — ไม่ต้องใช้ flag --unhandled-rejections=strict ตั้งแต่ Node 15 ดู Promises
4. Promise.all ที่ไม่จำกัด concurrency Promise.all(items.map(fn)) เริ่มทุก call พร้อมกันหมด — สำหรับ 10 รายการไม่มีปัญหา แต่สำหรับ 10,000 รายการอาจทำให้ socket, memory, หรือ rate limit หมดพร้อมกันหมด
Output (verified on Node 24)
max concurrent requests: 10ทั้งสิบตัวยิงพร้อมกันหมด วิธีแก้คือ worker pool เล็ก ๆ: worker จำนวนคงที่แต่ละตัวดึง item ถัดไปจาก index ที่ใช้ร่วมกัน ทำให้มี call ทำงานพร้อมกันไม่เกิน limit ตัว เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (การทดสอบ load ด้วย autocannon หรือเหตุการณ์จริงใน production เท่านั้นที่จะเปิดเผยมัน ไม่ใช่ lint rule — ดู Verification Tools) ดู Promises
5. setTimeout(fn, 0) ในที่ที่ควรใช้ setImmediate/process.nextTick ทั้งสามตัวจัดตารางงานในเฟสที่ต่างกันพร้อมการรับประกันที่ต่างกัน การถือว่า setTimeout(fn, 0) แปลว่า “รันตัวนี้ถัดไป” เป็นสมมติฐานที่พบบ่อยแต่ผิด
import { readFile } from 'node:fs';
readFile(new URL(import.meta.url), function () { setTimeout(function () { console.log('timeout (setTimeout 0)'); }, 0); setImmediate(function () { console.log('immediate (setImmediate)'); }); // scheduled second, but the check phase always runs before the next timers phase inside an I/O callback});immediate (setImmediate)timeout (setTimeout 0)ภายใน I/O callback setImmediate รับประกัน ว่าจะรันก่อน timer ใด ๆ เสมอ ไม่ว่าจะถูกจัดลำดับไว้อย่างไร — ตรงข้ามกับสิ่งที่โครงสร้างโค้ดบอก เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น นี่เป็นทางเลือกด้าน semantics ของการจัดตารางเวลา ไม่ใช่สิ่งที่ linter ตรวจจับได้ ดู libuv and the Threadpool
Streams & resources
หัวข้อที่มีชื่อว่า “Streams & resources”6. งาน CPU แบบ synchronous ข้างใน request handler server แบบ single-threaded ทำได้ทีละอย่างเท่านั้น — บล็อก synchronous ใน handler เดียวจะบล็อก request อื่นที่กำลังทำงานอยู่ด้วย
function busySleep(ms) { const end = Date.now() + ms; while (Date.now() < end) {}}
const log = [];
function handleHeavy() { log.push('start heavy'); busySleep(30); // BUG: blocks the event loop -- nothing else can run meanwhile log.push('end heavy');}
function handleFast() { log.push('fast');}
setImmediate(handleHeavy);setImmediate(handleFast);setImmediate(function () { console.log(log.join(' -> ')); });start heavy -> end heavy -> fasthandleFast (request ที่สองซึ่งไม่เกี่ยวข้องกันใน server จริง) รันไม่ได้จนกว่า block synchronous ทั้งหมดของ handleHeavy จะเสร็จ ทั้งที่มันถูกจัดคิวไว้ก่อน handleHeavy จะเสร็จด้วยซ้ำ วิธีแก้คือแบ่งงาน CPU-bound เป็นชิ้น ๆ ที่ yield กลับให้ event loop ระหว่างกลาง (await new Promise(r => setImmediate(r))) หรือย้ายงาน CPU จริง ๆ ไปที่ worker_thread ไปเลย เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (วัดด้วย --cpu-prof หรือ monitorEventLoopDelay ซึ่งอยู่ใน Verification Tools) ดู Call Stack
12. Listener leak การลงทะเบียน listener ใหม่ทุกครั้งที่มี request โดยไม่เคยลบออกเลย จะสะสมไปเรื่อย ๆ ไม่มีที่สิ้นสุด
Output (verified on Node 24)
listener count: 11
warning name: MaxListenersExceededWarningเกินขีดจำกัดดีฟอลต์ 10 listener สำหรับ event name เดียวบน emitter ตัวเดียว Node จะส่ง MaxListenersExceededWarning — เป็นแค่คำใบ้ ไม่ใช่ crash จึงพลาดได้ง่ายท่ามกลาง log ที่รกใน production วิธีแก้: ลงทะเบียน listener แค่ครั้งเดียวนอก path ที่รันทุก request หรือเรียก .off()/.removeListener() เมื่อทรัพยากรที่เกี่ยวข้องถูกทำลาย เครื่องมือ: --trace-warnings (แสดงว่า listener ถูกเพิ่มที่ไหนจริง ๆ) หรือดักจับ event warning ตรง ๆ ในเทสต์ ตามตัวอย่างข้างบน ดู EventEmitter
13. event error ที่ไม่มี listener EventEmitter ถือว่า 'error' ที่ไม่ถูกจัดการเป็นเรื่องร้ายแรงโดยการออกแบบ — มันเป็น event name เดียวที่ throw ถ้าไม่มีใครฟังอยู่
// @expect-throwimport { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.emit('error', new Error('boom')); // BUG: no 'error' listener registeredError: boom at ... (Emitted 'error' event at:)วิธีแก้คือลงทะเบียน handler .on('error', ...) เสมอสำหรับ emitter ใด ๆ ที่สามารถผลิต error ได้ — socket, stream, child process, หรือ class ของคุณเอง เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (การรันโค้ดตามตัวอย่างข้างบนคือวิธีที่เร็วที่สุดที่จะเห็นมัน ไม่มี lint rule ตรวจจับ listener error ที่หายไปแบบ static) ดู EventEmitter
14. .pipe() ที่ไม่มีการจัดการ error readable.pipe(writable) ไม่ forward event 'error' ระหว่างสอง stream — error ฝั่งใดฝั่งหนึ่งต้องมี listener ของตัวเอง
// @expect-throwimport { Readable, Writable } from 'node:stream';
const source = new Readable({ read() { this.emit('error', new Error('source failed')); }});
const dest = new Writable({ write(chunk, enc, cb) { cb(); }});
source.pipe(dest); // BUG: pipe() forwards data, not errors -- and neither stream has an 'error' listenerError: source failed at ... (Emitted 'error' event on Readable instance at:)วิธีแก้คือ stream.pipeline(source, dest, function (err) { if (err) console.error('pipeline failed:', err.message); }) จาก node:stream ซึ่งทั้ง forward error และเก็บกวาด (destroy) ทุก stream ใน chain ให้ เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น แม้กฎที่ใกล้เคียงใน eslint-plugin-n ก็ไม่ครอบคลุมเรื่องนี้; บท pipe/pipeline ในโมดูล Streams & I/O ครอบคลุม pipeline() แบบเต็ม ๆ
15. การ decode string ข้าม chunk boundary การ decode แต่ละ chunk ของ stream แยกกันด้วย .toString('utf8') จะทำให้ตัวอักษร multi-byte ที่บังเอิญถูกตัดคาบสอง chunk เสียหาย
Output (verified on Node 24)
ส���ัสดี
matches original: falseตัวอักษร multi-byte ที่ถูกตัดคาบกลายเป็นอักขระ replacement ของ Unicode (�) ทั้งสองฝั่งของรอยตัด และผลลัพธ์ที่ decode กลับมาก็ไม่ตรงกับข้อความต้นฉบับอีกต่อไป วิธีแก้คือ StringDecoder จาก node:string_decoder ซึ่งจะบัฟเฟอร์ลำดับ multi-byte ที่ยังไม่สมบูรณ์ไว้จนกว่า chunk ถัดไปจะทำให้มันสมบูรณ์ เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (สิ่งนี้ผ่าน test ที่สร้างจาก fixture แบบ ASCII ล้วนทุกอันได้อย่างสบาย) ดู Buffer
16. Buffer.concat ข้างใน loop การเรียก Buffer.concat([acc, chunk]) ทุกครั้งที่มี chunk เข้ามาจะคัดลอก buffer ที่สะสมไว้ทั้งหมดใหม่ทุกครั้ง — งานแบบ quadratic ในที่ที่ควรเป็น linear
const chunks = [Buffer.from('ab'), Buffer.from('cd'), Buffer.from('ef')];
let naive = Buffer.alloc(0);for (const chunk of chunks) { naive = Buffer.concat([naive, chunk]); // BUG: re-copies everything accumulated so far, on every chunk}
const collected = [];for (const chunk of chunks) { collected.push(chunk); // FIX: collect references, concat once at the end}const fixed = Buffer.concat(collected);
console.log(naive.toString(), fixed.toString(), naive.equals(fixed));abcdef abcdef trueทั้งสองแบบให้ผลลัพธ์ที่ถูกต้องเหมือนกันทุกประการบน input เล็ก ๆ นี้ — ซึ่งนั่นแหละคือกับดัก: ความถูกต้องดูดีในทุกขนาด และมีแค่ profiler เท่านั้นที่จะแสดงต้นทุนการคัดลอกแบบ quadratic ที่ขยายขึ้นเมื่อจำนวน chunk เพิ่มขึ้น เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น เปิดเผยด้วย --cpu-prof ภายใต้ traffic จริง (ดู Verification Tools) ไม่มีทางที่ functional test จะจับได้ ดู Buffer
HTTP boundaries
หัวข้อที่มีชื่อว่า “HTTP boundaries”7. เรียก res.end() สองครั้ง การเรียกครั้งที่สอง throw ERR_STREAM_WRITE_AFTER_END — และถ้าไม่มีใครฟัง event error ที่มันสร้างขึ้น โปรเซสจะ crash ก่อนที่ client จะได้รับ response ด้วยซ้ำ
// @expect-throwimport http from 'node:http';
const server = http.createServer(function (req, res) { res.end('first'); res.end('second'); // BUG: ERR_STREAM_WRITE_AFTER_END -- unhandled, this crashes the process});
server.listen(0, function () { const port = server.address().port; fetch('http://localhost:' + port);});Error [ERR_STREAM_WRITE_AFTER_END]: write after end at ServerResponse.end (...)Emitted 'error' event on ServerResponse instance at: at emitErrorNt (...)การ crash เกิดขึ้นแบบ synchronous ข้างใน handler ก่อนที่ fetch ของ client จะได้รับ response เลยด้วยซ้ำ — การเรียก res.end('first') ครั้งแรกไม่เคยไปถึง socket จริง ๆ ก่อนที่โปรเซสจะตาย วิธีแก้คือ guard แบบ early-return: if (res.writableEnded) return; ก่อนทุกการเรียก res.end() รวมศูนย์ไว้ใน response helper ตัวเดียว (แบบที่บท Production Server ทำ) เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น; การรัน handler ด้วย test ที่เรียกซ้ำสองครั้งคือวิธีที่เร็วที่สุดที่จะเห็นมัน ดู HTTP Server
8. ตั้ง header หลัง response ถูกส่งไปแล้ว res.setHeader() (และ res.writeHead()) จะ throw ERR_HTTP_HEADERS_SENT เมื่อ header ถูกส่งออกไปทาง wire แล้ว
// @expect-throwimport http from 'node:http';
const server = http.createServer(function (req, res) { res.end('ok'); res.setHeader('X-Late', 'true'); // BUG: headers (and the body) are already sent by res.end()});
server.listen(0, function () { const port = server.address().port; fetch('http://localhost:' + port);});Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the clientวิธีแก้คือเรื่องลำดับ: ตั้งทุก header ก่อน เรียก res.end() หรือ res.write() เสมอ ไม่ใช่หลังจากนั้น เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (หรือเช็ก res.headersSent ก่อนเรียกเพื่อความปลอดภัย) ดู HTTP Server
9. ไม่จำกัดขนาด request body การ buffer chunk ของ req โดยไม่มีเพดานเลย ทำให้ client (หรือผู้โจมตี) เพิ่ม memory ของ server ได้ไม่มีที่สิ้นสุด
Output (verified on Node 24)
status: 413ถ้าไม่มี guard received > MAX_BODY (ตัว bug จริง) server ตัวเดียวกันนี้จะรับ body ขนาดเท่าใดก็ได้ เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น ยืนยันได้ภายใต้ load ด้วย autocannon ที่ส่ง body เกินขนาด (Verification Tools) ดู JSON API
10. JSON.parse โดยไม่มี try/catch input ที่ผิดรูปแบบจะทำให้ handler ทั้งตัว crash ด้วย SyntaxError แทนที่จะคืน 400
// @expect-throwfunction parseConfig(raw) { return JSON.parse(raw); // BUG: no try/catch -- malformed input throws uncaught}
console.log(parseConfig('{ this is not valid json'));SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)วิธีแก้คือครอบการ parse ด้วย try/catch แล้วคืน 400 พร้อมข้อความที่ชัดเจนเมื่อล้มเหลว แบบเดียวกับที่ route ในบท JSON API ทำ เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (หรือ node --test พร้อม test case ที่ใช้ input ผิดรูปแบบ) ดู JSON API
11. fetch โดยไม่เช็ก .ok และไม่มี timeout fetch จะ reject ก็ต่อเมื่อ network ล้มเหลวเท่านั้น — response ที่เป็น 4xx/5xx ยัง resolve ตามปกติ และถ้าไม่มี timeout ชัดเจน server ที่ค้างจะทำให้ผู้เรียกค้างไปตลอดกาล
// @expect-throwimport http from 'node:http';
const server = http.createServer(function (req, res) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'internal error' }));});
server.listen(0, async function () { const port = server.address().port; const res = await fetch('http://localhost:' + port); // BUG: never checks res.ok const body = await res.json(); console.log('user name:', body.name.toUpperCase()); // body.name is undefined on the error payload server.close();});TypeError: Cannot read properties of undefined (reading 'toUpperCase') at Server.<anonymous> (...)response body ของ 500 ({ error: 'internal error' }) ไม่มี field name เลย และโค้ดไม่เคยสังเกตว่า status เป็น error ก่อนที่จะเชื่อรูปร่างของ body วิธีแก้คือเช็ก res.ok (หรือ status เฉพาะ) ก่อนเชื่อรูปร่างของ body และส่ง signal: AbortSignal.timeout(5000) เพื่อไม่ให้ server ที่ค้างทำให้ผู้เรียกค้างไปตลอดกาล เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น ไม่มี lint rule ใดพิสูจน์แบบ static ได้ว่า response ของ fetch ถูกตรวจสอบแล้ว ดู Requests
17. process.env.PORT ถูกเทียบเป็น string ทุกค่าใน process.env เป็น string (หรือไม่มีเลย) — การเทียบสองค่าด้วย </> จะทำการเทียบแบบ lexicographic ไม่ใช่ numeric
Output (verified on Node 24)
port is above the minimum9000 น้อยกว่า 10000 ในทางตัวเลขอย่างชัดเจน แต่ '9000' > '10000' เทียบทีละตัวอักษรและ '9' > '1' เป็น true วิธีแก้คือ Number(process.env.PORT) > Number(process.env.MIN_PORT) เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (หรือกฎแบบ n/no-process-env ของ eslint-plugin-n ที่ตรวจจับการอ่าน process.env เลยแต่ไม่ใช่ bug การเทียบค่าแบบนี้โดยเฉพาะ) ดู process.env
Modules & packages
หัวข้อที่มีชื่อว่า “Modules & packages”19. ปน require/import กัน หรือใช้ __dirname ใน ES module __dirname และ __filename เป็น global เฉพาะของ CommonJS — ES module ไม่มี __dirname เลย
// @expect-throwconsole.log(__dirname); // BUG: __dirname does not exist in ES module scopeReferenceError: __dirname is not defined in ES module scopeวิธีแก้คือ import.meta.dirname (stable ตั้งแต่ Node 21.2 / 20.11) ซึ่งทำงานเดียวกันโดยไม่ต้องใช้ path.dirname(fileURLToPath(import.meta.url)) เลย เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (ข้อความ error เองก็ชัดเจนเมื่อเจอเข้า แต่ไม่มีอะไรจับมันได้ก่อนที่จะรันไฟล์) ดู Modules
20. ช่วงเวอร์ชัน ^0.x, ไม่มี lockfile, npm install ใน Dockerfile สำหรับ package 0.x เครื่องหมาย caret (^) ของ npm อนุญาตแค่ patch bump — แต่การไม่มี lockfile หรือใช้ npm install แทน npm ci ข้างใน Docker build ยังคงทำให้ build resolve เวอร์ชัน transitive ที่ต่างกันได้ในแต่ละวัน
{ "dependencies": { "some-lib": "^0.4.2" }}# BUG: npm install can update the lockfile and resolve fresh versions on every buildRUN npm install --omit=devวิธีแก้คือ commit package-lock.json และใช้ npm ci --omit=dev ใน Dockerfile ซึ่งจะติดตั้ง ตรงตาม ที่ lockfile บอกเป๊ะ ๆ และล้มเหลวอย่างชัดเจนถ้า package.json กับ lockfile ไม่ตรงกัน — deterministic บนทุกเครื่องและทุก build เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น สำหรับบรรทัด Dockerfile; npm audit และความล้มเหลวเมื่อไม่ตรงกันของ npm ci เองจับความคลาดเคลื่อนที่เกี่ยวข้องได้ ดู Dependencies
21. ลำดับ field exports ผิดใน package.json Node จะ resolve เงื่อนไข แรก ที่ match ใน exports map — เงื่อนไขทั่วไปที่อยู่ก่อนเงื่อนไขที่เจาะจงกว่า (อย่าง types) จะบัง (shadow) มันไปเลย
{ "exports": { ".": { "default": "./dist/index.js", "types": "./dist/index.d.ts" } }}"default" ที่อยู่ก่อนแปลว่ามันชนะเสมอ — "types" ข้างล่างเป็น configuration ที่ตายไปแล้ว type checker จะไม่มีวันได้ใช้มันจริง วิธีแก้คือเรียง "types" ไว้ก่อน ตามกฎ “เงื่อนไขที่เจาะจงกว่ามาก่อน”: { "types": "./dist/index.d.ts", "default": "./dist/index.js" } เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น แม้ npm pack --dry-run ร่วมกับการ import tarball ที่ pack แล้วจริง ๆ จากโปรเจกต์ TypeScript จะเปิดเผยมันได้เร็ว ดู package.json
Errors & process
หัวข้อที่มีชื่อว่า “Errors & process”18. process.exit() กลางงานที่ยังค้างอยู่ process.exit() หยุดโปรเซสทันที — มันไม่รอให้ microtask queue ระบายให้หมดก่อน ดังนั้นอะไรที่ยังค้างอยู่จะถูกทิ้งไปเฉย ๆ
Output (verified on Node 24)
before exit
exit handler ran with code 0callback .then ที่ถูก queue ไว้ไม่เคยทำงาน และบรรทัดหลัง process.exit() ก็ไม่เคยรันเช่นกัน — มีแค่ listener 'exit' แบบ synchronous เท่านั้นที่ได้โอกาสรัน วิธีแก้เกือบทุกครั้งคือ ไม่ เรียก process.exit() เลย: ปล่อยให้ main() return แล้วให้ Node จบตามธรรมชาติเมื่อไม่มีอะไรเหลือให้ทำ หรือตั้ง process.exitCode = 0 แล้ว return แทนการ exit ทันที เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (test ที่เช็กว่า side effect ที่คาดไว้ทั้งหมดเกิดขึ้นจริงจะ fail แต่ไม่มี static tool ตัวไหนตรวจจับสิ่งนี้ได้) ดู process.env
23. console.log แทนที่จะเป็น structured logging console.log แบบ free-text หลาย argument อ่านครั้งเดียวใน terminal ได้ดี แต่ไม่มีอะไรที่อยู่ปลายทาง (log aggregator, grep, alert rule) จะ parse มันได้อย่างน่าเชื่อถือ
function logRequest(method, path, status) { console.log('request', method, path, 'returned', status); // BUG: free-text, not machine-parseable}
logRequest('GET', '/health', 200);request GET /health returned 200วิธีแก้ จากบท Production Server คือหนึ่ง JSON object ต่อบรรทัด: console.log(JSON.stringify({ event: 'request', method: method, path: path, status: status })) — ข้อมูลเดิม แต่ตอนนี้ทุก field เป็น key ที่คงที่และ query ได้ เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (หรือ log pipeline ที่เริ่ม parse บรรทัดไม่ได้เมื่อ free-text message ของใครสักคนบังเอิญมี comma หรือ newline อยู่ข้างใน) ดู Debugging
24. child_process.exec/eval/new Function กับ input ที่ไม่น่าเชื่อถือ exec() รัน argument ของมันผ่าน shell จริง — shell metacharacter ใด ๆ (;, &&, `) ใน input ที่ไม่ผ่านการกรองจะรันเป็น command แยกต่างหาก eval/new Function กับ input ที่ไม่น่าเชื่อถือก็เป็นปัญหาเดียวกันในฝั่ง JavaScript ล้วน
Output (verified on Node 24)
start
hi
INJECTED
; ใน “input” จบ command แรกแล้วเริ่ม command ที่สองซึ่งไม่เคยตั้งใจให้รัน — ด้วย input จากผู้ใช้จริง นี่คือการรัน command ตามใจชอบ วิธีแก้คือ execFileSync('echo', ['start', userInput]) — การส่ง argument เป็น array จะรันโปรแกรมโดยตรง ไม่มี shell เข้ามาเกี่ยวข้อง metacharacter ใน userInput จึงเป็นแค่ข้อมูลเฉย ๆ เครื่องมือ: ไม่มี — ต้องใช้คนเท่านั้น (เครื่องมือสแกน dependency อย่าง npm audit หา package ที่มีช่องโหว่ที่รู้จักแล้ว ไม่ใช่ bug จาก injection ในโค้ดของคุณเอง); ดู Security Model
Testing & tooling
หัวข้อที่มีชื่อว่า “Testing & tooling”22. test ที่ไม่มี assertion, mock ที่ผูกกับ reference เก่า, หรือ test ที่ใช้ timer แล้วค้าง ทั้งสามแบบทำให้ test suite รายงานว่าผ่านทั้งที่แทบไม่ได้พิสูจน์อะไรเลย
import { test } from 'node:test';
function add(a, b) { return a - b; // a real, planted bug}
test('adds two numbers', function () { add(2, 3); // BUG: the result is never checked with an assertion});ผลลัพธ์จริง (reporter ดีฟอลต์ของ node:test รันตรง ๆ โดยไม่มี flag --test; ตัวเลขเวลาของแต่ละ test จะต่างกันไปในแต่ละครั้งที่รัน ส่วนที่เหลือไม่เปลี่ยน):
✔ adds two numbers (0.4ms)ℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0test นี้ผ่านทั้งที่ add(2, 3) คืนค่า -1 การเพิ่ม assertion ที่หายไป (assert.equal(add(2, 3), 5)) จะทำให้ suite เดียวกันนี้แดงทันที — bug ใน add มีอยู่ตั้งแต่แรก แค่ไม่เคยถูกตรวจสอบ
รูปแบบที่ซับซ้อนกว่าของธีมเดียวกัน: mock ฟังก์ชัน หลังจาก ส่วนอื่นของโค้ดจับ reference ตรงไปยังต้นฉบับไว้แล้ว
// @expect-throwimport { test, mock } from 'node:test';import assert from 'node:assert/strict';
const mailer = { send: function (to) { console.log('REALLY sending to', to); return true; } };const sendFn = mailer.send; // BUG: captured before the test can replace it
function notify(to) { return sendFn(to); // always calls the original, no matter what gets mocked on mailer later}
test('notify does not really send mail', function () { const sendMock = mock.method(mailer, 'send', function () { return 'mocked'; }); assert.equal(sendMock.mock.calls.length, 1); // fails: the mock was never actually called});REALLY sending to [email protected]✖ notify does not really send mail (1.196083ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 0 !== 1mock.method แทนที่ property send บน mailer แต่ sendFn ยังถือฟังก์ชัน ต้นฉบับ จากก่อนที่ mock จะถูกติดตั้งอยู่ — การเรียก sendFn(to) จึงข้าม mock ไปเลย และ mailer.send ตัวจริงก็รันจริง (เห็นได้ตรง ๆ ใน stdout ที่จับมาได้) วิธีแก้คือเรียกผ่าน object ตรงจุดที่เรียกใช้ (mailer.send(to)) ไม่ใช่ผ่าน reference ที่จับไว้ก่อนหน้า
รูปแบบที่สามของปัญหาเดียวกัน อธิบายไว้แทนที่จะรันจริงตรงนี้เพราะมันจะค้างจริงตามระยะเวลาเต็ม ๆ: test ที่เริ่ม setTimeout(callback, 30000) จริงแล้ว await ผลลัพธ์โดยไม่เคยติดตั้ง mock.timers หรือเลื่อนเวลาด้วยตัวเอง — timeout ต่อ test ดีฟอลต์ของ node --test จะ fail มันในที่สุด แต่ต้องรอจริงก่อน ทำให้ suite ทั้งหมดช้าลงโดยไม่จำเป็น วิธีแก้คือ context.mock.timers.enable({ apis: ['setTimeout'] }) พร้อม context.mock.timers.tick(30000) ที่ชัดเจน ซึ่งอยู่ในบท Verification Tools
เครื่องมือ: node --test จับได้ทั้งสามแบบ แต่ก็ต่อเมื่อมีการเขียน assertion จริง ๆ และ mock ถูกใช้ในตำแหน่งที่ถูกต้อง — เครื่องมือไม่สามารถสร้างการตรวจสอบที่หายไปให้คุณได้ ดู Built-in Test Runner
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุก pattern ข้างบนมีโครงสร้างเดียวกัน: โค้ดที่ ถูกต้องตาม syntax และ ผิดในเชิงพฤติกรรมกับ input บางแบบที่ demo ไม่ได้บังเอิญใช้ ไม่มี pattern ไหนแปลกประหลาดเลย — ทั้งหมดเป็นรูปแบบดีฟอลต์ที่ AI assistant เลือกใช้เมื่อ prompt ไม่ได้ขอเวอร์ชันที่ปลอดภัยกว่าโดยเฉพาะ เพราะเวอร์ชันที่ไม่ปลอดภัยสั้นกว่า พบบ่อยกว่าใน training data และมักจะยัง “ดูถูกต้อง” เมื่ออ่านผ่าน ๆ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| แนวทางต่อแคตตาล็อกนี้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| จำทั้ง 24 ข้อโดยชื่อ | จับคู่ pattern ได้เร็วระหว่าง review | pattern ที่ 25 ที่แคตตาล็อกนี้ไม่ครอบคลุมยังต้องใช้นิสัยการอ่านจากบท index อยู่ดี |
| รันทุก diff ผ่าน tool ladder ก่อน | จับได้แค่ส่วนน้อย (3 จาก 24) แบบเครื่องจักร ฟรี ๆ | ให้ความมั่นใจที่ผิด ๆ ว่า run สะอาดแปลว่า diff สะอาด — ส่วนใหญ่ของแคตตาล็อกนี้คือ “ไม่มี — ต้องใช้คนเท่านั้น” |
| อ่านทุก diff ทีละบรรทัดไม่สนใจผล tool | จับได้ทุกอย่าง รวมถึง pattern ที่ยังไม่มีในแคตตาล็อกไหนเลย | ไม่ scale กับ diff ขนาดใหญ่ ถ้าไม่มีนิสัยการอ่านจากบท index มาช่วยจัดลำดับว่าจะดูตรงไหนก่อน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ถ้ามันเป็น bug จริง เครื่องมือน่าจะจับได้” 21 จาก 24 pattern ในแคตตาล็อกนี้ถูกระบุว่า “ไม่มี — ต้องใช้คนเท่านั้น” ด้วยเหตุผลจริง: ไม่มีเครื่องมือที่ติดตั้งใน ladder ของคอร์สนี้ตรวจสอบพวกมันได้
- “bug ที่ AI สร้างเป็น edge case แปลกประหลาด” ทุก pattern ที่นี่คือ footgun ของ Node.js/JavaScript ที่รู้จักกันดีมานานหลายสิบปี — AI แค่ผลิตมันได้คล่องแคล่วและบ่อยครั้งกว่า ไม่ได้คิดค้นอะไรใหม่
- “crash แย่กว่าค่าที่ผิดแบบเงียบ ๆ” pattern 1 (ลืม
await) ไม่เคย crash และให้ค่าundefinedอย่างเงียบ ๆ — นั่นอาจอันตรายกว่า crash ที่ดังและเห็นชัดของ pattern 3 ด้วยซ้ำ เพราะไม่มีอะไรบังคับให้ใครสักคนสังเกตเห็นมัน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ reviewer เปิด diff ที่ AI เขียนเทียบกับแคตตาล็อกนี้ การผ่านรอบแรกที่เร็วที่สุดคือแบบเครื่องจักร
- grep หา string ที่ pattern ในแคตตาล็อกนี้ยึดอยู่:
forEach(async,.then(ที่ไม่มี.catchคู่กัน,readFileSync,res.end(มากกว่าหนึ่งครั้งต่อฟังก์ชัน,JSON.parse(ที่ไม่มีtryล้อมรอบ,exec(,eval(,new Function(,catch {} - สำหรับทุกจุดที่เจอ หาว่ามันตรงกับ pattern ไหนใน 24 ข้อข้างบน แล้วเช็กว่าวิธีแก้ที่แสดงไว้สำหรับ pattern นั้นมีอยู่แล้วหรือยัง
- สิ่งที่เหลือ — pattern ที่ระบุว่า “ไม่มี — ต้องใช้คนเท่านั้น” ที่ grep ยึดไม่ได้ตรง ๆ (floating promise ที่ไม่ได้อยู่ในรูป
forEach, listener leak ใน class method,Promise.allที่ไม่มีขอบเขตให้เห็น) — ต้องการการอ่านจริงด้วยนิสัยสี่ข้อจากบท index
🐛 หา bug ในโค้ดนี้
import http from 'node:http';
const server = http.createServer(function (req, res) {let body = '';req.on('data', function (chunk) { body += chunk; }); // BUG 1: no size limit (pattern 9)req.on('end', async function () { const data = JSON.parse(body); // BUG 2: no try/catch (pattern 10) const saved = await save(data); // BUG 3: floating promise if save() ever rejects before this line is reached res.end(JSON.stringify(saved)); res.end(); // BUG 4: res.end() called twice (pattern 7)});});
async function save(data) {if (!data.name) throw new Error('name is required');return data;}เฉลย
สี่ pattern จากแคตตาล็อกนี้ซ้อนกันอยู่ในสิบเอ็ดบรรทัด: ไม่มีการจำกัดขนาด body (9), JSON.parse ที่ไม่มี guard (10), และ res.end() สองครั้ง (7) ซึ่งจะ crash โปรเซสทันทีที่ save() สำเร็จ เพราะการเรียกครั้งที่สองจะ throw ERR_STREAM_WRITE_AFTER_END เสมอโดยไม่มีใครฟังมัน และถ้า data.name หายไป save() จะ reject ข้างใน event handler แบบ async โดยไม่มีอะไรจับมันเลย — unhandled rejection (3) ที่ crash โปรเซสก่อนที่ bug res.end() สองครั้งจะมีโอกาสได้ทำงานด้วยซ้ำ การอ่านไฟล์นี้ด้วยนิสัยจากบท index (ตามขอบเขตข้อมูล, ตามอายุของ res, นับว่าถ้ามี catch block จะมีกี่จุด) จะหาทั้งสี่ข้อได้ก่อนที่จะส่ง request สักครั้งเดียว