ES Modules Logo ESModules.com

TypeScript & ESM

TypeScript and ES Modules have a complex relationship. Master module resolution modes, file extension requirements, declaration files, and the latest TypeScript features for ESM.

Module Resolution Modes

The moduleResolution setting in tsconfig.json determines how TypeScript finds imported modules. Choosing the right mode is critical:

Mode Use When Extensions
bundler Using Vite, webpack, esbuild, Turbopack Optional (.js or none)
nodenext Node.js libraries and apps (ESM or CJS) Required (.js)
node16 Same as nodenext (targeting Node 16+) Required (.js)
node10 Legacy - avoid for new projects Optional
// tsconfig.json for a Vite/bundler project
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2022",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  }
}

// tsconfig.json for a Node.js ESM project
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "ES2022",
    "strict": true,
    "outDir": "./dist"
  }
}

The .js Extension Requirement

With nodenext resolution, you must use .js extensions in imports - even when importing .ts files. This is because TypeScript emits .js files:

// src/utils.ts - your TypeScript source file
export function add(a: number, b: number): number {
  return a + b;
}

// src/app.ts - importing with .js extension
import { add } from './utils.js';  // .js, not .ts!
// TypeScript knows utils.js refers to utils.ts at compile time
// At runtime, the emitted utils.js file exists

// This will NOT work with nodenext:
import { add } from './utils';     // Error!
import { add } from './utils.ts';  // Error!

TypeScript 5.8+ Alternative:

The --rewriteRelativeImportExtensions flag lets you write import './utils.ts' and TypeScript will automatically rewrite it to ./utils.js in the output.

Declaration Files for ESM

When publishing TypeScript packages as ESM, declaration files (.d.ts) need special handling:

// package.json - ESM package with TypeScript types
{
  "name": "my-esm-library",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.js"
    }
  },
  "files": ["dist"]
}

// tsconfig.json for building the library
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}

Key Rule:

Always put "types" first in the exports conditions. TypeScript checks conditions in order and stops at the first match. If "import" comes before "types", TypeScript will try to use the .js file for type resolution.

TypeScript 5.8 - 5.9 ESM Features

TypeScript 5.8 (March 2025)

// --erasableSyntaxOnly flag
// Ensures your TypeScript only uses syntax that can be
// "erased" (removed) to produce valid JavaScript
// Required for Node.js 24 built-in type stripping

// --rewriteRelativeImportExtensions
// Write .ts imports, get .js in output
import { utils } from './utils.ts';
// Emits: import { utils } from './utils.js';

TypeScript 5.9 (August 2025)

// import defer support (TC39 Stage 3)
import defer * as analytics from './analytics.js';

// TypeScript understands the defer semantics:
// - Type checking works normally
// - No execution until property access
// - Supports full IntelliSense

function trackEvent(name: string) {
  analytics.track(name); // Evaluated here
}

TypeScript 7 (2026)

TypeScript 7 is a ground-up rewrite in Go, delivering 8-10x faster compilation. Key module changes:

  • Removed: AMD, UMD, and SystemJS module output formats
  • Removed: Classic module resolution (node10 becomes legacy)
  • Default: moduleResolution: "bundler" in TypeScript 6.0+
  • Performance: ~50% memory reduction, 8-10x faster builds
  • Example: VSCode (1.5M lines) compiles in 8.74s instead of 89s

What This Means:

TypeScript 7 fully embraces modern module systems. If your project still uses AMD, UMD, or old-style module resolution, you will need to migrate before upgrading to TypeScript 7.

Running TypeScript Directly

Multiple options for executing TypeScript without a manual compile step:

# Node.js 24+ built-in type stripping
node app.ts  # Works for erasable syntax!

# tsx - TypeScript execute (any Node.js version)
npx tsx app.ts
npx tsx --watch app.ts  # Watch mode

# ts-node with ESM support
node --loader ts-node/esm app.ts

# Deno - native TypeScript, no config needed
deno run app.ts

# Bun - native TypeScript, no config needed
bun run app.ts

Node.js Type Stripping Limitations:

Node.js 24 only strips type annotations. Features that require transformation (enums, namespaces, decorators, parameter properties) need the --experimental-transform-types flag or a tool like tsx.

Common Pitfalls

Missing .js extensions with nodenext

Use import './utils.js' not import './utils'. Or switch to moduleResolution: "bundler".

"types" condition after "import" in exports

Always put "types" first in package.json exports conditions.

Using module: "commonjs" with type: "module"

If package.json has "type": "module", use "module": "nodenext" or "module": "ESNext" in tsconfig.

esModuleInterop with nodenext

esModuleInterop is not needed with nodenext resolution - it's only for CJS interop in older modes.

Frequently Asked Questions