languages
June 26, 2026 · 6 min read · 0 views

TypeScript 5.9: Module Resolution Enhancements & Deprecation Warnings

TypeScript 5.9 introduces stricter module resolution rules, deprecation warnings for legacy patterns, and improved interoperability with CommonJS. Here's how to upgrade safely.

TypeScript 5.9: What’s New and Why It Matters

TypeScript 5.9, released in December 2024, brings significant improvements to module resolution, CommonJS interoperability, and developer experience. This release focuses on addressing long-standing friction points for developers working in complex monorepos, mixed CommonJS/ESM environments, and projects with intricate dependency graphs.

While not as headline-grabbing as major feature additions, these changes have a direct, practical impact on real-world projects—particularly those dealing with the ongoing CommonJS-to-ESM transition that’s plagued the JavaScript ecosystem for years.

Key Changes in TypeScript 5.9

1. Enhanced Module Resolution with --moduleResolution bundler

TypeScript 5.9 refines the bundler module resolution strategy introduced in 5.0. This mode aligns closer to how modern bundlers (Webpack, Vite, esbuild) actually resolve modules, reducing surprises when code works in development but fails at runtime.

The improvements include:

  • Better CommonJS detection: More accurate identification of .js files that export CommonJS vs. ESM
  • Stricter package.json exports field handling: Proper respect for conditional exports (node, browser, import, require)
  • Improved .mjs and .cjs extension handling: Clearer semantics for explicit module type files

2. Deprecation Warnings for Legacy Patterns

TypeScript now emits warnings (not errors) for patterns that are becoming obsolete:

  • Using require() in modules without CommonJS context
  • Ambiguous imports where the file could resolve to multiple targets
  • Relying on implicit module type detection in edge cases

These are warnings, not breaking changes, giving teams a migration path without immediate disruption.

3. Better Interoperability Helpers

New utility types help with the CommonJS/ESM boundary:

// New in 5.9: Explicit CommonJS compatibility
import { createRequire } from 'module';
const require = createRequire(import.meta.url);

// Old pattern (now shows deprecation warning in strict mode)
const pkg = require('./package.json');

// New pattern (properly typed, future-proof)
const pkg = JSON.parse(
  await readFile(new URL('./package.json', import.meta.url), 'utf-8')
);

The compiler also provides better type definitions for these CommonJS interop patterns, reducing the need for @ts-ignore comments.

Step-by-Step Upgrade Guide

Step 1: Update TypeScript

npm install --save-dev [email protected]
# or
yarn add --dev [email protected]

Update your tsconfig.json to use the latest compiler options:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2020"],
    "strict": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Step 2: Review Module Resolution Output

Run the TypeScript compiler with the new --listFilesOnly flag to see how your modules are being resolved:

tsc --listFilesOnly 2>&1 | head -50

This shows exactly which files are being resolved and in what order, helping identify ambiguous patterns.

Step 3: Audit Require Patterns

Search your codebase for require() calls in ESM contexts:

grep -r "require(" src/ --include="*.ts" --include="*.tsx"

For any found, decide whether to:

  1. Migrate to ESM imports (preferred):

    // Before
    const express = require('express');
    
    // After
    import express from 'express';
  2. Use createRequire for necessary CommonJS modules (when no ESM export exists):

    import { createRequire } from 'module';
    import { fileURLToPath } from 'url';
    
    const require = createRequire(import.meta.url);
    const legacyModule = require('some-cjs-only-package');
  3. Use dynamic imports (for conditional loading):

    const module = await import('some-package');

Step 4: Fix Package.json Exports

If you’re publishing a library, ensure your package.json exports field is properly configured:

{
  "name": "my-library",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./package.json": "./package.json"
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts"
}

This ensures TypeScript (and bundlers) route consumers to the correct entry point based on how they import your code.

Step 5: Run Full Type Check

tsc --noEmit

This performs a full type check without emitting code, making it safe to run on CI/CD pipelines. Address any new warnings at this stage.

Real-World Example: Monorepo Migration

Consider a monorepo with mixed CommonJS and ESM packages:

monorepo/
├── packages/
│   ├── core/ (ESM)
│   │   ├── src/
│   │   │   └── index.ts
│   │   └── package.json { "type": "module" }
│   ├── legacy/ (CommonJS)
│   │   ├── src/
│   │   │   └── index.js
│   │   └── package.json (no "type" field)
│   └── integration/
│       ├── src/
│       │   └── index.ts
│       └── package.json

Before TypeScript 5.9, importing from legacy into integration could lead to subtle issues:

// integration/src/index.ts
import { someFunction } from '@monorepo/legacy'; // May not resolve correctly

With TypeScript 5.9, the compiler:

  1. Checks the legacy package’s package.json and sees no "type": "module"
  2. Correctly identifies it as CommonJS
  3. Applies appropriate import/require rules
  4. If using ESM imports, suggests createRequire or dynamic imports

Common Pitfalls and Solutions

Pitfall 1: Circular Dependencies in Mixed Modules

Circular dependencies are more easily detected in 5.9 due to stricter resolution:

// moduleA.ts (ESM)
import { B } from './moduleB';
export const A = () => B();

// moduleB.ts (ESM, via CommonJS wrapper)
const { A } = require('./moduleA'); // Circular!
export const B = () => A();

Solution: Restructure to break the cycle:

// shared.ts
export const sharedLogic = () => {};

// moduleA.ts
import { sharedLogic } from './shared';
export const A = sharedLogic;

// moduleB.ts
import { sharedLogic } from './shared';
export const B = sharedLogic;

Pitfall 2: Conditional Imports Breaking Resolution

If you have platform-specific files, ensure package.json exports are explicit:

// Before (ambiguous)
import { platform } from './utils'; // Could be utils.node.ts or utils.browser.ts

// After (explicit in package.json)
{
  "exports": {
    ".\utils": {
      "node": "./dist/utils.node.js",
      "browser": "./dist/utils.browser.js"
    }
  }
}

Pitfall 3: Incorrect Type-Only Imports in CommonJS

Type-only imports behave differently in CommonJS contexts:

// This is now flagged as ambiguous in 5.9
import type { SomeType } from 'cjs-package';

// Correct approach for CommonJS packages
import type { SomeType } from 'cjs-package'; // Works if types are exported
// Or manually specify types via @types/cjs-package

Why It Matters: Real Impact on Your Projects

1. Fewer Runtime Surprises

Code that type-checks will behave consistently at runtime. No more “works in development, fails in production” issues caused by module resolution differences.

2. Clearer Migration Path

Deprecation warnings guide teams toward modern patterns without forcing immediate changes. This is especially valuable for large codebases where instant ESM adoption isn’t feasible.

3. Better Tooling Integration

Improved module resolution means better IDE support, faster builds, and fewer mysterious circular dependency errors in bundlers.

4. Library Compatibility

If you maintain a library, 5.9 ensures your exports field is properly respected, preventing version conflicts and import errors in downstream projects.

Testing Your Changes

Use Kloubot’s tools to validate your configuration and module patterns:

  • JSON Formatter — validate your tsconfig.json and package.json are valid
  • Diff Checker — compare before/after tsconfig to spot unintended changes

For CI/CD pipelines, create a simple test:

#!/bin/bash
set -e

echo "Type checking..."
npx tsc --noEmit

echo "Building..."
npm run build

echo "Testing module resolution..."
node -e "import('./dist/index.mjs').then(() => console.log('ESM works')).catch(e => { console.error(e); process.exit(1); })"
node --input-type=module -e "const m = require('./dist/index.cjs'); console.log('CommonJS works');"

echo "All checks passed!"

Migration Timeline

  • Now (December 2024): Update to TypeScript 5.9, review deprecation warnings
  • Q1 2025: Audit and fix module resolution issues in your codebase
  • Q2 2025: Plan ESM migration for libraries you maintain
  • Q3 2025+: Deprecation warnings may become errors in TypeScript 6.0

Conclusion

TypeScript 5.9 isn’t a flashy release with exciting new syntax, but it’s a practical improvement for developers working in the real world. By tightening module resolution and providing clear deprecation paths, it helps teams move confidently toward a fully ESM future while accommodating the CommonJS ecosystem we’re still transitioning away from.

Start by updating your project, running tsc --noEmit, and addressing any new warnings. The time invested now will pay dividends in reduced runtime surprises and clearer code organization.

Related Kloubot Tools

This post was generated with AI assistance and reviewed for accuracy.