skip to content

In tsconfig.json, what is the difference between the `esModuleInterop` and `allowSyntheticDefaultImports` compiler options?

level: middleimportance: should knowfreq 55%

answer

  1. one flag is checking-only
  2. the other also touches emit
  3. one implies the other, not both ways
  4. only relevant when emitting CommonJS
  5. it forbids something too, not just permits

basics

~20 s

allowSyntheticDefaultImports is type-checking only: it lets you write a default import from a module that declares no default export. esModuleInterop additionally changes CommonJS emit to wrap the required value, and it turns allowSyntheticDefaultImports on for you.

solid answer

~50 s

They address the same friction at two different layers. `allowSyntheticDefaultImports` is purely a checking relaxation: it lets `import x from 'cjs-pkg'` type-check even though the module's declarations expose no `default` export, on the assumption that *something else* — a bundler, or the runtime — will supply one. It changes no output at all. `esModuleInterop` does that too (it implies `allowSyntheticDefaultImports`) and additionally changes what the compiler emits when it emits CommonJS: default and namespace imports go through small emitted helpers so the imported value behaves the way the ESM specification says it should. It also tightens one thing — with interop on, a namespace import is a namespace object, so calling or `new`-ing it is an error, and you use a default import or `import x = require('x')` instead. In TypeScript 5.x, `esModuleInterop` defaults to on when `module` is `node16`, `nodenext` or `preserve`, and off otherwise.

code

json · 6 lines
json
{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true
  }
}

go deeper

for a junior

Know that these options exist so you can write a default import from an older CommonJS package, and that one of them also changes the compiled output while the other does not.

for a middle

Be able to state cleanly that allowSyntheticDefaultImports is checking-only, that esModuleInterop implies it and additionally alters CommonJS emit, and that the emit half is irrelevant when output is ESM.

for a senior

Show migration judgment: name the namespace-import breakage the flag introduces, and explain how you would choose between the two settings based on whether tsc or a bundler produces what actually runs.

for a principal

Own the consistency argument — a repo where the checker's assumptions and the build tool's real behaviour diverge produces green builds that fail at runtime, so the interop settings belong to a shared base config rather than to each package's taste.

## The friction both flags exist for ES modules and CommonJS disagree about what "the module's value" is. A CommonJS module has one mutable `module.exports` value; an ES module has a set of named bindings, one of which may be called `default`. When a TypeScript file written in ES module syntax consumes a CommonJS package, someone has to decide what `import x from 'pkg'` means. Both flags are answers to that, but at different layers of the compiler: one at the type level, one at the emit level. ## allowSyntheticDefaultImports — checking only The declarations for an old-style package typically describe it as an export-assignment (`export = Thing`) with no `default` member at all. Without this flag, writing `import thing from 'pkg'` is an error: there is no default export to import. Turning `allowSyntheticDefaultImports` on tells the checker to *pretend* a default export exists, bound to the module's export-assigned value. That is the entire effect. The compiler emits exactly what it emitted before; no helper appears, no import is rewritten. The flag is a statement about the environment: "whatever loads this code will give me a default, so stop complaining." That statement is often true, because a bundler or a native ESM loader does synthesise a default for CommonJS. It is exactly why the flag is the right choice when `tsc` is not the thing producing the runtime output. ## esModuleInterop — checking and emit `esModuleInterop` is a superset. It implies `allowSyntheticDefaultImports`, so the checking relaxation comes along, and it changes CommonJS emit so the runtime behaviour matches the pretence. ```json { "compilerOptions": { "module": "commonjs", "esModuleInterop": true } } ``` Under this configuration, a default import no longer compiles to a bare `require()` whose result is used directly. The compiler emits a small helper that inspects the required value and produces something with a `default` property, so the ESM-shaped source and the CommonJS-shaped dependency agree. Namespace imports (`import * as ns`) get their own helper, producing an object that behaves like a real module namespace. Because the flag only affects emitted CommonJS, it is irrelevant to output that is already ESM — there is no `require` to wrap. ## The tightening nobody expects Interop is not purely permissive. Before it, TypeScript let you treat a namespace import as the module's export value, so `import * as express from 'express'; express();` worked. With `esModuleInterop` on, that is an error: a namespace import is a namespace object, and a namespace object is not callable or constructable. The two correct spellings become: ```ts import express from "express"; // default import import express = require("express"); // TypeScript's export-assignment import ``` This is the single most common breakage when a codebase turns the flag on, and being able to name it is what separates a memorised answer from an experienced one. ## Defaults, and which one to set In TypeScript 5.x, `esModuleInterop` is on by default when `module` is `node16`, `nodenext` or `preserve`, and off otherwise; `allowSyntheticDefaultImports` defaults to on whenever `esModuleInterop` is on. Practical guidance: - **`tsc` produces the CommonJS you run** → `esModuleInterop: true`. You want the emit to match the checking. - **A bundler or another transpiler produces the output, and `tsc` only checks** → `allowSyntheticDefaultImports: true` is the honest setting. It aligns the checker with what the bundler will actually do, and it makes clear that `tsc`'s emit is not what runs. - **Output is native ESM** → the emit half is moot; only the checking relaxation matters. ## What this is not Neither flag changes what a package exports, and neither performs any runtime conversion of its own beyond the emitted helper. They are compiler-surface options: one adjusts what the checker will accept, the other also adjusts what the compiler writes. If the underlying question is what a CommonJS package's shape looks like to a native ESM loader, that is a runtime-semantics question, not a tsconfig one — the flags only decide how TypeScript's own compilation lines up with it.

  • With esModuleInterop on, why does `import * as express from 'express'; express();` stop compiling?
    Because interop makes namespace imports behave the way the ESM specification defines them: `express` is a module namespace object, and a namespace object is not callable or constructable. The module's export-assigned function is reached through a default import (`import express from 'express'`) or through TypeScript's `import express = require('express')` form.
  • When would you enable allowSyntheticDefaultImports but leave esModuleInterop off?
    When `tsc` is only type checking and something else produces the runtime output — a bundler, or a transpile-only pipeline. The bundler already synthesises defaults for CommonJS dependencies, so you want the checker to accept the same code, but the emit half of esModuleInterop would apply to output nobody runs. It is also moot when the emit is native ESM.

saying these in an interview costs you the question

  • Says the two options are just aliases for each other
  • Claims allowSyntheticDefaultImports changes the emitted code
  • Thinks esModuleInterop affects ESM output as well as CommonJS
  • Believes interop only relaxes rules and never breaks code
  • Assumes the flags change what the dependency itself exports

context