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

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()

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"
}
}
}

ไม่มีไฟล์ไหนในสามไฟล์นี้ที่ดูผิดชัดเจนเมื่อดูแยกกัน — นี่คือความรู้สึกจริงของการรีวิว 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 to
adjust 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] hi

require() พังตรง ๆ; dynamic import() ทำงานได้ @arethetypeswrong/cli --pack ยืนยันช่องว่างเดียวกันจาก shape ที่ publish จริงของ package:

⚠️ A require call resolved to an ESM JavaScript file, which is an error in Node and
some 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 mode

Fix: 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}`);
}
}
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 good

tsc -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 เลย

tsc -p tsconfig.json พังทันทีด้วย TS5011 บน config เดิม อะไรหายไปจริง ๆ แล้วทำไม TypeScript 7 ถึงมองมันเป็น hard error แทนที่จะเดา?
exports map ของ package มีแค่ condition 'import' และ require('wr4-lib') พังด้วย ERR_PACKAGE_PATH_NOT_EXPORTED ทำไมการมี field 'main' ที่ชี้ไปที่ไฟล์จริงถึงไม่ช่วย require() ให้รอด?
ทั้ง enum และ parameter property ใน source เดิม compile ผ่านโดยไม่มี tsc --strict error เลย แต่ node --experimental-strip-types src/index.ts พังทันที มันพิสูจน์อะไรเกี่ยวกับความสัมพันธ์ระหว่าง 'compile ผ่าน' กับ 'รันได้' สำหรับ syntax แบบนี้?
หลังแก้แล้ว attw --pack ยังแสดง warning 'node16 (from CJS): ESM (dynamic import only)' เหมือนเดิม ทำไมตอนนี้ถึงถือว่าเป็นสถานะที่ยอมรับได้และมี document ไว้ แทนที่จะเป็น bug ที่ยังไม่แก้?