Introduction: Why esModuleInterop Is More Than Just a Checkbox in tsconfig
\n\nIf you've ever written TypeScript and imported a library like express or lodash, you've likely encountered a moment when the compiler complains about import * as express from 'express'. Or, conversely, you calmly use import express from 'express', but in the generated JavaScript code you see a strange construct module.exports.default. What kind of magic is this? The magic (or lack thereof) is the esModuleInterop flag in your tsconfig.json.
This parameter is one of the most popular questions on Stack Overflow (thousands of views!), and it's no wonder: without understanding it, it's easy to fall into the trap of module incompatibility. In this tutorial, we won't just explain the theory; we'll walk through the journey from problem to solution with live examples. Let's go!
\n\nThe Problem: Conflict of Two Module Worlds
\n\nTypeScript is a superset of JavaScript, and JavaScript historically has two main module systems:
\n- \n
- CommonJS (CJS) — the Node.js standard, uses
require()andmodule.exports. Example:const express = require('express');\n - ES Modules (ESM) — the modern standard, uses
importandexport. Example:import express from 'express';\n
The problem is that these systems handle default exports (default export) differently. In ESM, a module can have one export default, and it is imported as import name from 'module'. In CommonJS, there is no concept of a "default export" — there is only the module.exports object, which can be anything.
When TypeScript compiles your code to CommonJS (which is often needed for Node.js), it must decide: how to represent import x from 'y' as require()? Without esModuleInterop, TypeScript does this literally: it creates module.exports.default, which breaks the import of libraries written for CommonJS.
What is esModuleInterop?
\n\nesModuleInterop is a flag in tsconfig.json that enables additional compatibility between CommonJS and ES Modules. When set to true, TypeScript generates helper code that allows you to write import foo from 'bar' even for CommonJS modules that don't have a default export.
By default in TypeScript 4.x and above, this flag is true (if you use --init), but in older projects it was often turned off. Why? Because it changes the behavior of imports, and without understanding it, this leads to bugs.
How It Looks in tsconfig.json
\n{\n "compilerOptions": {\n "esModuleInterop": true,\n // other settings...\n }\n}\n\n\nEnabling this flag also automatically enables allowSyntheticDefaultImports, which allows default imports from modules without a real default export at the type level.
Step-by-Step Tutorial: From Problem to Solution
\n\nLet's break down the situation with a real example. Suppose we have a simple library in CommonJS:
\n\n// myLib.js (CommonJS)\nmodule.exports = function greet(name) {\n return `Hello, ${name}!`;\n};\n\n\nWe want to use it in a TypeScript project. Let's create a file index.ts:
// index.ts\nimport greet from './myLib';\nconsole.log(greet('World'));\n\n\nNow let's see what happens during compilation with different esModuleInterop settings.
Step 1: Without esModuleInterop (false)
\n\nLet's run the compilation with "esModuleInterop": false. TypeScript will throw an error:
Module '"myLib"' has no d