TypeScript 4.7 beta models Node's module boundaries
The 4.7 beta introduces node12 and nodenext modes. Package type, output extensions and conditional exports become part of how TypeScript resolves and emits modules.
An import can type-check and still describe a file the runtime will not load. TypeScript's new Node module support addresses that gap by making the surrounding package and the importing file's module format part of resolution. It needs to model the runtime's rules while finding the source and declaration files used during checking.
The April 8 announcement names the beta's two modes node12 and nodenext. Those are the settings available in this preview. They extend TypeScript's understanding of Node's ECMAScript modules and CommonJS interoperability, including the fact that one package can expose different entry points to each format.
I value TypeScript's tooling when it makes application boundaries explicit. Module loading is one of those boundaries. A compiler that accepts an import should account for the runtime file that import will name, rather than relying on a development resolver to make an incomplete path work.
Package context determines the file format
Node's package.json field type determines whether ordinary JavaScript files in its scope use ECMAScript-module or CommonJS interpretation. The beta applies that context to TypeScript source as well. It walks upward looking for the package description and uses the result when resolving imports and choosing emitted syntax.
An ECMAScript-module TypeScript file retains its import and export statements in JavaScript output. A CommonJS file emits the corresponding CommonJS form. The same source extension can therefore participate in different module formats depending on its package context.
That makes the package file part of the compilation contract. Moving source into another package boundary can change how the compiler interprets it, even if the source text remains identical. A declaration file also has a module interpretation, so package context matters to consumers reading types as well as to the emitted executable code.
Explicit extensions provide another choice. The beta supports .mts and .cts source files, emitted as .mjs and .cjs respectively. Their declarations use .d.mts and .d.cts. Those extensions state the format without relying on a package-wide default.
The preview also adds moduleDetection. Its default auto mode considers package type in the new Node modes, alongside explicit imports and exports. That prevents the absence of an import statement from automatically making a file a global script when its package already declares module interpretation.
Source imports name the output
The announcement's relative-import example exposes a common surprise. An ECMAScript-module source file imports ./helper.js even when the file being edited is helper.ts. The specifier names the emitted file that Node will load.
// helper.ts
export function label(value: string) {
return value.trim()
}
// main.ts, in a package with "type": "module"
import { label } from "./helper.js"
console.log(label(" ready "))
TypeScript can use that specifier to find the source during checking while leaving an appropriate runtime path in the emitted import. Using an extensionless path would ask Node to perform a lookup that its ECMAScript-module resolver does not promise.
The Node 17.9 documentation requires extensions for relative and absolute ECMAScript-module specifiers. Directory index paths must also be fully specified. This runtime rule explains the compiler's behavior. It is not an arbitrary preference for JavaScript suffixes in TypeScript source.
The Node documentation distinguishes bare package specifiers from relative ones. A package's exports can control the permitted package entry points. Treating every import as a filesystem path would miss that distinction and misrepresent what the runtime actually resolves.
Types follow the selected export condition
A package can expose an import entry point and a separate require entry point through conditional exports. The beta uses the importing module's format to choose the relevant branch, then looks for the corresponding declarations. Types must describe the executable entry point the consumer will receive.
The announcement also supports a types condition for declarations stored elsewhere. That condition belongs before the runtime fallback inside the relevant export branch. The package can therefore express separate type declarations for its ECMAScript and CommonJS entry points.
This is more precise than one top-level types field describing every possible consumer. The executable entry points may have different module identities and declaration formats. Their types need to follow the same choice that selects their runtime implementations.
I prefer this explicit package contract even though it increases the amount of metadata a dual-format library must maintain. Hiding the distinction would only move the uncertainty into consumers' resolvers and interoperability assumptions.
Interoperability has an asymmetric limit
Node permits an ECMAScript module to import CommonJS, with module.exports available through a default export. It can also infer named exports in some cases. The beta announcement cautions that TypeScript cannot always know which named exports Node will synthesize, so successful checking is not proof of every dynamic export shape.
The reverse direction uses dynamic import() to load an ECMAScript module from CommonJS under the documented Node behavior. The beta also maps TypeScript's import-equals form to Node's createRequire when used from an ECMAScript module. That is a Node interoperability mechanism with a corresponding portability cost for browser execution. The preview makes those choices visible through module format and resolution, rather than treating both systems as interchangeable.