TypeScript 4.7 makes declaration files follow Node module identity
TypeScript 4.7 supports Node's ECMAScript modules through node16 and nodenext. Dual-format packages need declarations that match each runtime entry point and its module interpretation.
A package name does not necessarily select one implementation. Node can resolve an import to one entry point and a CommonJS require to another. TypeScript 4.7 brings those conditions into its type-resolution model, so the declarations a consumer reads can follow the runtime module it receives.
The May 24 release supplies node16 and nodenext module modes. Its stable mode changed from the beta's node12 because Node 12 had left maintenance. The final announcement also distinguishes features withdrawn or restricted since the beta, making it important to use the final compiler's contract when configuring a package.
I value TypeScript's web ecosystem while remaining cautious about the machinery around it. Module declarations are useful only when they describe what execution will actually load. A successful editor lookup that hides a runtime entry-point difference leaves the most consequential part of the dependency unexplained.
One source extension can have two interpretations
Node's package type field controls how ordinary JavaScript files are interpreted. TypeScript's new modes use that package context for source and declaration files. An ordinary .ts file in a module package emits ECMAScript imports and exports, while one interpreted as CommonJS emits the corresponding CommonJS operations.
The lookup walks upward to the relevant package description. A file's meaning therefore depends on more than the syntax inside it. Two otherwise identical files can belong to different module systems because their containing packages declare different defaults.
Explicit extensions make that choice local. TypeScript's .mts source emits .mjs, while .cts emits .cjs. Declaration emit produces .d.mts and .d.cts for those files. The format remains explicit even when the surrounding package uses another default.
A .d.ts file is not exempt from module interpretation. It also belongs to its package context. Treating a declaration as format-neutral would lose the distinction the runtime makes between the entry points it describes.
This gives package authors two ways to express intent. A package-wide default is convenient when most files share a format. Explicit extensions identify exceptions or state the format for every file. Either approach still needs the source, emitted JavaScript and declarations to describe the same module boundary.
Relative specifiers name executable files
The release announcement demonstrates imports ending in .js inside TypeScript source. That spelling names the output Node will load after compilation. The checker can associate it with the TypeScript source without changing the runtime specifier into an extensionless lookup.
A relative ECMAScript import that omits the output extension can compile under assumptions that do not match Node's loader. TypeScript's new modes make the required spelling visible during development. This is a runtime compatibility requirement, even if a different bundler has traditionally filled in missing extensions.
The distinction also affects declaration files. A declaration can contain module references that consumers resolve according to their own module context. Correct executable output alone does not establish that the package's type references resolve through the corresponding route.
The useful unit of reasoning is consequently the published package. Its source layout can be convenient for maintainers, but consumers receive emitted files and metadata. The import text and declaration paths must continue making sense in that published arrangement.
Conditional exports choose among entry points
The Node 16.15 package documentation describes conditional exports as ordered mappings. An import condition matches an import operation, and a require condition matches a require operation. A default condition supplies the general fallback and should come last.
TypeScript follows the relevant import or require branch when resolving the associated declarations. A package can include a types condition within each branch to point to declarations stored separately from the executable file. The following example describes a package layout whose named files must be included in its distribution.
{
"name": "sample-library",
"type": "module",
"exports": {
".": {
"import": {
"types": "./types/esm/index.d.ts",
"default": "./esm/index.js"
},
"require": {
"types": "./types/cjs/index.d.cts",
"default": "./cjs/index.cjs"
}
}
}
}
Here the package default makes the ordinary .d.ts declaration an ECMAScript-module declaration. The explicit .d.cts extension identifies the CommonJS declaration. Each condition pairs a declaration with the executable entry point it describes.
Order is part of the contract. The TypeScript announcement says the types condition must come first, and Node's documentation explains that earlier matching conditions take precedence. Conditions are not an unordered set of equivalent metadata fields.
Node ignores unrecognized conditions by default, including typing-oriented conditions that do not apply to its runtime resolution. The runtime can therefore proceed to the relevant executable fallback while TypeScript finds the declarations. This coordinates two consumers of the same package metadata without asking Node to execute a declaration file.
The top-level types and main fields can still provide fallbacks for older consumers, as the announcement's example shows. They do not eliminate the need for the conditional branches. Older resolution behavior and the newer conditional contract can coexist within one package description.
Self-references follow the same exports contract. A package importing itself by name can exercise an exported path, rather than reaching into its own private layout through an unrelated relative path. Node documents that only paths exposed by exports qualify. This gives internal consumers a way to use the same named entry points that external consumers receive.
Dual output can create dual state
Conditional exports describe how to find files, but finding the intended files is not the only design concern. Node's documentation explains the dual-package hazard. Import and require can load distinct versions of a package within the same process.
An application might load one entry point while a dependency loads the other. If both implementations create separate state, a change made through one need not appear through the other. Constructors can also have different identities, so an instance created by one version can fail an instanceof check against the other.
This is a runtime identity problem that declarations cannot remove. Two compatible type descriptions do not prove that two loaded implementations share the same objects. A library author must decide whether its package can tolerate separate instances or must arrange shared implementation state.
Node documents an ECMAScript wrapper around a CommonJS implementation as one approach. The wrapper exposes names derived from the underlying CommonJS module, so both consumers can reach the same values. Another approach isolates state shared by the separately exposed implementations.
I prefer a package to make that state decision explicit. Emitting two formats is an output choice, while sharing identity is a behavioral requirement. A build producing both directories has not answered whether consumers can safely combine them in one application.
The appropriate structure depends on what the library exports. A stateless function collection has different concerns from a mutable registry or a constructor used for identity tests. The package documentation presents alternatives because one output arrangement does not settle those differences for every library.
Interoperability remains asymmetric
An ECMAScript module can import a CommonJS module, with the exported CommonJS value available as the default. Node may also synthesize named exports by analyzing the source. The TypeScript announcement warns that the compiler cannot always determine which names the runtime will synthesize.
That warning limits what a successful type check establishes. A declaration can promise a named member, and the compiler can permit an import, while the runtime's CommonJS analysis still has its own rules. The declaration and interoperability strategy need to agree with the actual module representation.
In the reverse direction, the documented Node behavior requires dynamic import() to load an ECMAScript module from CommonJS. TypeScript also supports its import-equals syntax in ECMAScript modules by using Node's createRequire. That solves a Node-specific interoperability problem with a corresponding portability limitation for other environments.
The final release narrows the beta contract
The final announcement retains resolution-mode on reference directives but keeps its use on type imports experimental in nightly compilers. It also removes the beta's type queries on private fields because of API-compatibility and declaration-emission concerns. Those decisions reinforce that a preview configuration is evidence of an experiment, not a commitment about the final language.
Module detection is part of the final model too. The default auto mode considers package type under the new Node modes and implicit JSX-runtime imports. The force mode treats every non-declaration file as a module, while legacy retains the older explicit-import-or-export test.
For a dual-format library, the central change is that type resolution now has enough context to follow the selected runtime contract. Package conditions choose an entry point, file extensions identify its format, and declarations describe that specific interpretation. The compiler can check those relationships, while the library remains responsible for the shared-state and identity behavior of the executable files it publishes.