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

Modules & Resolution

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

เมื่อคุณเขียน 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"]
การเลือก moduleResolution mode
  • nodenext — จำลอง Node ปัจจุบันแบบเป๊ะ: อ่าน "type": "module" ใน package.json, เคารพ field "exports" และบังคับกฎ ESM (รวมถึงต้องเขียน file extension ใน relative import: import "./util.js") การตั้ง module: nodenext implies resolution ที่ตรงกันให้เลย ส่วน node16 คือไอเดียเดียวกันที่แช่แข็งไว้ที่ Node 16
  • node18 (5.8) / node20 (5.9) — snapshot แบบ stable ของ nodenext สำหรับทีมที่ pin Node ไว้สายใดสายหนึ่ง nodenext ขยับตาม Node ส่วน node20 จะไม่เปลี่ยนใต้เท้าคุณ module ไม่เคยตั้ง target ให้เอง — tsc --showConfig --module node20 ไม่มี key target โผล่มาเลย และ code ที่ใช้ ES2025 API (พร้อม lib ที่ตั้งไว้ให้เข้ากัน) compile ผ่านสบายภายใต้ module: node20 ที่ target default ของ compiler เลือก target แยกต่างหาก ตาม runtime ของคุณรองรับจริง ๆ ความต่างที่สำคัญที่สุดระหว่าง node18 กับ node20: node18 ยังยอมรับ syntax assert ของ 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

ภายใต้ 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 .js
import { helper } from "./util.js"; // ✅ correct under nodenext ESM
import { 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 disk
import { 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 สองชุด

feature ของ Node สองอย่างที่ nodenext จำลองไว้:

  • require("./esm-module.js") จาก CommonJS ใช้ได้ใน Node 22+ (ตราบใดที่ไฟล์ ESM ไม่มี top-level await) TypeScript 5.8+ เข้าใจเรื่องนี้ภายใต้ nodenext library จึง ship แค่ ESM แล้วให้ผู้เรียกฝั่ง CJS ใช้ได้ — ปัญหา dual-package เล็กลงกว่าเดิมมาก
  • subpath import ที่ขึ้นต้นด้วย #/ — Node ให้ package.json map "imports": { "#/*": "./dist/*" } ได้ และ 6.0 resolve import { x } from "#/util.js" ภายใต้ nodenext และ bundler นี่คือของแทนอย่างเป็นทางการของทริค baseUrl โดย scope อยู่ใน package ของคุณเองและ Node เข้าใจด้วยตัวมันเอง

keyword asserts ของ import assertion ถูก deprecate แล้ว มาตรฐานคือ with และภายใต้ nodenext การ import JSON ต้อง มีมันและ ต้อง เป็น default import:

// @skip-verify illustrative — "./config.json" is not a real module
import config from "./config.json" with { type: "json" }; // ✅
import { port } from "./config.json" with { type: "json" }; // ❌ no named exports from JSON
import config from "./config.json" assert { type: "json" }; // ❌ deprecated syntax

TypeScript 5.9 ยัง type import defer ซึ่งเป็น ECMAScript proposal สำหรับการ evaluate module แบบ lazy module ถูก link แต่ไม่ถูก รัน จนกว่าคุณจะแตะ export ครั้งแรก — มีประโยชน์กับ initialisation ที่แพงและใช้แค่บางครั้ง:

// @skip-verify illustrative — "./heavy-feature.js" is not a real module
import 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 ที่รองรับ

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 output
console.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 prose
const data = require("node:fs");
console.log(data);
Open in TS Playground

กด 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" ใน 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: false
esmInstance instanceof CjsCounter: false
esmInstance instanceof EsmCounter: true

EsmCounter กับ 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 ที่ใช้ทั้งคู่จริง ๆ ทำได้

ทุก 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 shimdual-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 ประกาศ global require เลย และโปรเจกต์จริงส่วนใหญ่มี @types/node ทำแบบนั้นอยู่ ความผิดพลาดเดียวกันจึง compile ผ่านเงียบ ๆ จนกว่า Node จะปฏิเสธไม่รันมัน
  • ถ้า package ship ทั้ง ESM build และ CJS build มันก็แค่ “code เดียวกันสองรูปแบบ” จริงก็ต่อเมื่อไม่มีอะไรใน package มี identity เท่านั้น class, registry แบบ Symbol.for, หรือ cache ระดับ module คือ instance แยกกัน ต่อ build — พิสูจน์แล้วข้างบนด้วย Node process จริงที่ class เดียวกัน fail instanceof กับตัวเองข้ามสอง entry point
  • bundler resolution mode เป็นเวอร์ชันที่หลวมกว่าของ nodenext เฉย ๆ มันต่างกัน ไม่ใช่แค่หลวมกว่า: มันไม่เคยแยก condition require/import เลย เลยไม่มีทางเผย dual-package hazard ที่โผล่แค่ตอน Node process โหลดทั้งสอง entry จริง ๆ — โปรเจกต์ที่ develop และ test ผ่าน bundler อย่างเดียวอาจ ship bug นั้นตรงไปหา consumer แบบ Node-direct เลย
  • การใส่ "types" ไว้ก่อนใน exports condition จำเป็นเพื่อให้ TypeScript หา declaration file เจอ ตรวจกับ TypeScript 7.0.2 ตรง ๆ แล้วไม่จำเป็น — tsc หา key "types" ด้วยชื่อ ไม่ใช่ตำแหน่ง คำแนะนำนี้ป้องกันเครื่องมืออื่นที่ระมัดระวังน้อยกว่า ไม่ใช่ป้องกัน tsc เอง
  • exports map ที่ generate มาใส่ "default" ก่อน "types" ใน condition object ไหม? มันน่าจะยังทำงานกับ tsc 7.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-level await ไหม? combination นั้นเจาะจง — CJS require ของไฟล์ ESM ที่มี top-level await — ยัง fail แม้บน Node 22+
  • code ที่ generate มาเรียก require() ในไฟล์ที่ nodenext จะมองว่าเป็น ESM ไหม? tsc --strict จะไม่จับสิ่งนี้ได้แน่นอน — ถ้ามี @types/node ติดตั้ง (ซึ่งมักมี) require type-check ผ่านเป็น global ที่ valid โดยไม่มี error เลย ความผิดพลาดจะโผล่แค่เป็น runtime ReferenceError ตอนที่ 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 ได้

moduleResolution ควรถูกตั้งให้อธิบายอะไร?
ภายใต้ nodenext ESM คุณเขียน extension อะไรเมื่อ import ./util.ts?
สถานะของ esModuleInterop ใน TypeScript 7 คืออะไร?
ค่า moduleResolution ตัวไหนเป็น hard error ใน TypeScript 7?
import ไฟล์ JSON ภายใต้ module: nodenext อย่างไร?
ไฟล์แบบ ESM tsc --strict รายงาน TS2591 เมื่อไม่โหลด Node types แต่รายงาน **ไม่มี error เลย** บน code เดียวกันเป๊ะเมื่อตั้ง "types": ["node"] แล้ว ทำไมถึงต่างกัน และเกิดอะไรขึ้นจริงตอนไฟล์นี้รัน?
const data = require("node:fs");
console.log(data);
package แยก exports เป็น ESM build .mjs กับ CJS build .cjs แต่ละอันมี class Counter ที่ "เหมือนกัน" consumer import ทั้งสองแบบใน process เดียวแล้วได้ reference Counter สองตัวที่ instanceof fail ข้ามกัน ทำไม?