CodeFixesHub
    programming tutorial

    Configuring esModuleInterop and allowSyntheticDefaultImports: A Practical Guide for Intermediate TypeScript Developers

    Master TypeScript interop: configure esModuleInterop and allowSyntheticDefaultImports to fix import issues, avoid runtime errors, and optimize builds. Read now.

    article details

    Quick Overview

    TypeScript
    Category
    Aug 21
    Published
    20
    Min Read
    2K
    Words
    article summary

    Master TypeScript interop: configure esModuleInterop and allowSyntheticDefaultImports to fix import issues, avoid runtime errors, and optimize builds. Read now.

    Configuring esModuleInterop and allowSyntheticDefaultImports: A Practical Guide for Intermediate TypeScript Developers

    Introduction

    For intermediate TypeScript developers working in mixed-module ecosystems—CommonJS, ES modules, bundlers like Webpack or Rollup, or transpilers like Babel—import/export interop can be a persistent source of confusion and subtle runtime bugs. Two compiler options often mentioned as a cure-all are esModuleInterop and allowSyntheticDefaultImports. But what do they actually do, when should you flip them on or off, and how do they interact with tooling and declaration files?

    In this guide you'll learn the technical differences between the flags, how they affect emitted JavaScript and type checking, practical examples showing how imports behave under different settings, how to configure tsconfig.json correctly for various project types (apps, libraries, monorepos), and migration strategies when encountering TypeError: X is not a function or X.default is undefined at runtime.

    We cover code samples, step-by-step troubleshooting, real-world recommendations for library authors and consumers, and advanced tips for working with Babel or ts-node. By the end you should be able to reason about interop and make pragmatic, consistent choices that reduce friction between TypeScript and the broader JavaScript ecosystem.

    Background & Context

    TypeScript predates widespread ES module usage in Node.js and had to interoperate with CommonJS modules (module.exports / exports). Two compiler options help with this:

    • allowSyntheticDefaultImports: permits default import syntax (import React from "react") in the type system even when the module’s shape has no real default export at runtime. This is a type-checker convenience.
    • esModuleInterop: goes further by emitting an extra helper (or using the native __importDefault behavior) so that default imports in emitted JavaScript actually resolve at runtime the way you expect when importing CommonJS packages.

    Understanding the distinction—type-level allowance vs runtime-emitting transformation—is crucial. The flags influence emitted helpers, declaration files, and the compatibility surface for other tooling such as Babel, bundlers, and ts-node. Choosing them correctly reduces runtime surprises and improves developer ergonomics.

    Key Takeaways

    • allowSyntheticDefaultImports affects only the type checker; it allows default import syntax for CommonJS-like modules.
    • esModuleInterop affects emitted JS by adding interop helpers so default imports behave as expected at runtime.
    • Prefer esModuleInterop: true for application code to avoid runtime errors, but be cautious for library authors who publish declaration files.
    • Understand how bundlers and transpilers (Babel, ts-loader) interact with these flags to avoid duplicate helpers.
    • When in doubt, use import * as or named imports when consuming CommonJS packages without changing compiler options.

    Prerequisites & Setup

    You should be comfortable with TypeScript, tsconfig.json, and the basics of ES modules vs CommonJS. Have Node.js (14+ recommended), TypeScript (4.x+), and a code editor. For following along, create a sample project directory and initialize:

    • node >= 14
    • npm or yarn
    • typescript installed locally (npm i -D typescript)

    Create a tsconfig.json in the root to experiment. You’ll toggle the two options and observe emitted JS. This guide includes commands and code you can run locally.

    Main Tutorial Sections

    1) What exactly do the two flags do?

    • allowSyntheticDefaultImports: a type-level permission. It allows you to write import default-style syntax from modules that don't declare a default export in their .d.ts files. Example: import fs from "fs" compiles type-check-wise even if node's fs is CommonJS. It does not change emit behavior.

    • esModuleInterop: emits helper code so imports like import express from "express" work at runtime against CommonJS modules. It synthesizes a default property on the imported module value using an __importDefault helper for CommonJS. It also enables allowSyntheticDefaultImports implicitly.

    Code example (tsconfig snippet):

    json
    {
      "compilerOptions": {
        "module": "commonjs",
        "target": "es2019",
        "allowSyntheticDefaultImports": true,
        "esModuleInterop": true
      }
    }

    With esModuleInterop enabled, TypeScript emits something akin to:

    js
    var express_1 = __importDefault(require("express"));
    var app = express_1.default();

    Without it, a default import may compile to using the CommonJS value directly and cause runtime surprises.

    2) Quick reproducible examples: lodash and fs

    Create index.ts and try different settings.

    index.ts:

    ts
    import lodash from "lodash";
    console.log(typeof lodash);

    Scenario A (esModuleInterop: false, allowSyntheticDefaultImports: false): TypeScript will error (no default export). If you force it via any cast or downlevel, runtime import may give you the library module object, not its default.

    Scenario B (allowSyntheticDefaultImports: true, esModuleInterop: false): The code passes type-checks but the emitted JS will do const lodash_1 = require("lodash"); and lodash_1 will be the CommonJS object; lodash_1.default may not exist. If used incorrectly, runtime errors appear.

    Scenario C (esModuleInterop: true): Both type-checking and emitted JS are aligned; lodash is loaded in a way that lodash.default is normalized and you can use the import lodash from "lodash" syntax safely.

    3) Emitted helper explanation (__importDefault and __importStar)

    When esModuleInterop is true, TypeScript emits helpers such as __importDefault and __importStar. These helpers check if a required module has an __esModule flag and, if not, wrap it with a { default: module } shape so import default is consistent.

    Example helper conceptually:

    js
    function __importDefault(mod) {
      return (mod && mod.__esModule) ? mod : { default: mod };
    }

    This interop layer is why the runtime behavior changes. If a bundler or runtime already adds similar interop (Babel or Webpack), you may get duplicated behavior if helpers are emitted twice—so be mindful of your toolchain.

    4) Practical guidance: Applications vs Libraries

    • Applications (apps): Enable esModuleInterop: true. This reduces friction importing many CommonJS-first packages (lodash, express, chalk). It gives the most ergonomic authoring experience.

    • Libraries (that publish .d.ts): Be conservative. If you enable esModuleInterop for your code but the published .d.ts files assume default exports that don't exist at runtime, consumers can get surprised. For library authors, either publish both types and runtime behavior consistently or use named exports and explicit default exports to avoid confusion.

    When you build a library, validate the emitted declaration files and test consumption via a sample project.

    Also consider module type in package.json ("type": "module") when publishing ESM - this affects how Node resolves imports.

    5) Module resolution combinations: module, moduleResolution, and transpilers

    The tsconfig options module and moduleResolution interact with interop. For example, using "module": "esnext" and building with a bundler that understands ESM often reduces interop headaches. But if you target CommonJS for Node, esModuleInterop helps.

    When using Babel to transpile TypeScript, Babel has its own interop helpers (often controlled via @babel/plugin-transform-modules-commonjs). If both Babel and TypeScript emit interop helpers, you may end up with redundant wrappers. Recommended approaches:

    • Let Babel handle downleveling and set tsconfig emit to preserve imports ("module": "esnext") and use Babel’s interop if needed.
    • Or let TypeScript emit and configure Babel not to rewrap.

    If using ts-node for development, ensure the ts-node and Node runtime options align with your chosen interop flags.

    6) Declaration files and compatibility

    esModuleInterop changes how TypeScript treats default imports at emit and also affects the shape of declarations in some scenarios. Example: a .d.ts that declares export = or export = module.exports may be consumed differently by users.

    If you publish types, ensure consumers can import your package both with import x from "pkg" and import * as x from "pkg" depending on their flags. Some library authors ship an index.d.ts that uses export = which requires esModuleInterop on the consumer side for default import syntax to work.

    To validate, create a consumer sandbox and test both import forms or document the expected import style.

    7) Troubleshooting common runtime errors and fixes

    Common error patterns:

    • TypeError: express_1.default is not a function — This often means you used import express from "express" but the runtime value has no default property. Fix: enable esModuleInterop or change to import * as express from "express" or const express = require("express").

    • undefined is not a function when importing CommonJS default — similar root cause. Verify whether the package marks __esModule or uses default exports.

    • Duplicate helper or double-wrapped default — happens when both Babel and TypeScript add interop wrappers. Fix by centralizing interop logic (prefer TypeScript or Babel) and aligning presets/plugins.

    Step-by-step: reproduce the error, inspect the compiled JS (look for __importDefault), try toggling esModuleInterop, and run tests in a small sandbox.

    8) Migrating an existing codebase safely

    If you have a large repository that currently uses import * as semantics or has many require() calls, change settings in stages:

    1. Turn on allowSyntheticDefaultImports only and run the type checker; fix type errors.
    2. Turn on esModuleInterop in a feature branch. Build and run tests. Fix runtime failures.
    3. Convert a few files to default import syntax where it simplifies code.
    4. Add lint rules to standardize import style across the repo.

    Use the compiler option --noEmit while you test type changes before committing any emitting behavior.

    9) When to avoid enabling esModuleInterop

    There are a few scenarios where you might avoid it:

    • If you're building a library that must support older TypeScript consumers with strict expectations about export = shapes.
    • If your toolchain already provides robust interop (and you prefer to centralize helpers in Babel), enabling it can create duplicate wrappers.
    • If you intentionally want to enforce explicit import * as usage for clarity and consistency.

    But for most application code, enabling esModuleInterop is the pragmatic choice.

    Advanced Techniques

    1. Using module aliasing and type-only imports

    Use import type for types to avoid emitting runtime imports; this pairs nicely with interop options because importing types doesn't change runtime behavior. Example:

    ts
    import type { Request, Response } from "express";
    import express from "express"; // esModuleInterop makes this ergonomic
    1. Build scripts that validate consumer scenarios

    Create an automated integration test that installs your built package into a temporary app project and tests both import pkg from and import * as pkg from forms. This helps catch mismatches between runtime and type expectations.

    1. Using utility types when wrapping modules

    When you write wrappers for third-party libraries, utility types help preserve constructor and instance shapes. For example, when creating a typed factory wrapper you might use ConstructorParameters and InstanceType to forward argument and instance types precisely.

    1. Conditional types to detect default-like shapes

    Advanced type-level detection using infer and conditional types lets you create robust wrappers that adapt to whether a module exports a default. For more on inference with function and object shapes, see our guides on using infer with functions and using infer with objects.

    Best Practices & Common Pitfalls

    Dos:

    • Prefer esModuleInterop: true for application code—it reduces developer friction.
    • Test published packages in a consumer sandbox to verify import shapes.
    • Use import type for type-only imports to avoid accidental runtime dependencies.
    • Read emitted JS when debugging import errors; look for __importDefault or __importStar usage.

    Don'ts:

    • Don’t assume changing compiler flags is a free operation—build and test thoroughly.
    • Don’t ship inconsistent declaration files; ensure your .d.ts matches runtime exports.
    • Avoid toggling both Babel and TypeScript interop helpers without accounting for duplicates.

    Troubleshooting tips:

    • Reproduce minimally: create a two-file sandbox to isolate the import issue.
    • Inspect package.json "type" field (module vs commonjs) and the runtime environment.
    • Use console.log(module) on a required package to inspect its shape.

    Real-World Applications

    • Migrating a legacy Node app that uses require() to modern TypeScript: enable esModuleInterop and gradually convert files to import syntax. This reduces boilerplate and aligns with ESM patterns.

    • Building CLI tools with mixed dependencies: many CLI libraries historically export CommonJS. esModuleInterop avoids repeated import * as workarounds and hides a lot of friction.

    • Monorepos: in a repo containing multiple packages with different module targets, standardize on a shared tsconfig and document expectations. Library packages that are intended to be consumed by other TypeScript projects should decide on an interop contract and test across consumers.

    If you work with advanced TypeScript type utilities while wrapping third-party modules, reference our guides on Parameters or advanced mapped/type utilities to keep your wrapper types precise.

    Conclusion & Next Steps

    Understanding and configuring esModuleInterop and allowSyntheticDefaultImports reduces a class of type/runtime mismatches that developers frequently encounter in mixed-module JavaScript ecosystems. For most application code, enable esModuleInterop for ergonomic default imports; for libraries, be deliberate and test across consumers.

    Next steps:

    • Experiment with a small sandbox project and toggle the flags while observing emitted JS.
    • If you author libraries, add a consumer integration test.
    • Read further on TypeScript module authoring patterns and utility types referenced in this article.

    Enhanced FAQ

    Q1: What’s the difference between allowSyntheticDefaultImports and esModuleInterop? A1: allowSyntheticDefaultImports is a type-check-only convenience that lets you write default-import syntax against modules without a declared default export. It does not change emitted JavaScript. esModuleInterop goes further: it changes the emitted JS to add interop helpers (e.g., __importDefault) that normalize CommonJS modules to an ES default shape at runtime. In short: one affects types-only, the other affects runtime and types.

    Q2: If I enable allowSyntheticDefaultImports but not esModuleInterop, will my code always run fine? A2: No. allowSyntheticDefaultImports only silences type-checker complaints. At runtime, a module imported via import foo from "pkg" may not have a .default property. This can lead to TypeError or unexpected values. If you need runtime default import semantics, enable esModuleInterop or change your imports to import * as or require().

    Q3: Should all projects set esModuleInterop: true? A3: For many application projects, yes—it's pragmatic and reduces friction. For library projects intended to be consumed by others, be more careful: enabling esModuleInterop can affect emitted declaration expectations. If you publish ESM packages or explicitly control exports, you can set esModuleInterop consistently—but always test consumers.

    Q4: How do these flags interact with Babel or other transpilers? A4: Babel has its own module transform and interop helpers. If both TypeScript and Babel emit interop helpers, you can get double wrapping. Choose a single layer of interop responsibility: either let TypeScript output ESM and let Babel handle module transforms and interop, or let TypeScript handle interop and configure Babel not to rewrap.

    Q5: What about Node's native ESM ("type": "module")? A5: If your package uses native ESM (package.json with "type": "module" or .mjs files), then default exports are first-class. Interop with CommonJS consumers still matters, and you may need to publish dual packages (CJS + ESM) or use conditional exports. esModuleInterop is relevant primarily for the TypeScript->JS emit path and how consumers import your compiled output.

    Q6: How can I debug X.default is undefined or is not a function errors? A6: Minimal reproduction: create a tiny file that requires the module and console.logs the value:

    js
    const m = require('some-pkg');
    console.log(m);

    Examine whether the object has .default, named properties, or function values. Also inspect the compiled TypeScript output for __importDefault or __importStar and try toggling esModuleInterop. Use a temporary consumer project to isolate behavior.

    Q7: Are there type-level tricks to write wrappers that adapt to either default shapes or named exports? A7: Yes—advanced conditional types and infer can detect whether a module type has a default or named exports and adapt. These patterns use utility types and inference; see our guides on using infer with functions and using infer with objects. Also consider preserving constructor/instance typing with ConstructorParameters and InstanceType when wrapping classes.

    Q8: What is the safest import style for consuming CommonJS packages without enabling esModuleInterop? A8: Use import * as pkg from 'pkg' or the classic const pkg = require('pkg'). These forms directly consume the CommonJS export shape and avoid default-wrapping issues. The downside is less ergonomic syntax for packages that would otherwise have a natural default import.

    Q9: Will esModuleInterop change my declaration (.d.ts) files? A9: It can influence how TypeScript treats module shapes and thus how generated declarations appear in edge cases (especially when using export = or export as namespace). Always inspect generated .d.ts and test a consuming project to ensure declarations match runtime expectations.

    Q10: Where can I learn more about module patterns and TypeScript design choices? A10: For a deeper look at modules vs namespaces and how to choose the right approach when designing and structuring libraries, see the guide on namespaces vs modules. For patterns involving decorators, mixins, and advanced type utilities, our other resources can help you avoid pitfalls when composing modules, e.g., Decorators in TypeScript and Implementing Mixins in TypeScript.


    If you want, I can provide a small reproducible repo that toggles the two flags and demonstrates the different emitted outputs and runtime results step-by-step. I can also tailor migration steps for a monorepo or an npm package you maintain.

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:20:05 PM
    Next sync: 60s
    Loading CodeFixesHub...