TypeScript 4.7 ECMAScript Module Support

Master native ESM configuration for Node.js with TypeScript 4.7's improved module support, enabling seamless interoperability between TypeScript and modern JavaScript.

Why TypeScript 4.7 Changed ESM Support

TypeScript 4.7 introduced native ECMAScript Module (ESM) support for Node.js, eliminating years of configuration complexity and enabling seamless interoperability between TypeScript and the modern JavaScript module ecosystem. This guide covers everything you need to configure ESM correctly in your projects.

Our web development services team has helped numerous clients migrate to modern module patterns, and this guide distills that practical experience into actionable configuration guidance.

What Changed in TypeScript 4.7

Before TypeScript 4.7, developers faced significant challenges when trying to use native ESM with TypeScript and Node.js. The ecosystem was fragmented between CommonJS (the traditional Node.js module format) and ESM (the standardized JavaScript module format), creating friction for projects that needed to support both module systems simultaneously. Developers relied on workarounds like the typesVersions field, transpiler-specific module configurations, and bundler-level solutions to bridge the gap between TypeScript's type checking and Node.js's module execution.

TypeScript 4.7 introduced official support for Node's native ESM implementation, allowing developers to use modern module syntax without workarounds or complex configuration layers. This release marked a significant milestone in TypeScript's evolution, bringing the language's module handling capabilities in line with contemporary JavaScript standards and Node.js best practices.

Key improvements include:

  • Native understanding of package exports field: TypeScript 4.7 now correctly interprets the exports field in package.json, enabling precise control over public API entry points
  • NodeNext module resolution strategy: A dedicated module resolution mode that follows Node.js's native algorithm, eliminating discrepancies between type checking and runtime behavior
  • Elimination of the typesVersions workaround: The need for complex typesVersions mappings is removed, as TypeScript now handles module resolution more intelligently
  • Proper handling of file extensions in import statements: TypeScript now enforces and validates explicit file extensions, aligning with ESM requirements

The result is a dramatically simplified configuration experience where TypeScript's type checking accurately reflects how Node.js will execute your code at runtime.

Essential tsconfig.json Configuration

The tsconfig.json file is the foundation of TypeScript ESM configuration. Understanding each option helps you optimize your build setup for performance and compatibility. For teams building scalable applications, proper module configuration is essential - see our guide on Structuring Scalable Next.js Projects for comprehensive architecture patterns.

Module and Module Resolution Settings

{
 "compilerOptions": {
 "module": "NodeNext",
 "moduleResolution": "NodeNext",
 "target": "ES2020",
 "outDir": "./dist",
 "rootDir": "./src",
 "declaration": true,
 "sourceMap": true
 }
}

The module option set to "NodeNext" tells TypeScript to generate ESM-compliant output that works with Node.js's native module system. This setting produces import and export statements that Node.js can execute directly without additional transformation. Combined with "moduleResolution": "NodeNext", TypeScript will resolve imports using Node's module resolution algorithm, ensuring consistency between development and runtime behavior. According to the TypeScript 4.7 Release Notes, this combination provides the most predictable ESM experience.

The target option of "ES2020" enables modern JavaScript features including dynamic imports and import.meta, which are essential for flexible module loading patterns in production applications. Dynamic imports allow for code splitting and lazy loading, improving both initial page load performance and overall application efficiency.

Understanding Module Detection

TypeScript 4.7 provides granular control over module detection. The module detection algorithm considers multiple signals to determine whether code should be treated as ESM or CommonJS:

  • The "type" field in package.json: When set to "module", Node.js treats all .js files as ESM
  • File extensions: .mjs files are always ESM, .cjs files are always CommonJS
  • The module and moduleResolution compiler options: These provide explicit configuration that overrides default behavior

This layered detection ensures predictable behavior across different environments and build tools, reducing the likelihood of runtime errors caused by module format mismatches. Understanding these signals helps developers debug configuration issues quickly and maintain consistent module behavior across development, staging, and production environments.

package.json Configuration for ESM

The package.json file plays a crucial role in ESM configuration. The "type": "module" field is the primary signal that tells Node.js to treat all .js files in the package as ESM modules. As explained in TypeScript and native ESM on Node.js, this field fundamentally changes how Node.js interprets JavaScript files in your project.

The Type Field

{
 "name": "your-package",
 "version": "1.0.0",
 "type": "module",
 "main": "./dist/main.js",
 "exports": {
 ".": "./dist/main.js",
 "./utils": "./dist/utils/index.js"
 }
}

Setting "type": "module" is essential for ESM projects. This tells Node.js to interpret .js files as ESM modules rather than CommonJS modules, which affects how import and require() statements are processed. Without this field, Node.js defaults to CommonJS behavior, causing syntax errors when using ESM syntax like import statements or export declarations.

Additional Configuration Fields

Beyond the essential "type": "module" setting, several other package.json fields support ESM projects:

The main field specifies the primary entry point to your package. For ESM projects, this should point to the compiled JavaScript file with the .js extension, reflecting the fact that Node.js requires explicit file extensions in import statements when loading local modules.

The types or typings field points to your TypeScript declaration file. While not strictly required for JavaScript consumers, this field helps TypeScript users understand your package's API:

{
 "types": "./dist/index.d.ts",
 "sideEffects": false
}

The sideEffects field allows you to indicate whether your package has side effects when imported. Setting this to false enables tree-shaking, where bundlers can safely remove unused exports from your package.

Package Exports: Cleaner Imports and Internal Hiding

The package exports field, introduced in Node.js 12, provides a powerful way to define public entry points while hiding internal implementation details. This feature became significantly more valuable with TypeScript 4.7's native support, as documented in TypeScript and native ESM on Node.js.

Defining Public API

{
 "exports": {
 ".": {
 "import": "./dist/main.js",
 "require": "./dist/main.cjs"
 },
 "./utils": "./dist/utils/index.js",
 "./internal/*": null
 }
}

The exports field supports conditional exports, allowing different entry points for ESM and CommonJS consumers. This dual-package approach ensures compatibility with both module systems while providing optimized builds for each. The conditional syntax uses import and require conditions to select the appropriate entry point based on how the consumer loads the package.

Benefits of Package Exports

Package exports offer two primary advantages that transform how you design and maintain JavaScript packages:

  1. Internal Hiding: By defining specific entry points, you prevent consumers from accessing internal modules through deep import paths. This encapsulation allows you to refactor internal code without breaking external consumers. Setting a subpath to null explicitly disallows access to that path, creating a clear boundary between public API and implementation details.

  2. Cleaner Import Paths: Exports enable shorter, more readable import statements. Without exports, consumers might write import { func } from 'my-package/dist/src/utils/helper.js'. With exports, they simply write import { func } from 'my-package/utils'. This abstraction also allows you to change your internal structure without affecting consumers.

Export Patterns

The exports field supports several patterns to address different package structures:

  • Single entry point: " ./": "./dist/index.js" - Maps the root import to a specific file
  • Wildcard mapping: " ./*": "./dist/*" - Allows any subpath to resolve to corresponding files
  • Conditional exports: "import": "...", "require": "..." - Provides different entry points for different module systems
  • Subpath exports: " ./feature": "./dist/feature/index.js" - Maps specific named exports to their locations
Package Exports Benefits

Internal Hiding

Prevent consumers from accessing internal modules through deep import paths, enabling safe refactoring

Cleaner Imports

Enable shorter import paths like 'pkg/utils' instead of 'pkg/dist/src/utils/index.js'

Conditional Exports

Provide different entry points for ESM and CommonJS consumers in the same package

Version Stability

Maintain backward compatibility by controlling exactly what surfaces in the public API

File Extensions in Import Statements

One of the most significant changes when moving to ESM is the requirement for explicit file extensions in import statements. Unlike CommonJS, where require('./utils') can resolve to ./utils.js, ESM requires import { func } from './utils.js'. This requirement, while initially requiring adjustment, leads to more explicit and predictable module loading behavior.

VS Code Configuration

VS Code can be configured to automatically add the correct file extension when generating imports, preventing common errors and improving developer productivity:

{
 "typescript.preferences.importModuleSpecifierEnding": "js",
 "javascript.preferences.importModuleSpecifierEnding": "js"
}

These settings ensure that VS Code's import generation includes the .js extension, matching ESM requirements and preventing TypeScript errors when type checking code that will run in an ESM environment.

Manual Import Updates

For existing codebases migrating to ESM, you may need to update imports manually. The following search-and-replace pattern, suggested in the 2ality ESM guide, helps identify imports that need updating:

  • Search pattern: ^(import [^';]* from '(\./|(\.\./)+)[^';.]*)';
  • Replace pattern: $1.js';

This regex targets import statements with relative paths that lack file extensions. After applying this transformation, verify each import to ensure the target file exists and the path is correct.

ESM Import with File Extension
1// ESM requires explicit file extensions2import { start } from './utils/helper.js';3import { API } from './services/api.js';4 5// Dynamic imports also work with ESM6const module = await import('./feature.js');7 8// Compared to CommonJS (no extension required)9// const helper = require('./utils/helper');

Common Migration Challenges and Solutions

Migrating to ESM with TypeScript 4.7 involves addressing several common challenges that arise from the differences between CommonJS and ESM. Understanding these challenges upfront helps you plan migrations more effectively. Our web development team has extensive experience guiding organizations through this migration pattern.

Dynamic Import Requirements

When using dynamic imports (import()), ensure your target environment supports the feature. TypeScript 4.7's ES2020 target or higher includes dynamic import support out of the box. However, when targeting older environments, you may need additional configuration or polyfills. The ES2020 target is recommended for modern Node.js projects as it provides the best balance of modern features and runtime compatibility.

Path Alias Resolution

Path aliases configured with tools like tsconfig-paths or Webpack aliases require additional attention in ESM projects. The module resolution changes in TypeScript 4.7 mean that alias patterns may need updating to work correctly with NodeNext resolution. Consider migrating to relative imports or configuring your bundler to handle aliases consistently with Node.js's resolution algorithm.

JSON Imports

ESM requires explicit handling of JSON imports. In Node.js, you can use the experimental flag --experimental-json-modules or configure your bundler to handle JSON imports appropriately. TypeScript supports JSON imports through declaration files, allowing type-safe access to JSON data:

import config from './config.json';
// TypeScript will check that the imported JSON matches the expected type

TypeScript Definitions for ESM-Only Packages

When publishing packages that are ESM-only, ensure your type definitions are correctly published. The typesVersions field is no longer required with TypeScript 4.7's improved module handling, but ensuring your .d.ts files are correctly generated and included in the package remains essential. Generate declaration files during your build process and verify they are included in your published package.

Common ESM Migration Questions

Do I need to use .mjs extension with TypeScript?

No. TypeScript 4.7+ works with .js extensions when package.json has "type": "module". The .mjs extension is only needed for plain JavaScript projects without a build step.

Can I support both ESM and CommonJS consumers?

Yes, use conditional exports in package.json to provide different entry points for ESM (import) and CommonJS (require) consumers. This allows maximum compatibility with different module systems.

Why does TypeScript show errors on my imports?

Ensure your tsconfig.json has "module": "NodeNext" and "moduleResolution": "NodeNext", and that your package.json has "type": "module". Mismatched settings between these files cause common import errors.

How do I handle path aliases in ESM?

Configure moduleResolution to "NodeNext" and update your bundler config to match. Some alias patterns may need adjustment for native ESM. Consider using relative imports as an alternative.

Best Practices for TypeScript ESM Projects

Following established best practices ensures your TypeScript ESM projects are maintainable, performant, and compatible across environments. For deeper patterns on organizing TypeScript codebases, see our comprehensive guide on TypeScript Object Destructuring and other TypeScript best practices.

  1. Use NodeNext Module Settings: Set module and moduleResolution to "NodeNext" in your tsconfig.json for consistent Node.js behavior. This configuration aligns TypeScript's type checking with Node.js's runtime module resolution, eliminating surprises when deploying to production.

  2. Always Include File Extensions: Include .js extensions in import statements for local modules, matching ESM requirements. This explicit approach improves code readability and ensures TypeScript's understanding matches runtime behavior. Configure your editor to automatically add extensions to reduce manual effort.

  3. Define Package Exports: Use the exports field to define clear public APIs and hide internal implementation details. This encapsulation protects consumers from implementation changes and allows you to refactor internal code confidently. The exports field also enables conditional exports for dual CommonJS/ESM support.

  4. Generate Type Declarations: Enable declaration generation ("declaration": true) to support TypeScript consumers of your package. Combined with the declarationMap option, this provides an excellent developer experience for consumers using your package in their TypeScript projects.

  5. Test Both ESM and CommonJS Consumers: If supporting both module formats, test that conditional exports work correctly for each consumer type. Automated tests that verify both import styles function correctly prevent regressions and ensure broad compatibility.

  6. Update Editor Settings: Configure your editor to automatically add file extensions to imports for a smoother development experience. This prevents common errors and reduces the cognitive load of manually managing extensions.

Performance Considerations

ESM provides inherent performance benefits through better tree-shaking and code splitting. Modern bundlers can analyze ESM imports more effectively, removing unused code and optimizing bundle sizes. Dynamic imports enable lazy loading of code on demand, improving initial page load performance for large applications. These benefits compound as projects grow, making ESM a foundation for scalable application architecture.

ESM Benefits Summary

30%

Faster cold starts with native ESM

100%

Tree-shaking efficiency

1

Single module format to maintain

Conclusion

TypeScript 4.7's native ESM support represents a significant advancement in TypeScript's ability to work seamlessly with modern JavaScript module systems. By understanding and properly configuring tsconfig.json, package.json, and package exports, you can leverage the full benefits of ESM in your projects. The configuration may seem complex initially, but the resulting improvements in code organization, performance, and interoperability make the effort worthwhile for modern web development.

The key to successful ESM adoption lies in consistent configuration across your project, proper package.json settings, and understanding how TypeScript's type checking aligns with Node.js's runtime behavior. As the JavaScript ecosystem continues to standardize on ESM, these skills become increasingly valuable for maintaining competitive, performant applications.

For teams building scalable applications, combining TypeScript's ESM support with modern frameworks like Next.js creates a foundation for maintainable, high-performance web applications that leverage the best of modern JavaScript.

Ready to Modernize Your TypeScript Projects?

Our team specializes in building high-performance web applications with modern TypeScript patterns and Next.js.

Sources

  1. TypeScript 4.7 Release Notes - Official documentation on ESM support, module detection, and NodeNext module resolution
  2. TypeScript and native ESM on Node.js - Detailed technical explanation of package exports, typesVersions, and import specifier best practices
  3. Understanding TypeScript 4.7 and ECMAScript module support - Overview of major ESM features with practical implementation guidance
  4. Pure ESM package guide - Quick steps for migrating to ESM including package.json configuration