Modules & Resolution
module system สองแบบ ยังอยู่
หัวข้อที่มีชื่อว่า “module system สองแบบ ยังอยู่”JavaScript มี module format สองแบบ และ TypeScript ต้องรองรับทั้งคู่:
- CommonJS (CJS) — format เก่าของ Node:
require()และmodule.exportsโหลดแบบ synchronous ด้วยการ resolve ไฟล์บน disk - ES Modules (ESM) — มาตรฐาน:
importและexportใช้ native ใน browser และ Node สมัยใหม่ วิเคราะห์แบบ static ได้
ความสับสนส่วนใหญ่ในโปรเจกต์ TypeScript ย้อนกลับไปที่ความไม่ตรงกันระหว่าง module format ที่คุณ เขียน, format ที่ tsc emit และ format ที่ runtime คาดหวัง option module และ moduleResolution คือวิธีที่คุณทำให้ทั้งสามอย่างนี้ตรงกัน ตั้งแต่ 6.0 default ของ module คือ esnext — ESM คือสมมติฐานตั้งต้น ส่วน CJS คือสิ่งที่คุณต้อง opt in
resolution ทำงานอย่างไร
หัวข้อที่มีชื่อว่า “resolution ทำงานอย่างไร”เมื่อคุณเขียน import { x } from "./util" tsc ต้องแปลง "./util" ให้เป็นไฟล์จริง กลยุทธ์ที่ใช้ขึ้นกับ moduleResolution และใน TypeScript 7 เหลืออยู่แค่สองตระกูลเท่านั้น:
flowchart TB q["How is your code loaded?"] --> node["Runs in Node directly"] q --> bundled["Goes through a bundler"] node --> nodenext["module: nodenext (or node18 / node20) respects package.json type + exports"] bundled --> bundler["moduleResolution: bundler lets the bundler resolve"]
nodenext— จำลอง Node ปัจจุบันแบบเป๊ะ: อ่าน"type": "module"ในpackage.json, เคารพ field"exports"และบังคับกฎ ESM (รวมถึงต้องเขียน file extension ใน relative import:import "./util.js") การตั้งmodule: nodenextimplies resolution ที่ตรงกันให้เลย ส่วนnode16คือไอเดียเดียวกันที่แช่แข็งไว้ที่ Node 16node18(5.8) /node20(5.9) — snapshot แบบ stable ของnodenextสำหรับทีมที่ pin Node ไว้สายใดสายหนึ่งnodenextขยับตาม Node ส่วนnode20จะไม่เปลี่ยนใต้เท้าคุณmoduleไม่เคยตั้งtargetให้เอง —tsc --showConfig --module node20ไม่มี keytargetโผล่มาเลย และ code ที่ใช้ ES2025 API (พร้อมlibที่ตั้งไว้ให้เข้ากัน) compile ผ่านสบายภายใต้module: node20ที่ target default ของ compiler เลือกtargetแยกต่างหาก ตาม runtime ของคุณรองรับจริง ๆ ความต่างที่สำคัญที่สุดระหว่างnode18กับnode20:node18ยังยอมรับ syntaxassertของ import ที่ deprecated แล้ว และห้ามrequire()ของ ESM;node20กับnodenextกลับกันbundler— สำหรับ code ที่ผ่าน Vite, esbuild, Rolldown หรือ webpack โหมดนี้ผ่อนเรื่อง extension และปล่อยให้ bundler จัดการการหาไฟล์จริง ให้ตรงกับพฤติกรรมของเครื่องมือเหล่านั้น ตั้งแต่ 6.0 จับคู่กับmodule: commonjsได้ด้วย นอกจากesnext/preserveซึ่งเปิดทางให้โปรเจกต์ CJS migrate ได้
algorithm node / node10 แบบเก่าและ classic หายไปแล้ว ใน TypeScript 7 (deprecated ใน 6.0) ทั้งคู่เกิดก่อน "exports" และ "type" ของ package จึงอธิบาย Node ที่ไม่มีอยู่แล้ว
mental model สำคัญ: moduleResolution ควรอธิบายว่า code ของคุณถูก โหลดจริง อย่างไร Node รันโดยตรงใช่ไหม? nodenext ผ่าน bundler ใช่ไหม? bundler
เรื่องเซอร์ไพรส์ของ extension
หัวข้อที่มีชื่อว่า “เรื่องเซอร์ไพรส์ของ extension”ภายใต้ nodenext native ESM ต้องมี extension ใน import และ — น่าสับสน — คุณเขียน extension .js แม้ไฟล์บน disk จะเป็น .ts:
// @skip-verify illustrative — "./util(.js)" is not a real module// util.ts exists on disk, but the emitted import must point at the emitted .jsimport { helper } from "./util.js"; // ✅ correct under nodenext ESMimport { helper } from "./util"; // ❌ ESM needs the extensionเรื่องนี้ทำทุกคนสะดุดครั้งหนึ่ง เหตุผลคือ tsc ไม่ rewrite import path ของคุณ และตอน runtime ไฟล์จะเป็น util.js ดังนั้น source ต้องเรียกชื่อไฟล์ตอน runtime ไว้ตั้งแต่แรก
ตั้งแต่ 5.7 มีทางออก เปิด rewriteRelativeImportExtensions: true แล้วคุณเขียน extension จริงได้ และ tsc จะ rewrite ให้ตอน emit — เฉพาะ path แบบ relative ที่ลงท้ายด้วย extension ของ TypeScript เท่านั้น:
// @skip-verify illustrative — none of these modules exist on diskimport { helper } from "./util.ts"; // emitted as "./util.js"import { x } from "../lib/thing.mts"; // emitted as "../lib/thing.mjs"import pkg from "some-package/file.ts"; // not relative — left aloneนี่คือสิ่งที่ทำให้ “รัน .ts ตรง ๆ ใน Node ตอน develop แล้ว emit .js สำหรับ production” ทำได้โดยไม่ต้องมี import สองชุด
require() ของ ESM และ import แบบ #/
หัวข้อที่มีชื่อว่า “require() ของ ESM และ import แบบ #/”feature ของ Node สองอย่างที่ nodenext จำลองไว้:
require("./esm-module.js")จาก CommonJS ใช้ได้ใน Node 22+ (ตราบใดที่ไฟล์ ESM ไม่มี top-levelawait) TypeScript 5.8+ เข้าใจเรื่องนี้ภายใต้nodenextlibrary จึง ship แค่ ESM แล้วให้ผู้เรียกฝั่ง CJS ใช้ได้ — ปัญหา dual-package เล็กลงกว่าเดิมมาก- subpath import ที่ขึ้นต้นด้วย
#/— Node ให้package.jsonmap"imports": { "#/*": "./dist/*" }ได้ และ 6.0 resolveimport { x } from "#/util.js"ภายใต้nodenextและbundlerนี่คือของแทนอย่างเป็นทางการของทริคbaseUrlโดย scope อยู่ใน package ของคุณเองและ Node เข้าใจด้วยตัวมันเอง
import attribute, JSON และ import defer
หัวข้อที่มีชื่อว่า “import attribute, JSON และ import defer”keyword asserts ของ import assertion ถูก deprecate แล้ว มาตรฐานคือ with และภายใต้ nodenext การ import JSON ต้อง มีมันและ ต้อง เป็น default import:
// @skip-verify illustrative — "./config.json" is not a real moduleimport config from "./config.json" with { type: "json" }; // ✅import { port } from "./config.json" with { type: "json" }; // ❌ no named exports from JSONimport config from "./config.json" assert { type: "json" }; // ❌ deprecated syntaxTypeScript 5.9 ยัง type import defer ซึ่งเป็น ECMAScript proposal สำหรับการ evaluate module แบบ lazy module ถูก link แต่ไม่ถูก รัน จนกว่าคุณจะแตะ export ครั้งแรก — มีประโยชน์กับ initialisation ที่แพงและใช้แค่บางครั้ง:
// @skip-verify illustrative — "./heavy-feature.js" is not a real moduleimport defer * as heavy from "./heavy-feature.js";
declare const flag: boolean;if (flag) { heavy.run(); // the module body executes here, not at startup}อนุญาตแค่ namespace import และ tsc ไม่ transform มัน จึงต้องใช้ module: esnext หรือ preserve และ runtime ที่รองรับ
esModuleInterop เป็นเรื่องในอดีตแล้ว
หัวข้อที่มีชื่อว่า “esModuleInterop เป็นเรื่องในอดีตแล้ว”CJS กับ ESM มีความเห็นไม่ตรงกันเรื่อง default export ซึ่งในอดีตทำให้ import express from "express" fail กับ module ที่เป็น CommonJS เว้นแต่ esModuleInterop จะแทรก shim เล็ก ๆ ให้ หลายปีที่ทุก tutorial เริ่มด้วย “เปิด esModuleInterop”
คุณจะยังเห็นบรรทัดนั้นใน config เก่า แต่มันไม่ทำอะไรแล้ว: esModuleInterop และ allowSyntheticDefaultImports เปิดตลอดเวลาใน TypeScript 6/7 และปิดไม่ได้ import express from "express" ใช้ได้เลย step migration เดียวที่เหลือคือทางกลับ — ถ้า codebase มี import * as express from "express" จากยุคก่อน interop ให้เปลี่ยนเป็น default import
ไฟล์หนึ่งไฟล์ตัดสินใจว่าเป็น module format ไหนได้อย่างไร
หัวข้อที่มีชื่อว่า “ไฟล์หนึ่งไฟล์ตัดสินใจว่าเป็น module format ไหนได้อย่างไร”nodenext/node18/node20 ไม่ได้เช็ค flag บน compiler — มันถามสามอย่างเรียงกัน: ไฟล์ลงท้ายด้วย .mts/.cts ไหม (เป็น ESM/CJS เสมอไม่ว่าอะไรจะเกิดขึ้น)? ถ้าไม่ใช่ package.json ที่ใกล้ที่สุดบอกว่า "type": "module" ไหม? ถ้าไม่ใช่อีก .ts ธรรมดาจะ default เป็น CommonJS ความผิดพลาดข้ามฟอร์แมตที่หน้าตาคล้ายกันสองแบบกลับถูกจับคนละแบบเลย ควรแยกให้ออก:
import.meta ที่ใช้ในไฟล์ที่ nodenext ตัดสินว่าเป็น CommonJS คือ syntax error จริง ไม่ขึ้นกับอะไรที่ติดตั้งไว้เลย:
// @skip-verify only reproduces on a real .cts file under --module nodenext -- see prose for the exact command and outputconsole.log(import.meta.url);error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output (ตรวจใน scratch จริงบนไฟล์ .cts จริง ภายใต้ --module nodenext ทั้งมี @types/node ติดตั้งและไม่มี — ผลเหมือนกันทั้งสองแบบ) นี่คือ checker บังคับว่าไฟล์ที่ผูกกับ CommonJS แล้วใช้ syntax เฉพาะ ESM ไม่ได้ จบ
require() ที่ใช้ในไฟล์ ESM ดูเหมือนภาพสะท้อนกัน แต่ไม่ใช่: มันคือการ lookup ชื่อที่ไม่ได้ประกาศธรรมดา ๆ ไม่ใช่ syntax restriction และมัน error หรือไม่ขึ้นอยู่กับว่ามีอะไรใน scope ประกาศ global require ไว้หรือเปล่า — ซึ่งโปรเจกต์จริงส่วนใหญ่มี ผ่าน @types/node
checker นี้ไม่โหลด @types/node เลย — กด Check types
// this checker loads no @types/node -- that absence is exactly the point, see proseconst data = require("node:fs");console.log(data); กด Check types: TS2591: Cannot find name 'require'. Do you need to install type definitions for node? — ใน checker นี้ (ไม่โหลด Node types เลยโดยตั้งใจ — เป็นเหตุผลที่ snippet นี้ถูก skip โดย CLI check ที่อิง tsc ของคอร์สนี้เอง ซึ่งโหลด --types node เสมอ) นั่นคือ fix จริง แต่รัน code เดียวกันเป๊ะผ่าน tsc --strict ในโปรเจกต์จริงที่ตั้ง "types": ["node"] ไว้ แล้วมัน compile ผ่าน ไม่มี error เลย (ตรวจใน scratch แล้ว): global declaration ของ @types/node เพิ่ม require เป็น ambient global แบบไม่มีเงื่อนไข โดยไม่รู้เลยว่าไฟล์ปัจจุบันเป็น module format ไหน type checker พอใจ แต่ Node ไม่พอใจ — รันไฟล์นั้นตรง ๆ แล้ว throw ReferenceError: require is not defined in ES module scope, you can use import instead (ตรวจใน scratch แล้ว) นี่คือช่องว่างจริง แม้จะแคบ ของสิ่งที่ strict จับได้: โปรเจกต์ที่มี @types/node ติดตั้งได้ warning ศูนย์สำหรับความผิดพลาดที่พังทุกครั้งที่รันจริง มีแค่ typed lint rule ที่มองหา “global ผิดในไฟล์ผิด” — หรือ runtime test — เท่านั้นที่ปิดช่องว่างนี้ได้
field exports: ลำดับ condition, .d.mts/.d.cts, และ dual-package hazard
หัวข้อที่มีชื่อว่า “field exports: ลำดับ condition, .d.mts/.d.cts, และ dual-package hazard”field "exports" ใน package.json ของ package ชี้ ESM กับ CJS consumer ไปที่ไฟล์ต่างกัน ได้ แต่ละไฟล์มี type declaration ของตัวเอง ผ่าน condition:
{ "name": "mylib", "type": "module", "exports": { ".": { "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } } }}.d.mts และ .d.cts คือ declaration file สำหรับ output .mjs/.cjs โดยเฉพาะ — มันให้ entry ESM กับ entry CJS ของ package มี type ที่ต่างกันจริง ๆ ได้ (หายาก แต่เป็นไปได้เมื่อ shape ของสอง build ต่างกันจริง) ตรงกับ resolver แบบ nodenext/node16 ที่เลือกระหว่าง .mjs/.cjs จาก condition เดียวกันด้วย
คำแนะนำที่ทุก guide พูดซ้ำคือ “ใส่ "types" ไว้ก่อนในแต่ละ condition object” ทดสอบกับ TypeScript 7.0.2 ตรง ๆ แล้วพบว่าคำแนะนำนี้เป็นเรื่อง defensive มากกว่าจำเป็นจริงสำหรับ tsc เอง: ย้าย "types" ไปไว้ท้ายสุดของ condition import แล้วชี้ไปที่ไฟล์ที่มีชื่อ export ผิดโดยตั้งใจ ก็ยัง resolve ถูกต้อง — tsc หา key "types" ด้วยชื่อ ไม่ใช่ตำแหน่ง และจะพังก็ต่อเมื่อไม่มี declaration file ที่ match ให้ fallback เลยจริง ๆ (ตรวจใน scratch แล้ว: ตราบใดที่ .d.mts ข้าง ๆ ยังอยู่ tsc หาเจอทั้งสองแบบ) คำแนะนำให้ขึ้นต้นด้วย "types" ยังคุ้มค่าสำหรับ consumer ที่ระมัดระวังน้อยกว่าของ exports field เดียวกัน — bundler หรือเครื่องมือเก่าที่ iterate condition ตามตำแหน่งแทนที่จะหา "types" ด้วยชื่อ — เลยยังเป็น default ที่ถูกต้องที่จะเขียน แค่ไม่ใช่เพราะ tsc 7.0.2 ต้องการมัน
dual-package hazard คือผลลัพธ์จริงตอน runtime ของการแยก import/require เป็นไฟล์คนละไฟล์: สอง entry เป็นสอง module ที่ประเมินอิสระต่อกันในสายตาของ JavaScript engine ดังนั้นอะไรก็ตามที่มี identity — class, Symbol, Map ที่ใช้เป็น registry — ไม่ใช่ของเดียวกันเมื่อเข้าถึงผ่านสอง path นี้ Node process จริงพิสูจน์เรื่องนี้ (ตรวจใน scratch แล้ว — entry point แบบ ESM ที่ import package ทั้งสองแบบ):
EsmCounter === CjsCounter: falseesmInstance instanceof CjsCounter: falseesmInstance instanceof EsmCounter: trueEsmCounter กับ CjsCounter มาจากสิ่งที่ดูเหมือน “package เดียว” แต่จริง ๆ เป็นสองไฟล์ที่ถูก evaluate แยกกัน แต่ละไฟล์มี class identity ของตัวเอง — instance ที่สร้างผ่าน entry หนึ่ง instanceof กับอีก entry แล้ว fail นี่คือเหตุผลเป๊ะที่ resolution ของ tsc เอง และเครื่องมืออย่าง @arethetypeswrong มองการแยก CJS/ESM ของ package ว่าเป็นสิ่งที่ต้อง verify ไม่ใช่สมมติเอาเอง: node16/nodenext resolve specifier เดียวกันไปที่ declaration .d.mts หรือ .d.cts ขึ้นกับว่า module format ของไฟล์ที่ import เลือก condition ไหน ในขณะที่ bundler mode (ที่ Vite/webpack/esbuild ใช้) ไม่สน condition require/import เลย resolve ผ่าน default/types อย่างเดียว — package ที่พึ่งพาว่า CJS/ESM split จะทำงานต่างกันจึงมองไม่เห็นได้เลยในโปรเจกต์ที่ใช้ bundler mode เพราะมันไม่เคย evaluate ทั้งสองไฟล์พร้อมกันแบบที่ Node process ที่ใช้ทั้งคู่จริง ๆ ทำได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุก resolution rule ในบทนี้ตอบคำถามเดียวที่ tsc ต้องตกลงให้ได้ก่อนจะ check import สักตัว: ไฟล์นี้เป็น ESM หรือ CJS และเป้าหมายที่มัน import เห็นด้วยไหม? .mts/.cts ตกลงเรื่องนี้ด้วยชื่อไฟล์โดยไม่สนอะไรอื่น; .ts/.js ธรรมดา fallback ไปที่ "type" ของ package.json ที่ใกล้ที่สุด พอตกลงเรื่องนั้นแล้ว condition ของ exports ให้packageตอบคำถามเดียวกันต่างกันได้ต่อ consumer — ซึ่งเป็น hazard พอดี: consumer สองรายถาม “นี่เป็น ESM หรือ CJS” อาจได้ไฟล์คนละไฟล์กลับมา และถ้า identity ของ package นั้น (class, registry ที่ share กัน) สำคัญ สองไฟล์นั้นก็ใช้แทนกันไม่ได้ ไม่ว่า public API บนกระดาษจะดูเหมือนกันแค่ไหน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
nodenext/node18/node20 | ตรงกับสิ่งที่ Node ทำจริง — type ใน package.json, exports, กฎ extension ถูกบังคับทั้งหมด | ต้องมี extension .js ชัดเจนใน relative import (หรือ rewriteRelativeImportExtensions) |
bundler | ไม่ต้องมี extension; ตรงกับวิธีที่ Vite/webpack/esbuild resolve | ไม่บังคับ condition require/import แบบที่ Node process จริงที่ใช้ทั้งคู่จะทำ — dual-package identity bug อาจมองไม่เห็นตอน dev แล้วโผล่แค่กับ consumer แบบ Node-direct |
แยก package เป็น ESM build (.mjs) กับ CJS build (.cjs) แยกกัน | รองรับ consumer ทั้งสองแบบโดยธรรมชาติ ไม่ต้องมี interop shim | dual-package hazard จริง: แต่ละ build คือ module ที่ evaluate แยกกัน identity (instanceof, Map/Set registry ที่ share กัน) พังข้ามสองอันนี้ |
ใส่ "types" ไว้ก่อนใน exports condition | ป้องกัน consumer/bundler ที่ระมัดระวังน้อยกว่าที่ iterate condition ตามตำแหน่ง | ตรวจแล้วว่าไม่จำเป็นสำหรับ resolution ของ tsc 7.0.2 เอง ซึ่งหา "types" ด้วยชื่อไม่ว่าตำแหน่งไหน — ยังคุ้มเขียนเป็น default ที่ปลอดภัยกว่าสำหรับ ecosystem ที่กว้างกว่า |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”requireในไฟล์ ESM กับimport.metaในไฟล์ CJS ถูกจับแบบเดียวกัน ไม่ใช่import.metaใน CJS คือ syntax restriction จริงที่ (TS1470) ที่ error ไม่ว่าจะติดตั้งอะไรไว้requireใน ESM คือการ lookup ชื่อที่ไม่ได้ประกาศธรรมดา — มัน error ก็ต่อเมื่อไม่มีอะไรใน scope ประกาศ globalrequireเลย และโปรเจกต์จริงส่วนใหญ่มี@types/nodeทำแบบนั้นอยู่ ความผิดพลาดเดียวกันจึง compile ผ่านเงียบ ๆ จนกว่า Node จะปฏิเสธไม่รันมัน- ถ้า package ship ทั้ง ESM build และ CJS build มันก็แค่ “code เดียวกันสองรูปแบบ” จริงก็ต่อเมื่อไม่มีอะไรใน package มี identity เท่านั้น class, registry แบบ
Symbol.for, หรือ cache ระดับ module คือ instance แยกกัน ต่อ build — พิสูจน์แล้วข้างบนด้วย Node process จริงที่ class เดียวกัน failinstanceofกับตัวเองข้ามสอง entry point bundlerresolution mode เป็นเวอร์ชันที่หลวมกว่าของnodenextเฉย ๆ มันต่างกัน ไม่ใช่แค่หลวมกว่า: มันไม่เคยแยก conditionrequire/importเลย เลยไม่มีทางเผย dual-package hazard ที่โผล่แค่ตอน Node process โหลดทั้งสอง entry จริง ๆ — โปรเจกต์ที่ develop และ test ผ่าน bundler อย่างเดียวอาจ ship bug นั้นตรงไปหา consumer แบบ Node-direct เลย- การใส่
"types"ไว้ก่อนในexportscondition จำเป็นเพื่อให้ TypeScript หา declaration file เจอ ตรวจกับ TypeScript 7.0.2 ตรง ๆ แล้วไม่จำเป็น —tscหา key"types"ด้วยชื่อ ไม่ใช่ตำแหน่ง คำแนะนำนี้ป้องกันเครื่องมืออื่นที่ระมัดระวังน้อยกว่า ไม่ใช่ป้องกันtscเอง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”exportsmap ที่ generate มาใส่"default"ก่อน"types"ใน condition object ไหม? มันน่าจะยังทำงานกับtsc7.0.2 (ตรวจแล้วข้างบน) แต่ก็ควร flag ไว้ตามธรรมเนียมของ ecosystem ที่กว้างกว่าอยู่ดี — เครื่องมืออื่นไม่ได้ยืดหยุ่นขนาดนั้นpackage.jsonแยกimport/requireเป็นไฟล์ที่ build แยกกัน โดยไม่เช็คว่า package export อะไรที่มี identity ไหม (class ที่ตั้งใจให้ใช้instanceof, cache ที่ share กัน)? นั่นคือรูปร่างของ dual-package hazard เป๊ะ — ควรมี test แบบสอง entry point จริง ไม่ใช่แค่ “แต่ละ build ผ่าน unit test ของตัวเอง”- มีการเพิ่ม
require("./esm-module.js")เพื่อ “แก้” error interop CJS/ESM โดยไม่เช็คว่าเป้าหมายมี top-levelawaitไหม? combination นั้นเจาะจง — CJSrequireของไฟล์ ESM ที่มี top-levelawait— ยัง fail แม้บน Node 22+ - code ที่ generate มาเรียก
require()ในไฟล์ที่nodenextจะมองว่าเป็น ESM ไหม?tsc --strictจะไม่จับสิ่งนี้ได้แน่นอน — ถ้ามี@types/nodeติดตั้ง (ซึ่งมักมี)requiretype-check ผ่านเป็น global ที่ valid โดยไม่มี error เลย ความผิดพลาดจะโผล่แค่เป็น runtimeReferenceErrorตอนที่ code path นั้นรันจริงครั้งแรก typed lint rule หรือ runtime test ต่างหากที่จับสิ่งนี้ได้จริง ไม่ใช่ type checker
🐛 หา bug ในโค้ดนี้
{"name": "@acme/widgets","type": "module","exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }}}เฉลย
ดูเหมือน dual-package setup ที่สะอาดและทันสมัย และ resolution เองก็ไม่มีปัญหา — bug อยู่ที่สิ่งที่มันไม่ได้พูด ไม่มี condition ไหนมี key "types" เลย ทั้งสอง consumer จึง fallback ไปที่การ lookup แบบ implicit ของ TypeScript: ไฟล์ .d.mts/.d.cts ที่อยู่ข้าง ๆ ไฟล์ JS แต่ละไฟล์ที่ชื่อตรงกัน ถ้า build step วันหนึ่ง emit แค่ index.d.ts (declaration ตัวเดียวที่ share กัน ซึ่งเป็นนิสัยของ single-build ที่พบบ่อยกว่า) แทนที่จะเป็นทั้ง index.d.mts และ index.d.cts consumer ฝั่ง ESM จะได้ ไม่มี type เลย สำหรับ package นี้เงียบ ๆ (TS7016, “implicitly has an ‘any’ type”) ในขณะที่ consumer ฝั่ง CJS อาจยังหา .d.ts ที่ share กันเจอผ่าน resolution path แบบเก่าที่ยืดหยุ่นกว่า — ผลลัพธ์ type-safety สองแบบจากการ install เดียวกัน โดยไม่มีอะไรในไฟล์นี้อธิบายว่าทำไม การ review exports map แบบ dual-package ต้องเช็คว่ามี declaration file ที่ match จริงสำหรับแต่ละcondition ไม่ใช่แค่ว่า JS resolve ได้