Question

Understanding esModuleInterop and allowSyntheticDefaultImports in TypeScript

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 __importStar and __importDefault helpers for runtime Babel ecosystem compatibility and enable --allowSyntheticDefaultImports for 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 export and import
    • Example:
export default function greet() {
  console.log("Hello")
}
import greet from "./greet"
  • CommonJS (CJS)
    • Use module.exports and require
    • Example:
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 convenience
  • esModuleInterop = 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:

  1. Type checking: decides whether your code is valid
  2. 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.

Take Quiz

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:

  1. You write ES module import syntax
  2. The real package may be CommonJS
  3. TypeScript decides whether that syntax is allowed
  4. 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.

Take Quiz

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 esModuleInterop for 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: true is 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 allowSyntheticDefaultImports without runtime interop behavior.

Common Mistakes

1. Thinking both options do the same thing

They are related, but not identical.

  • allowSyntheticDefaultImports affects type checking only
  • esModuleInterop affects runtime emit behavior and also enables allowSyntheticDefaultImports

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 esModuleInterop when 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: true usually 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 / ConceptWhat it changesAffects emitted JavaScript?Typical use
allowSyntheticDefaultImportsLets TypeScript accept default imports from modules without a real default exportNoType-checking convenience
esModuleInteropAdds compatibility helpers and enables synthetic default importsYesPractical interoperability with CommonJS
import * as x from "pkg"Imports the module namespace/objectNo special interop neededSafer when you want the full module object
import x from "pkg"Imports the default exportDepends on module shape and configCleaner syntax when interop is enabled

allowSyntheticDefaultImports vs esModuleInterop

  • Use allowSyntheticDefaultImports if you only want the type checker to allow default import syntax.
  • Use esModuleInterop if you want TypeScript to also emit compatibility code.

In most real TypeScript app projects, esModuleInterop is the more useful choice.

CommonJS vs ES Modules

FeatureCommonJSES Modules
Export stylemodule.exports / exports.xexport / export default
Import stylerequire()import
Originally common inNode.jsModern JavaScript standard
Default export conceptNot identical to ESM default exportBuilt in

Practical rule

If you are targeting or consuming CommonJS modules and want modern import syntax, esModuleInterop often removes friction.

Take Quiz

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 syntax
  • esModuleInterop = 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.

Take Quiz

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

  1. Create a CommonJS-style module that exports one function with module.exports.
  2. Import that module into a TypeScript file using default-import syntax.
  3. Configure TypeScript with module: "commonjs" and esModuleInterop: true.
  4. Run the compiled code and verify that the imported function works.
  5. Compare the setup mentally with what would happen if interop were disabled.
Take Quiz

Keep learning

Related questions

View more