18.5 esModuleInterop and allowSyntheticDefaultImports
Alright, let’s talk about two of the most misunderstood entries in your tsconfig.json: esModuleInterop and allowSyntheticDefaultImports. If you’ve ever been greeted by the infamous error Module '"...module-name..."' has no default export, you’ve stumbled into the exact problem these options are designed to solve. They exist to paper over a fundamental crack in the JavaScript ecosystem: the Great Module Schism between CommonJS (CJS) and ES Modules (ESM).
The core of the issue is that CJS modules require and export things differently than ESM modules import and export them. A CJS module can always do module.exports = someFunction, resulting in a single “default” export. In the ESM world, that’s written as export default someFunction. But what if you import a CJS module that exports a single function into an ESM file? What do you get?
The original TypeScript behavior, which you get with esModuleInterop: false, was brutally strict and, frankly, a bit daft. It treated that CJS module as if it were an ES module with no default export. To get that function, you’d have to use a namespace import (import * as namespace from 'module') and then access it as namespace.default. This is awkward, unintuitive, and breaks how pretty much every bundler (like Webpack) actually works at runtime. It was technical “accuracy” at the expense of practical usability.
What esModuleInterop Actually Does
Setting esModuleInterop: true (which automatically enables allowSyntheticDefaultImports) makes TypeScript get out of its own way and act like the rest of the JavaScript world. It generates a helper function at compile time that performs a clever bit of runtime gymnastics. Let’s look at the output.
You write this clean, intuitive ESM-style import:
// input.ts
import express from 'express';
With esModuleInterop: false, TypeScript would naively generate:
// output.js (bad, old way)
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express = require("express");
…which would crash at runtime because require('express') returns an object with a .default property that doesn’t exist.
With esModuleInterop: true, it generates sane, interoperable code:
// output.js (good, new way)
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
The magic is in __importDefault. It checks if the required module is an ES module (if it has the __esModule property). If it is, it just returns it. But if it’s a CommonJS module, it shoves the entire module object into a property named default. This creates the “synthetic” default export you were asking for in your code. Now, express_1.default refers to the top-level export from the express CommonJS module. It’s a lie, but it’s a useful and interoperable lie.
allowSyntheticDefaultImports: The TypeScript Sidekick
This option is often enabled automatically by esModuleInterop, but it’s worth understanding its separate role. While esModuleInterop changes the emitted JavaScript, allowSyntheticDefaultImports only changes TypeScript’s type checking. It lets you write the syntax import mod from '...' for a module that doesn’t technically have a default export in its type definitions.
When you import * as React from 'react', the type definitions for React don’t declare a default export. But your bundler (e.g., Webpack) will, at runtime, helpfully create one for the CJS module. allowSyntheticDefaultImports: true tells the type checker, “Yeah, I know the types say there’s no default, but trust me, there will be one at runtime. Don’t yell at me for this.” It suppresses the type error, letting you use the cleaner syntax.
The Golden Rule and Best Practices
Here’s the deal: You should always set esModuleInterop: true. There is virtually no good reason to keep it disabled in a new project. It makes your code work the way you expect it to and aligns TypeScript’s emit behavior with common bundlers and the Node.js runtime itself.
The one edge case to be aware of is if you’re writing a library that might be consumed by other TypeScript projects with esModuleInterop: false. However, this is becoming rarer by the day. The modern JavaScript ecosystem has settled on the interoperability pattern that esModuleInterop provides. If you’re using a modern version of TypeScript (4.0+), just turn it on and never think about it again. Your imports will work, your code will be cleaner, and your brilliant friend (me) will be proud of you for not clinging to the old, broken ways.