# JavaScript Modules Demystified: package.json, tsconfig, and How They Fit Together

Kevzzsk 8 min read
Table of Contents

You open a new typescript project and see three config files, all mentioning “module” in some form:

  1. package.json has "type": "module"
package.json
{
"type": "module",
...
}
  1. tsconfig.json has both "target" and "module"
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "esnext",
...
}
}

They sound like they do the same thing, but they don’t.

This post untangles the confusion. By the end, you’ll know exactly what each setting controls, how they relate to each other, and how to configure them correctly for any project.

TL;DR

  • "target" in tsconfig.json: Controls JS syntax level (arrow functions, async, etc.). This sets the lowest JS version to target. Modern JS syntax will be downleveled accordingly (not polyfills).
  • "module" in tsconfig.json: Controls the import/export syntax (import vs require).
  • "type": "module" in package.json: Controls how Node.js reads the emitted .js files (treat them as ESM or CJS)

A Brief History

Before we dig in, some context. Node.js originally shipped with CommonJS (CJS) as its module system. You would write require() to import and module.exports to export. It worked, but it was Node-specific.

cjs.js
const crypto = require('crypto')
const randomNumber = (max) => crypto.randomInt(max)
module.exports = randomNumber

Then the JavaScript language itself got a standard module system: ECMAScript Modules (ESM). This is the import/export syntax you see everywhere today. The problem? Node needed a way to tell .js files apart, since the same .js extension could mean either system.

esm.js
import crypto from 'crypto'
const randomNumber = (max) => crypto.randomInt(max)
export default randomNumber

That’s where our three settings come in.

"type": "module" in package.json

This one is the simplest, and the most misunderstood.

The "type" field in package.json tells Node’s runtime how to interpret .js files in your project. It’s a Node.js runtime concern, though TypeScript’s module nodenext mode also reads it.

Without "type": "module"

By default (or with "type": "commonjs"), Node treats every .js file as CommonJS:

math.js (treated as CJS)
// This works
const add = (a, b) => a + b
module.exports = { add }
index.js (treated as CJS)
const { add } = require('./math.js')
console.log(add(1, 2))

With "type": "module"

When you add "type": "module" to your package.json, Node treats every .js file as ESM:

math.js (treated as ESM)
export const add = (a, b) => a + b
app.js (treated as ESM)
import { add } from './math.js'
console.log(add(1, 2))

What if you need to mix ESM and CJS?

Node provides two special file extensions:

  • .mjs files are always treated as ESM, regardless of the "type" field
  • .cjs files are always treated as CJS, regardless of the "type" field

target in tsconfig.json

Now we move to TypeScript territory. The "target" setting in tsconfig.json controls what JavaScript syntax TypeScript emits when it compiles your .ts files.

This is purely about syntax downleveling. If your target environment doesn’t support arrow functions, TypeScript rewrites them as regular functions. If it doesn’t support async/await, TypeScript rewrites those too.

Example

Given this TypeScript input:

input.ts
const greet = async (name: string) => {
const message = `Hello, ${name}!`
return message
}

With "target": "ES5", TypeScript emits:

output (ES5)
var greet = function (name) {
return __awaiter(this, void 0, void 0, function () {
var message
return __generator(this, function (_a) {
message = 'Hello, ' + name + '!'
return [2, message]
})
})
}

With "target": "ES2022", TypeScript emits:

output (ES2022)
const greet = async (name) => {
const message = `Hello, ${name}!`
return message
}

The logic is identical. Only the syntax differs.

What Each Target Enables

TargetNotable Syntax Features
ES5No arrow functions, no const/let, no template literals
ES2015Arrow functions, const/let, classes, template literals, destructuring
ES2017async/await
ES2020Optional chaining (?.), nullish coalescing (??)
ES2022Top-level await, class fields
ESNextLatest features (moves forward with each TypeScript release)

Common Misconception

target does NOT control import/export syntax. It will not convert your import statements to require() calls, or vice versa. That’s the job of the module setting.

module in tsconfig.json

This is the setting that actually controls module syntax in TypeScript’s output. Specifically, it determines what kind of import/export statements appear in the emitted JavaScript.

Same Input, Different Output

Given this TypeScript file:

math.ts
export const add = (a: number, b: number) => a + b

With "module": "commonjs":

output (commonjs)
'use strict'
Object.defineProperty(exports, '__esModule', { value: true })
exports.add = void 0
const add = (a, b) => a + b
exports.add = add

With "module": "esnext":

output (esnext)
export const add = (a, b) => a + b

The first emits require()-compatible CJS code. The second preserves the ESM export syntax.

Common Values

ValueEmitsUse Case
"commonjs"require() / module.exportsTraditional Node.js apps
"esnext"import / exportProjects using a bundler (Vite, webpack, esbuild)
"nodenext"import or require() depending on contextModern Node.js (respects package.json "type")
"preserve"Keeps original syntax unchangedWhen another tool handles module transformation

nodenext: The Smart Choice for Node.js

"module": "nodenext" is special. Instead of always emitting one format, it reads your package.json "type" field and emits the appropriate syntax:

  • If "type": "module" is set, .ts files compile to ESM
  • If "type" is absent or "commonjs", .ts files compile to CJS
  • .mts files always compile to ESM, .cts files always compile to CJS

This mirrors exactly how Node itself decides, which means your TypeScript output and Node’s runtime expectations will always be in sync.

A Note on moduleResolution

You’ll often see "moduleResolution" alongside "module". While "module" controls what syntax is emitted, "moduleResolution" controls how TypeScript finds the files you import.

For instance,

index.ts
import customModule from './customModule'

"moduleResolution" answers the question of where to find './customModule' file.

Dive deeper into "moduleResolution" at the official docs.

How They All Relate

Here’s the full picture of how these three settings work together:

Source files to emitted files to output flow
Fig 1: Source files to emitted files to output flow.

TypeScript controls what gets written. package.json controls how Node reads it. They must agree.

If TypeScript emits import statements (because "module": "esnext") but your package.json doesn’t have "type": "module", Node will try to parse ESM syntax as CommonJS. You’ll get the dreaded error:

SyntaxError: Cannot use import statement outside a module

The fix is always the same: make sure the module format TypeScript emits matches what Node expects to read.

Real-World Example Configs

Let’s look at three common setups and how their configs align.

1. Pure ESM Node.js Library

For a modern library that ships ESM:

package.json
{
"name": "my-esm-library",
"type": "module",
"exports": "./dist/index.js"
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "dist",
"declaration": true
}
}

Why this works: "module": "nodenext" sees "type": "module" in package.json and emits ESM. Node sees "type": "module" and parses the output as ESM. Everything agrees.

2. CJS Node.js Application

For a traditional CommonJS app (still very common):

package.json
{
"name": "my-cjs-app",
"main": "dist/index.js"
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist"
}
}

Why this works: No "type" field means Node defaults to CJS. "module": "commonjs" tells TypeScript to emit require() calls. Both sides expect CJS.

3. Frontend App (Vite/bundler)

For a frontend app where a bundler handles module resolution:

package.json
{
"name": "my-frontend-app",
"type": "module"
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx"
}
}

Why this works: The bundler (Vite, webpack, esbuild) consumes the TypeScript output, not Node directly. "module": "esnext" emits clean ESM that bundlers understand natively. "moduleResolution": "bundler" tells TypeScript to resolve imports the way bundlers do (allowing extensionless imports, package.json "exports", etc.).

These are some related tsconfig options worth exploring further, as they relate to "target" and "module" that have been discussed here:

  • "lib": provides type definitions for built-in JS APIs (like Math). Its default is set by "target".
  • "verbatimModuleSyntax"
  • "esModuleInterop"

Common Pitfalls

Before wrapping up, here are mistakes that catch people repeatedly:

1. ESM output without "type": "module"

You set "module": "esnext" in tsconfig.json but forget to add "type": "module" to package.json. TypeScript happily emits import statements, but Node chokes on them.

2. Confusing target with module

Setting "target": "ES2022" and expecting import statements to appear. target only controls js syntax like arrow functions and optional chaining. Module syntax is controlled by module.

3. Using "module": "esnext" for Node.js

"esnext" is designed for bundlers. For Node.js, use "nodenext" instead. It respects your package.json and handles the .mjs/.cjs edge cases correctly.

4. Mismatched module and moduleResolution

Using "module": "nodenext" with "moduleResolution": "node" (the old resolver) is a compile error — TypeScript enforces that "nodenext" uses "moduleResolution": "nodenext". Similarly, pair "esnext" with "moduleResolution": "bundler".

Resources

Here are some more in-depth references:

My avatar

Thanks for reading my little blog! I hope you learn something from my rambling.


Comments