Module resolution deep-dive

You can import modules relatively

import {Something} from './Something';
import {Something} from '/Something';
import {Something} from '../bar/Something';

or Non-relatively:

import {Something} from 'mylibrary';

How does TypeScript find modules that you are referencing? Either via Classic or Node --moduleResolution strategy. Classic is the default when emitting AMD, System or ES2015 modules. Node is the default when emitting CommonJS or UMD modules.

Resolving Classic Relative Imports:

// assuming this file is /src/app/foo.ts
import {Something} from './Something';

// will look in this order:
/src/app/person.ts
/src/app/person.d.ts

Resolving Classic Non-relative Imports:

// assuming this file is /src/app/foo.ts
import {Something} from './Something';

// will look in this order:
/src/app/person.ts
/src/app/person.d.ts
/src/person.ts
/src/person.d.ts
// continue searching up the directory tree

Resolving Node Relative resolution:

// assuming this file is /src/app/foo.ts
import {Something} from 'person';

// will look in this order:
// /src/app/person.ts
// /src/app/person.tsx
// /src/app/person.d.ts
// /src/app/person/package.json (having a 'typings' property)
// /src/app/index.ts
// /src/app/index.tsx
// /src/app/index.d.ts

Resolving Node Non-relative resolution:

// assuming this file is /src/app/foo.ts
import {Something} from 'person';

// will look in this order:
// /src/app/node_modules/person.ts
// /src/app/node_modules/person.tsx
// /src/app/node_modules/person.d.ts
// /src/app/node_modules/person/package.json (having a 'typings' property)
// /src/app/node_modules/index.ts
// /src/app/node_modules/index.tsx
// /src/app/node_modules/index.d.ts
// now going up one directory but still looking in node_modules
// /src/node_modules/person.ts
// /src/node_modules/person.tsx
// /src/node_modules/person.d.ts
// /src/node_modules/person/package.json (having a 'typings' property)
// /src/node_modules/index.ts
// /src/node_modules/index.tsx
// /src/node_modules/index.d.ts
// continue searching up the directory tree

You can define a location that the compiler will look up when importing non-relative modules:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./modules"
  }
}

You can also specify specific lookup locations for specific modules:

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "my_lib" : ["./customPath"] // relative to baseUrl if specified
    }
  }
}

If you have source files located under several locations and you want to combine them, then you can use:

// tsconfig.json
{
  "compilerOptions": {
    "rootDirs": [
      "modules",
      "src",
      "ts/modules"
    ]
  }
}

To troubleshoot module resolution:

// tsconfig.json
{
  "compilerOptions": {
    "traceResolution": true
  }
}

1. ESM resolution vs CJS resolution

  • CommonJS (require)
    Classic Node CJS resolution is very forgiving:
    • You can require("./utils") and Node will look for:
      1. ./utils.js
      2. ./utils.json
      3. ./utils.node
      4. ./utils/index.js
    • This is why “directory imports” have traditionally “just worked” in CJS projects.
  • ECMAScript Modules (import)
    Node’s ESM resolver is strict:
    • Relative specifiers must include an extension (./utils.js)
    • Directory imports are not allowed. If you write: import { foo } from "./utils"; Node will throw ERR_UNSUPPORTED_DIR_IMPORT.
    • You must explicitly import the file: import { foo } from "./utils/index.js";

So: ESM does not allow implicit resolution of index.js in a folder.

2. What about barrel files?

A barrel file is usually index.ts that re-exports from submodules, e.g.:

// src/utils/index.ts
export * from "./math.js";
export * from "./string.js";

And then you’d import:

import { add } from "./utils/index.js"; // ✅ valid in ESM

or if you’re in CJS or using a bundler in “legacy” mode:

import { add } from "./utils"; // ❌ breaks in ESM, works in CJS

So in strict Node ESM mode ("moduleResolution": "nodenext" in TS):

  • You must include /index.js in the specifier.
  • Or, better: expose that barrel via "exports" in package.json:
{
  "exports": {
    "./utils": "./dist/utils/index.js"
  }
}

Now consumers can do:

import { add } from "my-lib/utils"; // ✅ no need for /index.js

3. Do you have to use .ts or .js?

  • In source TypeScript:
    You always write .js, not .ts.
    Example: import { add } from "./utils/index.js"; // ✅ TypeScript in moduleResolution: "nodenext" knows to resolve this against index.ts in the source tree, but keeps .js in the emitted code.
  • At runtime (Node / webpack bundle):
    The compiled files are .js, so your .js specifier still works.

If you write .ts, Node will fail at runtime, because .ts doesn’t exist in your build output.


4. Does this cause errors when chaining barrels?

Chaining barrel files is fine, as long as you respect .js extensions in all specifiers.

Example:

// src/index.ts
export * from "./utils/index.js";
// src/utils/index.ts
export * from "./math.js";

TypeScript resolves these .js imports to .ts sources during build, and Node sees .js at runtime.
No error, as long as you don’t try export * from "./utils"; (that would fail in ESM mode unless webpack rewrites it).

5. Best practices

  1. Never rely on implicit directory resolution in ESM projects. Always specify /index.js.
  2. Always use .js in imports, even in .ts source — TypeScript rewrites them against your .ts files.
  3. Prefer exports in package.json to create clean subpath entry points (avoiding /index.js in user imports). { "exports": { ".": "./dist/index.js", "./utils": "./dist/utils/index.js" } } Then users can do: import { add } from "my-lib/utils"; // ✅ clean
  4. Don’t over-nest barrels. Chaining too many index.ts barrels can slow down bundlers and confuse tree shaking. Keep them purposeful.

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.