February 22, 2024·4 min read·engineeringtypescriptmodulescompilerinference

TypeScript 5.4 RC models mixed imports without rewriting their meaning

The 5.4 release candidate adds module preserve for code that mixes import and require. Conditional exports explain why this matters even when TypeScript does not emit the application's JavaScript.

Choosing between import and require can select different files from the same package. A type checker that treats both as equivalent can consequently describe the wrong dependency. The TypeScript 5.4 release candidate, announced February 22, includes module: preserve to model environments that permit both forms without rewriting every import to one module format.

This is relevant even when a bundler emits the JavaScript. TypeScript still needs to resolve the files whose declarations it checks. Its model of the eventual module operation determines which package export condition applies. A configuration that produces no JavaScript can therefore still need an accurate account of what that JavaScript would do.

I remain more impressed by TypeScript's web ecosystem than attached to the language itself. Correctly describing the framework and runtime environment is part of what makes that ecosystem usable. A compiler option that matches the actual module operations is useful because it reduces disagreement between editor feedback and the program that eventually runs.

Bundler resolution originally assumed imports

The implementation proposal explains the earlier restriction. Bundler resolution initially assumed that resolved dependencies came through ECMAScript imports. Requiring module: esnext prevented a later TypeScript transformation from turning those imports into CommonJS calls before the bundler saw them. That kept the selected export condition aligned with the expected module operation.

The restriction also prevented TypeScript's import x = require(...) syntax in that configuration. The proposal records a practical motivation for changing it. An attempted switch of editor defaults to bundler resolution lost useful JavaScript require IntelliSense and had to be reverted. Supporting both forms needed a module mode that could preserve their distinct meanings.

The new setting supplies that mode. ECMAScript imports retain their module form. TypeScript-specific CommonJS syntax emits its CommonJS equivalent. The implementation notes that ordinary import elision still applies, so preserve should not be interpreted as keeping every unused type import in the output. Preserving module format and retaining imports unused at runtime are separate decisions.

The syntax determines the export condition

An illustrative package could publish this export map:

{
  "name": "example-adapter",
  "exports": {
    ".": {
      "import": "./esm/index.mjs",
      "require": "./cjs/index.cjs"
    }
  }
}

Assuming suitable declarations accompany the package, the checker needs to follow the branch corresponding to the operation in the consuming source. These two TypeScript forms deliberately express different operations:

import * as modernAdapter from "example-adapter";
import legacyAdapter = require("example-adapter");

Under the new mode, the first follows the import condition and the second follows the require condition. Their eventual runtime implementations need not be represented by the same file. That is why allowing both forms involves resolution behavior, not merely relaxing a syntax error. The compiler has to keep enough information to distinguish them throughout checking.

The release candidate documents module: preserve as implying bundler resolution, along with interoperability and JSON module defaults. Those defaults make the intended configuration easier to express. They do not turn a bundler-oriented project into a direct model of every Node.js module restriction. The setting's purpose is to represent the more permissive mixed-module behavior described by the proposal.

Inference and narrowing remain part of the candidate

The candidate also includes the beta's NoInfer utility and improved closure narrowing. NoInfer<T> prevents an argument from contributing candidates while the compiler infers T. The argument must still satisfy the inferred type. This lets a list define allowed values while another argument supplies a default checked against that list.

Closure narrowing uses a different kind of evidence. A non-hoisted function created after a parameter or local variable's last assignment can retain a refinement known at creation. A nested function that assigns the variable prevents that analysis because the compiler cannot establish when the mutation occurs. These rules improve existing source patterns without requiring changes to the JavaScript runtime.

The release candidate announcement says no further changes are expected beyond critical fixes before stability. It also expands the documented behavioral changes since beta, including enum compatibility restrictions. Those notes matter because a more accurate checker can reject previously accepted code. Feature availability and compatibility with existing type assumptions are separate parts of the candidate's behavior.

For enums with matching declaration and member names, the candidate now requires known member values to agree. Matching names alone previously allowed incompatible known values. Two declarations can therefore look structurally related while describing different numeric meanings. The new diagnostic makes that disagreement visible at assignment. This is relevant to declarations duplicated across a package boundary, where apparent agreement in names can conceal disagreement in the values a program actually sends or receives. The candidate's notes explain the restriction through that value-level difference.

I regard the module change as particularly useful for explaining configuration. If source syntax determines a different export branch, the editor needs to follow that branch too. module: preserve gives projects targeting supporting bundlers and runtimes a way to retain that distinction, including when another tool owns JavaScript emission. The candidate makes that model available alongside the inference improvements already introduced in the beta.