If you’ve ever opened a tsconfig.json file, tweaked a setting, and prayed your build didn’t break, you’re not alone. TypeScript’s compiler settings can feel like a labyrinth of overlapping keywords. Four options in particular cause endless head-scratching:
targetmodulemoduleResolutionesModuleInterop
While they all sound like they’re doing the exact same thing (handling JavaScript modules), they actually control very distinct phases of compilation and module resolution.
Here is the ultimate mental model to untangle them once and for all.
The Quick Mental Model
To keep them straight, assign each setting a specific role in your build pipeline:
| Setting | What Question Does It Answer? | Analogy |
|---|---|---|
target |
How modern should the output JavaScript syntax be? | Choosing the language dialect |
module |
How should file imports and exports look in the output JS? | Choosing the delivery container |
moduleResolution |
How does TypeScript find the imported file on disk? | The map/GPS algorithm |
esModuleInterop |
How do we bridge the gap between ESM and legacy CommonJS modules? | An adapter plug |
1. target: Modern Syntax vs. Legacy Compatibility
target dictates the version of JavaScript syntax TypeScript outputs when it strips out your types. It controls syntax features like async/await, arrow functions, classes, and optional chaining.
Important:
targetdoes not change how yourimportandexportstatements are written—unless yourmodulesetting depends on it!
Example
Imagine you write this TypeScript code:
const greet = (name: string) => {
console.log(`Hello, ${name}?.length`);
};
"target": "ES2020"→ Outputs modern arrow functions and template literals as-is.
const greet = (name) => {
console.log(`Hello, ${name}?.length`);
};
"target": "ES5"→ Transpiles everything down to old ES5-compatible code using regular functions and string concatenation.
var greet = function (name) {
console.log("Hello, " + (name === null || name === void 0 ? void 0 : name.length));
};
2. module: The Module Format of the Output
While target controls JS syntax, module controls how files import and export other files in the generated .js output.
Common JS module systems include:
- CommonJS (CJS): Uses
require()andmodule.exports(traditional Node.js). - ES Modules (ESM): Uses
importandexport(modern browser & Node.js standard).
Example
Suppose your TypeScript code imports a helper:
import { add } from './math';
export const result = add(1, 2);
"module": "CommonJS"
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const math_1 = require("./math");
exports.result = (0, math_1.add)(1, 2);
"module": "ESNext"(orES2022)
import { add } from './math';
export const result = add(1, 2);
3. moduleResolution: How TS Finds Your Files
moduleResolution tells the TypeScript compiler the algorithm to use when searching for a module when you write import something from 'location'.
It doesn’t affect your compiled JS code at all. It strictly exists for compile-time type checking.
Common Values:
node10(formerlynode): Mimics older Node.jsrequire()lookup behavior. Looks innode_modules, searches forindex.jsorpackage.json“main” fields.node16/nodenext: Built for modern Node.js which supports both ESM and CJS natively. It respectspackage.json"exports"fields and requires file extensions (like.js) in relative import paths!bundler: Designed for apps using modern bundlers (Vite, Webpack, esbuild). It respects"exports"likenode16, but relaxes rules like requiring explicit.jsextensions in imports.
Example
import { helper } from './utils';
With node16, TypeScript will yell at you:
Relative import paths must end with an extension. You’d have to write
./utils.js(even though the source file is./utils.ts).
With bundler, TypeScript lets ./utils pass without complaint because your bundler handles extension resolving for you.
4. esModuleInterop: Smoothing Over CJS & ESM Friction
Historically, CommonJS modules exported a single default value like this:
// lodash in CommonJS
module.exports = function () { /* ... */ };
ES Modules require explicit default exports (export default ...). When ESM code tries to import a CJS package, syntax friction happens:
// Standard ES Module spec requires this:
import React from 'react';
// But without esModuleInterop, TS forced you to do this for CJS libs:
import * as React from 'react';
Setting "esModuleInterop": true tells TypeScript to emit tiny helper functions in the JavaScript output so you can use standard import React from 'react' syntax seamlessly, even when importing legacy CommonJS modules.
Example
import express from 'express';
"esModuleInterop": false→ Fails compilation or runtime error becauseexpressexports viamodule.exports, not an ES default export."esModuleInterop": true→ TS wraps therequire('express')in an__importDefaulthelper under the hood so your clean ESimportdefault syntax just works!
Summary Checklist
When configuring your next project, ask yourself:
target: How old are the browsers or Node runtime I am deploying to? (e.g.,ES2022)module: What module system does my target runtime execute? (e.g.,NodeNextfor modern Node,ESNextfor Vite/Frontend)moduleResolution: Who is bundling or running my code? (e.g.,bundlerfor Webpack/Vite,nodenextfor pure Node)esModuleInterop: Am I importing CommonJS libraries into an ESM project? (Set totrue99% of the time!)