Back to KB

improves bundle sizes via tree-shaking, and aligns the runtime with the official ECMASc

Difficulty
Beginner
Read Time
67 min

What does "type" in package.json actually do?

By Codcompass Team··67 min read

Node.js Module Resolution: Controlling CJS and ESM Behavior with package.json

Current Situation Analysis

The JavaScript ecosystem operates under a dual-module paradigm. Node.js supports both CommonJS (CJS) and ECMAScript Modules (ESM), creating a persistent source of friction for developers managing dependencies, build pipelines, and runtime configurations. The type field in package.json is the critical control mechanism that dictates how Node.js interprets .js and .ts files within a package scope.

This configuration is frequently overlooked because Node.js defaults to CommonJS for backward compatibility. Many projects function correctly for years without explicitly declaring a module type, leading to a false sense of security. When teams attempt to integrate modern tooling, browser-native code, or static analysis features, the implicit default often causes resolution failures. The industry is aggressively standardizing on ESM due to browser parity, tree-shaking efficiency, and static analysis capabilities. However, the transition is non-trivial; ESM enforces stricter resolution rules, requires explicit file extensions, and alters how global variables like __dirname are accessed. Misunderstanding the type field's scope and precedence is a primary cause of "Cannot use import statement outside a module" errors and broken dependency graphs in production environments.

WOW Moment: Key Findings

The type field does more than toggle syntax; it fundamentally changes the module resolution algorithm, static analysis capabilities, and runtime features available to the codebase. The following comparison highlights the operational differences driven by this single configuration line.

FeatureCommonJS (Default / "type": "commonjs")ESM ("type": "module")
Primary Syntaxrequire(), module.exportsimport, export
Resolution AlgorithmDynamic; resolves extensions automaticallyStatic; requires explicit extensions for relative imports
Browser CompatibilityRequires bundling/transpilationNative support in all modern browsers
Static AnalysisLimited (dynamic require calls)Full (enables tree-shaking, dead code elimination)
Top-Level AwaitNot supportedSupported
Global Contextmodule, exports, require, __dirname, __filenameimport.meta, no __dirname/__filename
JSON Importsrequire('./data.json')import data from './data.json' assert { type: 'json' }

Why this matters: Setting "type": "module" unlocks browser-native execution, improves bundle sizes via tree-shaking, and aligns the runtime with the official ECMAScript standard. However, it demands rigorous adherence to extension requirements and path resolution patterns that CommonJS abstracts away.

Core Solution

Implementing a robust module strategy requires configuring the type field, understanding extension overrides, and integrating TypeScript correctly. The following implementation demonstrates a production-grade setup.

1. Package Configuration

The type field applies to the package scope. It influences all .js and .ts files within that package unless overridden by file extensions.

{
  "name": "@acme/data-pipeline",
  "version": "2.1.0",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}

Rationale:

  • "type": "module": Explicitly declares ESM. This prevents ambiguity and ensures consistent behavior across environments.
  • "exports": Defines the public API surface. This is critical for ESM packages to control entry points and enable subpath imports.
  • "engines": ESM features like import.meta and stable JSON imports require Node.js 18+.

2. Module Implementation

ESM enforces explicit imports. Relative imports must include the file extension.

// src/services/metrics-collector.ts
export interface MetricPayload {
  event: string;
  timestamp: number;
  value: number;
}

export class MetricsCollector {
  private buffer: MetricPayload[] = [];

  async record(payload: MetricPayload): Promise<void> {
    this.buffer.push(payload);
    if (this.buffer.length >= 100) {
      await this.flush();
    }
  }

  private async flush(): Promise<void> {
    // Simulated network call
    console.log(`Flushing ${this.buffer.length} metrics`);
    this.buffer = [];
  }
}
// src/index.ts
import { MetricsCollector } from './services/metrics-collector.js';
import { createServer } from 'node:http';

const collector = new MetricsCollector();

const server = createServer(async (req, res) => {
  if (req.url === '/track') {
    await collector.record({
      event: 'api_call',
      timestamp: Date.now(),
      value: 1,
    });
    res.writeHead(200);
    res.end('OK');
  } else {
    res.writeHead(404);
    res.end();
  }
});

server.listen(3000, () => {
  console.log('Server running on port 3000');
});

Key Implementation Details:

  • Extension Requirement: import ... from './services/metrics-collector.js' includes .js. Even in TypeScript source, the import path references the emitted JavaScript extension. This is required by the ESM resolver.
  • Named Exports: Using `export cl

assandexport interface` allows consumers to import specific bindings. This supports better tree-shaking compared to default exports.

  • Node Built-ins: import { createServer } from 'node:http' uses the node: protocol, which is the recommended pattern for core modules in ESM.

3. TypeScript Integration

TypeScript must be configured to respect the type field and enforce ESM resolution rules.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

Rationale:

  • "module": "NodeNext": Instructs TypeScript to emit ESM syntax and respect package.json type.
  • "moduleResolution": "NodeNext": Enforces ESM resolution rules, including extension requirements.
  • This configuration ensures TypeScript compilation aligns with Node.js runtime behavior, preventing mismatches between build and runtime.

4. Extension Overrides

File extensions can override the type field for specific files. This is useful for gradual migration or integrating legacy code.

  • .mjs: Always treated as ESM.
  • .cjs: Always treated as CommonJS.
  • .mts / .cts: TypeScript equivalents for ESM and CJS respectively.

Example: A legacy utility that must remain CJS in an ESM project.

// src/legacy-adapter.cjs
module.exports = {
  transformData: (input) => {
    return input.map((item) => item.toUpperCase());
  },
};
// src/modern-consumer.mjs
import { createRequire } from 'node:module';
import { transformData } from './legacy-adapter.cjs';

// In ESM, you can import CJS files using the .cjs extension.
// The CJS exports are mapped to the default export.
console.log(transformData(['hello', 'world']));

Pitfall Guide

Production environments expose subtle failures in module configuration. The following pitfalls are common in ESM migrations and hybrid setups.

PitfallExplanationFix
Missing File ExtensionsESM requires explicit extensions for relative imports. Omitting .js causes ERR_MODULE_NOT_FOUND.Always append .js to relative imports in TypeScript and JavaScript source files.
__dirname UndefinedESM does not provide __dirname or __filename. Code relying on these fails at runtime.Use import.meta.url with fileURLToPath and dirname from node:path.
Dynamic Import ProtocolDynamic import() in ESM requires the file:// protocol for local files. Relative paths fail.Use import('file://' + path.resolve('./module.js')) or import.meta.resolve.
JSON Import SyntaxESM JSON imports require import assertions or specific flags depending on Node version.Use import data from './data.json' assert { type: 'json' } or read via fs for compatibility.
Default Export MismatchCJS module.exports = fn maps to ESM default export, but named imports fail.Access via import legacy from './legacy.cjs' or use createRequire for named access.
require in ESMrequire() is not defined in ESM scope. Attempting to use it throws ReferenceError.Use dynamic import() for conditional loading or createRequire if synchronous loading is mandatory.
Extensionless Package ImportsESM resolves package imports based on exports field. Missing exports can cause resolution failures.Ensure dependencies define exports in their package.json. Avoid relying on implicit file resolution.

Best Practice: Path Resolution Helper Create a utility to handle path resolution consistently across ESM and CJS contexts.

// src/utils/path-resolver.ts
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

export function resolveProjectPath(...segments: string[]): string {
  return resolve(__dirname, '..', ...segments);
}

Production Bundle

Action Checklist

  • Audit Module Type: Verify "type": "module" is set in package.json for new projects. Remove implicit defaults.
  • Enforce Extensions: Configure linters to require file extensions in all relative imports. Update existing imports to include .js.
  • Update TypeScript Config: Set module and moduleResolution to NodeNext in tsconfig.json. Verify output matches ESM expectations.
  • Replace Global Paths: Search for __dirname and __filename. Replace with import.meta.url patterns using fileURLToPath.
  • Handle Dynamic Imports: Review dynamic require() calls. Convert to dynamic import() or use createRequire where synchronous behavior is unavoidable.
  • Define Exports Field: Add an exports map to package.json to control public API and prevent deep imports into internal files.
  • Test JSON Imports: Verify JSON import syntax. Use assert { type: 'json' } or fallback to fs.readFileSync for broader compatibility.
  • Validate Dependencies: Check third-party packages for ESM compatibility. Identify packages requiring createRequire or dual-package workarounds.

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
New Project"type": "module"Aligns with browser standards, enables static analysis, future-proofs codebase.Low. Modern tooling supports ESM natively.
Legacy Monolith"type": "commonjs"Maintains stability, avoids breaking existing require patterns and dependencies.Low risk, but accumulates technical debt as ecosystem shifts.
Gradual Migration"type": "module" + .cjs filesAllows incremental conversion. New code uses ESM; legacy code remains CJS via extension override.Medium. Requires managing mixed syntax and interop patterns.
Library AuthorDual Package (exports field)Supports both CJS and ESM consumers. Maximizes compatibility for downstream users.High. Requires build configuration to emit both formats.
Browser-First App"type": "module"Eliminates bundling overhead for module syntax. Enables direct browser execution.Low. Build tools optimize ESM efficiently.

Configuration Template

package.json

{
  "name": "@acme/production-service",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./utils": {
      "import": "./dist/utils.js",
      "types": "./dist/utils.d.ts"
    }
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Quick Start Guide

  1. Initialize Project: Run npm init -y to create package.json.
  2. Set Module Type: Add "type": "module" to package.json.
  3. Configure TypeScript: Create tsconfig.json with "module": "NodeNext" and "moduleResolution": "NodeNext".
  4. Write ESM Code: Create src/index.ts. Use import/export syntax. Ensure relative imports include .js extensions.
  5. Build and Run: Execute npm run build followed by npm start. Verify output and resolve any extension or path errors.

This configuration establishes a robust, standards-compliant foundation for Node.js development. By explicitly controlling module resolution through package.json and adhering to ESM constraints, teams can leverage modern JavaScript features while maintaining compatibility with the broader ecosystem.