Question
I was looking at someone else's tsconfig.json file and noticed these options:
{
"compilerOptions": {
"moduleResolution": "node",
"target": "es6",
"module": "commonjs",
"lib": ["esnext"],
"strict": true,
"sourceMap": true,
"declaration": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declarationDir": "./dist",
"outDir": "./dist",
"typeRoots": ["node_modules/@types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
My main question is: what do these two settings mean?
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
I understand that they are related in some way to:
"module": "commonjs"
Can someone explain them in simple, practical language?
The official documentation for allowSyntheticDefaultImports says:
Allow default imports from modules with no default export. This does not affect code emit, just typechecking.
What exactly does that mean? If a module does not have an export default, when would a default import even make sense?
I also found this description of esModuleInterop difficult to understand:
Emit
__importStarand__importDefaulthelpers for runtime Babel ecosystem compatibility and enable--allowSyntheticDefaultImportsfor type system compatibility.
I would like a beginner-friendly explanation of what these options actually do, why they exist, and when to use them.
Short Answer
By the end of this page, you will understand why TypeScript sometimes struggles with imports from CommonJS modules, what allowSyntheticDefaultImports changes in type checking, what esModuleInterop changes in emitted JavaScript, and why these options are commonly enabled together in real projects.
Concept
TypeScript sits between your source code and the JavaScript module system you are targeting. The confusion here comes from the fact that there are two different module styles in the JavaScript ecosystem:
- ES Modules (ESM)
- Use
exportandimport - Example:
- Use
export default function greet() {
console.log("Hello")
}
import greet from "./greet"
- CommonJS (CJS)
- Use
module.exportsandrequire - Example:
- Use
module.exports = function greet() {
console.log("Hello")
}
const greet = require("./greet")
These systems are similar, but they are not identical.
Why this causes problems
Many npm packages were originally written using CommonJS. But in TypeScript, developers often want to write modern-looking imports like this:
import express from "express"
The problem is that many CommonJS modules do not actually have a real ES module default export. So TypeScript has to decide:
- Should this import be allowed?
- If it is allowed, how should the emitted JavaScript behave at runtime?
That is where these two options come in.
allowSyntheticDefaultImports
This option affects type checking only.
When it is true, TypeScript lets you write:
import express from "express"
...even if the module does not truly define export default.
The word synthetic means "pretend" or "artificial". TypeScript is basically saying:
- "I know this module may not have a real default export."
- "But I will still allow you to write default-import syntax as if it does."
Important: this option does not change the generated JavaScript. It only changes whether TypeScript reports a type error.
esModuleInterop
This option goes further.
When esModuleInterop is true, TypeScript changes the emitted JavaScript so that imports from CommonJS modules behave more like developers expect when using ES module syntax.
It also automatically enables:
allowSyntheticDefaultImports
So:
allowSyntheticDefaultImports= type system convenienceesModuleInterop= runtime compatibility + type system convenience
Why this matters in real code
Without these options, importing many popular CommonJS packages can feel awkward:
import * as express from "express"
With esModuleInterop: true, you can usually write the more familiar version:
import express from "express"
This makes TypeScript code easier to read and more consistent with Babel-based projects and modern JavaScript examples.
What "does not affect code emit, just typechecking" means
TypeScript does two major jobs:
- Type checking: decides whether your code is valid
- Code emit: generates JavaScript output
If allowSyntheticDefaultImports is enabled, TypeScript may accept this code:
import fs from "fs"
But if your emitted JavaScript still does not correctly handle that default import at runtime, the code can fail.
So the compiler is saying:
- "I will allow this syntax during checking"
- "But I am not changing the output to make it work"
That is why esModuleInterop is often the more useful option in practice.
Mental Model
Think of ES Modules and CommonJS as two different plug shapes.
- ES Modules are one plug type
- CommonJS modules are another plug type
Your TypeScript code wants to use a modern ES module-style plug:
import thing from "package"
But the actual package might be exporting itself in a CommonJS shape.
allowSyntheticDefaultImports
This is like saying:
"It's okay to pretend this plug fits while we are checking the blueprint."
It makes the code look acceptable to TypeScript.
esModuleInterop
This is like adding a real adapter so the plug actually fits when the code runs.
So a simple way to remember it is:
allowSyntheticDefaultImports= "let me write it"esModuleInterop= "make it work more reliably"
That is why esModuleInterop is usually the better setting when you are importing CommonJS packages using default-import syntax.
Syntax and Examples
Core idea
Here is a CommonJS module:
// math.js
module.exports = function add(a, b) {
return a + b
}
CommonJS usage
const add = require("./math")
console.log(add(2, 3))
ES module-style usage in TypeScript
import add from "./math"
console.log(add(2, 3))
Whether that TypeScript import is allowed and works correctly depends on your compiler settings.
Example 1: allowSyntheticDefaultImports
{
"compilerOptions": {
"module": "commonjs",
"allowSyntheticDefaultImports": true
}
}
This tells TypeScript:
- allow
import add from "./math"during type checking - do not change emitted JavaScript just because of this option
So this may type-check, but runtime behavior still depends on your emitted code and module shape.
Example 2: esModuleInterop
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true
}
}
This tells TypeScript:
- allow default imports from CommonJS-like modules
- emit helper code so default imports are handled more safely
Now this is commonly fine:
import express from "express"
const app = express()
Named import vs default import
Suppose a module exports an object:
// utils.js
module.exports = {
log(message) {
console.log(message)
}
}
A default import means:
import utils from "./utils"
utils.log("Hello")
A namespace import means:
import * as utils from "./utils"
utils.log("Hello")
In practice, esModuleInterop makes default imports from CommonJS modules behave more consistently with developer expectations.
Typical tsconfig.json
{
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"strict": true,
"esModuleInterop": true
}
}
In many Node.js TypeScript projects, this is a common and practical choice.
Step by Step Execution
Consider this TypeScript code:
import path from "path"
const result = path.join("src", "index.ts")
console.log(result)
Assume module is set to commonjs.
What happens conceptually with esModuleInterop: true
Step 1: You write a default import
import path from "path"
You are saying: "Give me the module's default export and call it path."
Step 2: TypeScript checks the module
The path module is not written as a normal ES module with export default.
Without compatibility settings, TypeScript may complain because the module shape does not match a true default export.
Step 3: esModuleInterop allows compatibility
TypeScript treats the CommonJS module in a way that supports this style of import.
Step 4: JavaScript is emitted with interop handling
The emitted JavaScript will use helper logic so the imported value behaves like a default export when needed.
Conceptually, it is doing something similar to this:
const path_1 = __importDefault(require("path"))
console.log(path_1.default.join("src", "index.ts"))
You do not need to write this helper yourself. TypeScript generates it.
Without esModuleInterop
You may need to write imports in a different style, such as:
import * as path from "path"
Then use it like this:
const result = path.join("src", "index.ts")
Key takeaway
The important step-by-step idea is:
- You write ES module import syntax
- The real package may be CommonJS
- TypeScript decides whether that syntax is allowed
- TypeScript may also add helper code so the runtime behavior matches the syntax better
Real World Use Cases
Importing popular npm packages
Many packages in the Node.js ecosystem were historically published as CommonJS modules.
Examples developers often import with esModuleInterop enabled:
import express from "express"
import cors from "cors"
import dotenv from "dotenv"
This style is shorter and more familiar than older interop patterns.
Working in Node.js backends
In backend TypeScript apps, especially older CommonJS-based projects, these settings make it easier to use packages from npm without constantly switching import styles.
Migrating JavaScript projects to TypeScript
If a codebase already uses Babel or default-import syntax everywhere, enabling esModuleInterop makes migration smoother.
Using type definitions from DefinitelyTyped
Some type definition packages were written with compatibility assumptions in mind. These flags help TypeScript work more smoothly with those packages.
Consistent developer experience
Teams often choose esModuleInterop: true so developers can use one predictable import style across many dependencies, even if those dependencies were authored in different module formats.
Real Codebase Usage
In real projects, developers usually do not think about these flags every day. They just want imports to be clear and reliable.
Common pattern: enable esModuleInterop
A very common setup is:
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"strict": true
}
}
Because esModuleInterop also enables allowSyntheticDefaultImports, teams usually set only the first one.
Why teams prefer it
- cleaner imports
- less confusion when using npm packages
- better compatibility with examples from documentation and tutorials
- easier migration from Babel-based JavaScript projects
In library code vs application code
- Application code often enables
esModuleInteropfor convenience. - Library code may be more careful, especially if the library must support multiple module systems and produce clean public typings.
Common pattern: import the whole module when appropriate
Even with interop enabled, developers sometimes still use namespace imports when they want to emphasize they are working with a module object:
import * as fs from "fs"
But for many CommonJS packages, this is more common with interop enabled:
import fs from "fs"
Configuration guidance
- If your project uses CommonJS output and imports npm packages with default syntax,
esModuleInterop: trueis often the practical choice. - If you disable it, be ready to use stricter and sometimes less ergonomic import styles.
- Avoid setting both flags manually unless you specifically want
allowSyntheticDefaultImportswithout runtime interop behavior.
Common Mistakes
1. Thinking both options do the same thing
They are related, but not identical.
allowSyntheticDefaultImportsaffects type checking onlyesModuleInteropaffects runtime emit behavior and also enablesallowSyntheticDefaultImports
2. Assuming allowSyntheticDefaultImports makes runtime code work
Broken expectation:
import somePackage from "some-package"
If only allowSyntheticDefaultImports is enabled, TypeScript may accept the code, but the emitted JavaScript may still not behave correctly at runtime.
How to avoid it:
- Prefer
esModuleInteropwhen using default imports from CommonJS modules
3. Confusing default export with "main thing exported"
In CommonJS, this is valid:
module.exports = function () {
console.log("Hi")
}
But that is not literally the same syntax as ES module export default.
They are similar in intent, but not identical in module semantics.
4. Mixing import styles inconsistently
Example:
import express from "express"
import * as path from "path"
import fs from "fs"
This may work, but beginners often do not know why one style was used instead of another.
How to avoid it:
- Pick a project convention
- If using CommonJS output,
esModuleInterop: trueusually makes default imports more consistent
5. Forgetting that docs may assume Babel behavior
Some JavaScript examples online rely on Babel or bundlers that already handle interop differently.
That means code copied into a TypeScript project may fail unless your TypeScript config supports the same style.
6. Misreading the word "synthetic"
synthetic default import does not mean the module really has a default export.
It means TypeScript is allowing you to treat it as if it does for convenience.
Comparisons
| Option / Concept | What it changes | Affects emitted JavaScript? | Typical use |
|---|---|---|---|
allowSyntheticDefaultImports | Lets TypeScript accept default imports from modules without a real default export | No | Type-checking convenience |
esModuleInterop | Adds compatibility helpers and enables synthetic default imports | Yes | Practical interoperability with CommonJS |
import * as x from "pkg" | Imports the module namespace/object | No special interop needed | Safer when you want the full module object |
import x from "pkg" | Imports the default export | Depends on module shape and config | Cleaner syntax when interop is enabled |
allowSyntheticDefaultImports vs esModuleInterop
- Use
allowSyntheticDefaultImportsif you only want the type checker to allow default import syntax. - Use
esModuleInteropif you want TypeScript to also emit compatibility code.
In most real TypeScript app projects, esModuleInterop is the more useful choice.
CommonJS vs ES Modules
| Feature | CommonJS | ES Modules |
|---|---|---|
| Export style | module.exports / exports.x | export / export default |
| Import style | require() | import |
| Originally common in | Node.js | Modern JavaScript standard |
| Default export concept | Not identical to ESM default export | Built in |
Practical rule
If you are targeting or consuming CommonJS modules and want modern import syntax, esModuleInterop often removes friction.
Cheat Sheet
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true
}
}
Quick rules
-
allowSyntheticDefaultImports: true- allows default-import syntax during type checking
- does not change emitted JavaScript
-
esModuleInterop: true- changes emitted JavaScript for better CommonJS interop
- automatically enables
allowSyntheticDefaultImports
Easy memory trick
allowSyntheticDefaultImports= allow the syntaxesModuleInterop= allow the syntax and help it work at runtime
Common examples
With interop
import express from "express"
Without interop, older style may be needed
import * as express from "express"
When to use esModuleInterop
- your project outputs CommonJS
- you import npm packages with default import syntax
- you want compatibility similar to Babel-based projects
Important edge case
A CommonJS module may export one value with:
module.exports = something
That does not mean it is a true ES module default export, even if it feels similar.
Practical recommendation
For many TypeScript Node.js applications:
{
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"strict": true,
"esModuleInterop": true
}
}
FAQ
What does esModuleInterop do in TypeScript?
It helps TypeScript work more smoothly with CommonJS modules when you use ES module import syntax such as import x from "pkg".
Does esModuleInterop automatically enable allowSyntheticDefaultImports?
Yes. Enabling esModuleInterop also enables allowSyntheticDefaultImports.
What does "does not affect code emit, just typechecking" mean?
It means TypeScript will allow the import syntax during compile-time checks, but it will not change the generated JavaScript to make that syntax work at runtime.
Should I enable both options?
Usually you only need esModuleInterop: true, because it already includes the behavior of allowSyntheticDefaultImports.
Why do some examples use import * as express from "express"?
That style was commonly used when interop support was stricter or disabled. With esModuleInterop, many developers now use import express from "express" instead.
Is CommonJS the same as export default?
No. module.exports = value is not exactly the same thing as an ES module export default value, even though they can feel similar in everyday use.
Can I use these options in tsconfig.json instead of the command line?
Yes. They are normal TypeScript compiler options and are commonly placed inside compilerOptions in tsconfig.json.
Mini Project
Description
Build a small TypeScript project that imports a CommonJS-style module in two different ways so you can see why esModuleInterop exists. This project demonstrates the difference between type-checking convenience and runtime compatibility.
Goal
Create a TypeScript app that imports a CommonJS export using default-import syntax and understand why esModuleInterop makes that pattern easier and safer.
Requirements
- Create a CommonJS-style module that exports one function with
module.exports. - Import that module into a TypeScript file using default-import syntax.
- Configure TypeScript with
module: "commonjs"andesModuleInterop: true. - Run the compiled code and verify that the imported function works.
- Compare the setup mentally with what would happen if interop were disabled.
Keep learning
Related questions
@Directive vs @Component in Angular: Differences, Use Cases, and When to Use Each
Learn the difference between @Directive and @Component in Angular, including use cases, examples, and when to choose each.
Accessing Input Value from EventTarget in TypeScript
Learn why EventTarget has no value property in TypeScript and safely read values from HTML input events in Angular applications.
Angular (change) vs (ngModelChange): What’s the Difference?
Learn the difference between Angular (change) and (ngModelChange), when each fires, and which one to use in forms and inputs.