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:./utils.js
./utils.json
./utils.node
./utils/index.js
- This is why “directory imports” have traditionally “just worked” in CJS projects.
- You can
- 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 throwERR_UNSUPPORTED_DIR_IMPORT
. - You must explicitly import the file:
import { foo } from "./utils/index.js";
- Relative specifiers must include an extension (
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"
inpackage.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 inmoduleResolution: "nodenext"
knows to resolve this againstindex.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
- Never rely on implicit directory resolution in ESM projects. Always specify
/index.js
. - Always use
.js
in imports, even in.ts
source — TypeScript rewrites them against your.ts
files. - 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
- Don’t over-nest barrels. Chaining too many
index.ts
barrels can slow down bundlers and confuse tree shaking. Keep them purposeful.
Module resolution when using Webpack with ts-loader and subpath exports
Yes, it get’s more complicated. I am trying to bundle my ts-files with webpack’s ts-loader which fails with:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /code/my-project/packages/types/src/lib/enums/index.ts
I use subpath exports in the types package which are pointing to ts-files, not compiled js-files:
"exports": { //... "./enums": { "types": "./src/lib/enums/index.ts", "default": "./src/lib/enums/index.ts" }, //... }
I think that webpack’s ts-loader would take care of handling ts-files, but it does not. My package.json also has "type": "module"
specified and that’s whats causing the problem:
The Module Resolution Order:
- Webpack’s Module Resolution: When webpack encounters an
import
statement, it first uses Node.js module resolution to locate the file - File Location vs. File Processing: Node.js resolution determines which file to load, then webpack’s loader system determines how to process that file
- The Problem: When Node.js finds a
.ts
file in an ESM package, it tries to validate the module before webpack can intercept it
Detailed Flow:
// When webpack sees this import:
import { MyEnum } from '@my-org/types/enums'
// Step 1: Node.js module resolution (happens first)
// - Reads package.json exports field
// - Resolves to: ./src/lib/enums/index.ts
// - Since "type": "module" is set, Node.js expects ES module format
// - Node.js validates the file extension (.ts) against ES module rules
// - ERROR: .ts is not a valid ES module extension
// Step 2: Webpack loader system (never reached)
// - ts-loader would process the .ts file
// - But this step never happens because Step 1 failed
The solution is to remove "type": "module"
from the package which switches to CommonJS, which is technically wrong, but it fixes the bundling.
.ts
files are not valid ES module extensions per the specification. When This Combination Works: Tools like Vite or Rollup that don’t use Node.js resolution, Development Servers that implement custom resolution logic or TypeScript-first tools that understand .ts
extensions natively.