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
.jsfiles that export CommonJS vs. ESM -
Stricter
package.jsonexports field handling: Proper respect for conditional exports (node,browser,import,require) -
Improved
.mjsand.cjsextension 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:
-
Migrate to ESM imports (preferred):
// Before const express = require('express'); // After import express from 'express'; -
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'); -
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:
-
Checks the
legacypackage’spackage.jsonand sees no"type": "module" - Correctly identifies it as CommonJS
- Applies appropriate import/require rules
-
If using ESM imports, suggests
createRequireor 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.jsonandpackage.jsonare 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.