TypeScript 4.7: Native ESM Support for Node.js

Felipe Hlibco

TypeScript 4.7 shipped yesterday, and the headline feature is one that was supposed to land six months ago: native ESM support for Node.js.

If you’ve been following this saga, you know the backstory. TypeScript 4.5 (November 2021) was supposed to include the node16 and nodenext module resolution settings. The TypeScript team pulled them at the last minute because the implementation wasn’t ready. That delay was frustrating for everyone who’d been waiting — the CJS/ESM interop story in Node.js has been a mess for years, and TypeScript not supporting it natively made everything worse.

Well, it’s here now. Let’s look at what actually shipped.

The New Module Settings: node16 and nodenext #

The core change is two new values for module and moduleResolution in your tsconfig.json:

{
  "compilerOptions": {
    "module": "node16",
    "moduleResolution": "node16"
  }
}

What these do: they tell TypeScript to follow Node.js’s actual module resolution rules, which means understanding that .mjs files are ESM, .cjs files are CommonJS, and .js files depend on the nearest package.json’s "type" field.

This sounds basic, but it’s a significant shift from how TypeScript has handled modules historically. Before 4.7, TypeScript had its own module resolution logic that didn’t fully align with how Node.js actually resolves modules at runtime. You’d write code that TypeScript was happy with, but Node would choke on at runtime because the resolution semantics diverged.

The nodenext setting is identical to node16 today but will track future Node.js module resolution changes. If you want stability, use node16. If you want to stay current with Node’s evolution, use nodenext. My recommendation: start with node16 and switch to nodenext once you have a sense of how aggressively Node changes things.

New File Extensions: .mts and .cts #

Node.js uses .mjs for ES modules and .cjs for CommonJS. TypeScript 4.7 adds the corresponding .mts and .cts extensions, which compile to .mjs and .cjs respectively.

This matters for packages that need to ship both ESM and CJS builds (which, in 2022, is most packages that care about broad compatibility). You can now write your source as .mts files with full ESM semantics, and TypeScript will produce .mjs output that Node.js treats as ESM without any ambiguity.

The package.json "type" field is now respected too. Set "type": "module" and your .ts files are treated as ESM by default. Set "type": "commonjs" (or omit it) and they’re treated as CJS. This aligns TypeScript with Node’s own behavior, which eliminates an entire category of “works in dev, breaks in production” bugs.

Why This Took So Long #

The CJS/ESM interop problem in Node.js is genuinely hard. It’s not just about syntax (import vs require); it’s about fundamentally different module evaluation semantics. CJS is synchronous and evaluates modules on demand. ESM is asynchronous and statically analyzable. Making them work together — especially around things like circular dependencies, conditional exports, and dual-package publishing — involves tradeoffs that don’t have clean solutions.

TypeScript’s challenge was even harder: they needed to model these runtime semantics in the type system. When you import a CJS module from an ESM file, what types do you get? How does exports vs module.exports vs export default interact across the boundary? These aren’t questions with obvious answers, and getting them wrong would cause more problems than not supporting ESM at all.

I’ve been dealing with CJS/ESM pain at work for a while now. Internal libraries that need to work in both module systems, build tooling that expects one format but receives another, test runners that have their own opinions about module loading. The number of hours I’ve spent debugging “ERR_REQUIRE_ESM” and “SyntaxError: Cannot use import statement outside a module” is genuinely depressing.

TypeScript 4.7 doesn’t make all of that go away. But it gives you a type-safe way to reason about it, which is a significant improvement over the “try it and see if Node throws” approach.

Other Notable Features #

Beyond ESM support, 4.7 has a few things worth mentioning.

Computed properties in control flow narrowing. TypeScript can now narrow types based on computed property access in if checks:

const key = Symbol('name');
const obj = { [key]: 'hello' as string | number };

if (typeof obj[key] === 'string') {
  // obj[key] is narrowed to string here
  obj[key].toUpperCase();
}

This has been a pain point for anyone using Symbols or dynamically-keyed objects. It’s a small change that removes a lot of as casts.

Improved function inference in objects and methods. TypeScript is better at inferring types for functions defined inside object literals and passed to generic functions. The specifics are technical, but the practical impact is fewer situations where you need to add explicit type annotations because the compiler couldn’t figure it out.

Migration Notes #

If you’re upgrading an existing project:

Start with moduleResolution: "node16" and see what breaks. The stricter resolution rules will surface imports that work by accident today — missing file extensions in relative imports, incorrect exports maps in package.json, imports that resolve differently under Node’s actual algorithm. Fix those first.

File extensions in imports are now required for ESM. import { thing } from './utils' needs to become import { thing } from './utils.js' (yes, .js even though the source is .ts — TypeScript resolves the .js extension to the .ts source file). This trips up everyone the first time. It’s correct behavior per Node’s ESM specification; it just feels wrong.

Check your dependencies. Some npm packages don’t have proper exports maps in their package.json, which can cause resolution failures under node16 mode. Most popular packages have been updated, but you might hit edge cases with less-maintained dependencies.

Don’t upgrade everything at once. If you have a monorepo, pick one package to migrate first and work through the issues before rolling it out broadly. The ESM transition has a lot of subtle gotchas, and debugging them across multiple packages simultaneously is a recipe for frustration.

The ESM Transition Is Still Messy #

I want to be honest about this: TypeScript 4.7 makes things better, but the ESM transition in Node.js is still not smooth. The ecosystem is in a long, awkward in-between state where some packages are ESM-only, some are CJS-only, and some ship dual builds with varying degrees of correctness.

TypeScript supporting node16 and nodenext is a necessary step, but it doesn’t solve the social problem of getting the entire npm ecosystem to converge on consistent module practices. That’s going to take time — probably another year or two before the rough edges are truly gone.

In the meantime, TypeScript 4.7 gives you the tools to do it right. And that’s worth celebrating, even if the road ahead is still bumpy.