Worked review 4: a library's tsconfig and package.json
สามรีวิวแรกเป็นโปรแกรม รีวิวนี้เป็น config — tsconfig.json กับ package.json ของ library เล็ก ๆ ชื่อ wr4-lib ที่ export class Logger ตัวเดียว bug ของ config ไม่โผล่มาเป็น console output ที่ผิด มันโผล่มาเป็น build ที่ปฏิเสธจะรัน หรือ consumer ที่ require() แล้วพังด้วยเหตุผลที่ไม่มีอะไรใน source code อธิบายได้ ทุกความล้มเหลวด้านล่าง quote มาจากการรันจริงใน scratch environment ของคอร์สนี้ — tsc -p, npm pack + @arethetypeswrong/cli, และ consumer จริงที่เรียกทั้ง require() กับ import()
Source, tsconfig.json, และ package.json
หัวข้อที่มีชื่อว่า “Source, tsconfig.json, และ package.json”export enum LogLevel { Info, Warn, Error }
export class Logger { constructor(public readonly level: LogLevel) {} log(message: string): void { console.log(`[${LogLevel[this.level]}] ${message}`); }}{ "compilerOptions": { "target": "esnext", "module": "esnext", "moduleResolution": "bundler", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"]}{ "name": "wr4-lib", "version": "1.0.0", "type": "module", "main": "./src/index.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }}ลองหา bug ก่อนอ่านต่อ
หัวข้อที่มีชื่อว่า “ลองหา bug ก่อนอ่านต่อ”ไม่มีไฟล์ไหนในสามไฟล์นี้ที่ดูผิดชัดเจนเมื่อดูแยกกัน — นี่คือความรู้สึกจริงของการรีวิว config ลองบอกให้ครบทั้งสี่ bug ก่อนเปิดเฉลย
Bug 1 — tsconfig.json ไม่มี rootDir
Symptom: tsc -p tsconfig.json ปฏิเสธที่จะ build เลย
Real output:
tsconfig.json(7,5): error TS5011: The common source directory of 'tsconfig.json' is'./src'. The 'rootDir' setting must be explicitly set to this or another path toadjust your output's file layout. Visit https://aka.ms/ts6 for migration information.Evidence: เมื่อตั้ง outDir แต่ไม่มี rootDir tsc ตัดสินใจไม่ได้ว่าจะ mirror src/index.ts เข้า dist/ ยังไงโดยไม่เดา — และ TypeScript 7 เปลี่ยนการเดานั้นให้เป็น hard error แทนที่จะเดาแบบเงียบ ๆ ออกมาเป็น layout dist/src/index.js แบบที่ version เก่าเคยทำ
Fix: ตั้ง rootDir ตรง ๆ:
{ "compilerOptions": { "target": "esnext", "module": "esnext", "moduleResolution": "bundler", "rootDir": "src", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"]}Bug 2 — "main": "./src/index.ts" ชี้ไปที่ source ดิบ
Symptom: field main ของ package — entry point แบบดั้งเดิมที่เครื่องมือทุกตัวก่อนยุค exports ยังอ่านอยู่ — ชี้ตรงไปที่ TypeScript source ไม่ใช่ไฟล์ที่ build แล้ว
Evidence: Node สมัยใหม่เพิกเฉย main ทั้งหมดทันทีที่มี exports เลย package นี้เจาะจงยัง resolve ถูกต้องสำหรับ import/require วันนี้ — แต่เครื่องมือไหนก็ตามที่ resolve แบบดั้งเดิม (bundler เก่า, task runner ของ monorepo ที่อ่าน main ตรง ๆ, moduleResolution: "node10" ของ TypeScript เองสำหรับโปรเจกต์ consumer) จะได้รับไฟล์ .ts ที่ JavaScript runtime ไหนก็รันตรง ๆ ไม่ได้ ยืนยันตรง ๆ: การรันไฟล์นั้นเป๊ะด้วย TypeScript support ของ Node เองพังทันที (ดู Bug 4) — พอดีกับความล้มเหลวที่ field main ที่ชี้ผิดแบบนี้ส่งให้เครื่องมือที่ผิดได้
Fix: ชี้ main ไปที่ build output จริง:
"main": "./dist/index.js"Bug 3 — exports ไม่มี require condition แต่ main บอกเป็นนัยว่ามี
Symptom: field main ในประวัติศาสตร์แปลว่า “นี่คือวิธีที่ CommonJS consumer โหลด package นี้” — แต่ exports map ของ package นี้ไม่มี require condition เลย มีแค่ import
Real output (consumer package จริงใน scratch เรียกทั้งสองแบบ):
$ node -e "require('wr4-lib')"Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in.../node_modules/wr4-lib/package.json
$ node --input-type=module -e "import('wr4-lib').then(m => new m.Logger(m.LogLevel.Warn).log('hi'))"[Warn] hirequire() พังตรง ๆ; dynamic import() ทำงานได้ @arethetypeswrong/cli --pack ยืนยันช่องว่างเดียวกันจาก shape ที่ publish จริงของ package:
⚠️ A require call resolved to an ESM JavaScript file, which is an error in Node andsome bundlers. CommonJS consumers will need to use a dynamic import.
┌───────────────────┬──────────────────────────────┐│ node10 │ 🟢 │├───────────────────┼──────────────────────────────┤│ node16 (from CJS) │ ⚠️ ESM (dynamic import only) │├───────────────────┼──────────────────────────────┤│ node16 (from ESM) │ 🟢 (ESM) │├───────────────────┼──────────────────────────────┤│ bundler │ 🟢 │└───────────────────┴──────────────────────────────┘Fix: package นี้เป็น ESM-only จริง ๆ Fix ไม่ใช่การปลอมว่ารองรับ CJS ทั้งที่ไม่มี build สำหรับมัน — นั่นต้องมี build target ที่สองจริง (dist/index.cjs บวก .d.cts declaration อ้างจาก "require" condition) Fix ที่ถูกต้องจริง ๆ ตรงนี้คือหยุดบอกเป็นนัยว่ารองรับ CJS ที่ package ไม่เคยมี: พอ main ชี้ไปที่ dist/index.js ถูกต้องแล้ว (fix ของ Bug 2) package นี้อย่างน้อยก็ สอดคล้องกัน ในการเป็น ESM-only แล้ว warning นี้กลายเป็น trade-off ที่มี document ไว้และเข้าใจแล้ว แทนที่จะเป็นผลข้างเคียงจาก field ที่ชี้ผิด เขียนไว้ใน README แทนที่จะปล่อยให้ consumer มาเจอเองตอน production crash ถ้าต้องการ dual CJS/ESM จริง ๆ วิธีแก้มาตรฐานคือการแยก .d.mts/.d.cts แบบที่บท Build & Emit ของคอร์สนี้ครอบคลุมไว้
Bug 4 — source ใช้ numeric enum กับ parameter property
Symptom: ทั้งคู่ compile ผ่านสมบูรณ์ใต้ tsc --strict — นี่คือช่องว่าง erasable-syntax เดียวกับที่ The AI Bug Catalog ของคอร์สนี้ครอบคลุมไว้ (pattern 20 กับ 21)
Real output (node --experimental-strip-types src/index.ts รันตรงบน source — พอดีกับสิ่งที่ field main ที่ชี้ผิดของ Bug 2 ส่งให้เครื่องมือได้):
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeFix: union แบบ as const แทน enum และ field ตรง ๆ แทน parameter property — ทั้งคู่ erasable ไม่ต้องการ emit อะไรนอกจาก object/class syntax ธรรมดา:
export const LogLevel = { Info: "Info", Warn: "Warn", Error: "Error" } as const;export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
export class Logger { readonly level: LogLevel; constructor(level: LogLevel) { this.level = level; } log(message: string): void { console.log(`[${this.level}] ${message}`); }}Config ที่แก้แล้ว
หัวข้อที่มีชื่อว่า “Config ที่แก้แล้ว”export const LogLevel = { Info: "Info", Warn: "Warn", Error: "Error" } as const;export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
export class Logger { readonly level: LogLevel; constructor(level: LogLevel) { this.level = level; } log(message: string): void { console.log(`[${this.level}] ${message}`); }}{ "compilerOptions": { "target": "esnext", "module": "esnext", "moduleResolution": "bundler", "rootDir": "src", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"]}{ "name": "wr4-lib", "version": "1.0.0", "type": "module", "types": "./dist/index.d.ts", "main": "./dist/index.js", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }}พิสูจน์ real output ตามลำดับ:
$ tsc -p tsconfig.json(no output -- exit 0)
$ node --experimental-strip-types src/index.ts(no output -- parses and runs cleanly, no enum/parameter-property syntax left to reject)
$ node -e "import('wr4-lib').then(m => new m.Logger(m.LogLevel.Warn).log('all good'))"[Warn] all goodtsc -p ตอนนี้ build ได้โดยไม่ขาด flag ไหนเลย; source เองเป็น syntax แบบ erasable ธรรมดาที่รันเหมือนกันไม่ว่าจะผ่าน tsc, strip-types ของ Node, หรือ transform อื่นไหน; และ package resolve กับรันตรงตามที่ exports map สัญญาไว้ทุกอย่าง warning node16 (from CJS) จาก attw ไม่เปลี่ยน — เพราะ package นี้ไม่รองรับ require() จริง ๆ โดยตั้งใจ — แต่มันไม่ถูกขัดแย้งด้วย field main ที่บอกเป็นนัยตรงข้ามอีกต่อไป
เครื่องมือจับอะไรได้บ้าง
หัวข้อที่มีชื่อว่า “เครื่องมือจับอะไรได้บ้าง”| Bug | เครื่องมือ | สัญญาณจริง |
|---|---|---|
1. rootDir หายไป | tsc -p โดยตรง | TS5011 ปฏิเสธ build เลย |
2. main ชี้ไปที่ source | ไม่มีเครื่องมือไหนอัตโนมัติ | โผล่มาก็ต่อเมื่อเครื่องมืออื่นพยายาม resolve ผ่าน main จริง ๆ |
3. ไม่มี require condition | @arethetypeswrong/cli โดยตรง; ยืนยันด้วยการเรียก require() จริง | warning CJSResolvesToESM; ERR_PACKAGE_PATH_NOT_EXPORTED |
4. enum / parameter property | ไม่มีผ่าน tsc --strict; รันไฟล์จริงเท่านั้น | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX ใต้ node --experimental-strip-types |
มีแค่หนึ่งในสี่ตัวนี้ที่เป็น tsc error ที่ build เพิกเฉยไม่ได้เลย อีกสามตัวเป็นสิ่งที่โมดูลนี้ฝึกให้เช็คด้วยมือ — หรือด้วย attw ซึ่งมีอยู่เพราะไม่มีอะไรในบันไดตัวอื่นมองจากมุมของ consumer เลย