A single, seemingly harmless line of code can trigger more than a dozen costly disk operations. And it’s a line you write dozens of times a day:
import { something } from "./foo";
JavaScript’s module system is a genuine hell, for humans and tools alike. And it all comes down to a simple observation: this import is fundamentally ambiguous. The engine (runtime, bundler, compiler, or static analyzer) doesn’t know which file "./foo" actually points to. It has to guess.
Why an import is ambiguous
The ambiguity doesn’t stem from a single flaw, but from the accumulation of several layers of history and standards coexisting within the ecosystem:
- Multiple module systems: CommonJS and ECMAScript modules (ESM) coexist, with different semantics.
- Multiple possible extensions:
.js,.ts,.jsx,.tsx,.cjs,.mjs,.cts,.mts. - Multiple module resolution algorithms, depending on the runtime and the configuration.
- Multiple ways to publish a library’s sources (
main,module,exports, types fields…). - Differences in how modules are handled across runtimes and tools (bundlers, linters, compilers).
This functional complexity translates into two very real costs. First, a cognitive complexity: the developer has to keep the resolution rules in mind to understand what will actually be imported. Second, an irreducible complexity for tooling: module bundlers, linters, compilers, and every static analysis tool (skott, knip, NodeSecure, etc.) must faithfully reproduce these rules to do their job correctly.
Module resolution, or the art of groping for a file
Not specifying an extension means asking the tool to go hunting for the file on disk. That’s precisely what we call module resolution. And this search has to explore every possible variant, in a determined order of priority:
foo.js(JavaScript)foo.jsx(JavaScript Syntax Extension)foo.ts(TypeScript)foo.tsx(TypeScript Syntax Extension)foo.mjs(JavaScript, ECMAScript module)foo.cjs(JavaScript, CommonJS module)foo.mts(TypeScript, ECMAScript module)foo.cts(TypeScript, CommonJS module)
And that’s only half the problem. You still have to handle index file resolution: "./foo" could just as easily refer to a foo/ folder containing a foo/index.js, and again, for each of the extension variants listed above.
Concretely, for a single import, the tool may have to test foo.ts, foo.tsx, foo/index.ts, foo/index.js, and plenty of other candidates. Each of these attempts is a system call to the disk, and therefore a costly operation.
At best, the file is found on the very first attempt. At worst, it takes a dozen failed checks before landing on the right one. Multiply that by the number of imports in a real-world project, and the bill gets steep.
Tools are obviously not naive: with contextual information and a set of heuristics (filesystem cache, prioritizing extensions according to the configuration, memoizing resolutions already performed), they manage to optimize this traversal. But they’re optimizing a problem that, at its root, shouldn’t exist.
What if we stopped guessing?
The best way to resolve an ambiguity is to remove it at the source. Rather than letting the tool guess the extension, you may as well declare it explicitly.
This isn’t a new idea: it’s already how ECMAScript modules work, requiring explicit file extensions in imports. With ESM, import { x } from "./foo.js" leaves no room for doubt, and resolution becomes trivial.
On the TypeScript side, writing extensions explicitly was long blocked by the compiler. As of version 5, a new configuration option lifts that restriction: allowImportingTsExtensions. It allows imports to mention the exact extension of the source file, including TypeScript extensions:
import { something } from "./foo.tsx";
In tsconfig.json, this boils down to enabling the option:
{
"compilerOptions": {
"allowImportingTsExtensions": true
}
}
The benefits of explicit extensions
Declaring the extension in the import brings immediate benefits, for both the machine and the reader:
- No more complex resolution strategies: the path is known, there’s nothing left to guess.
- Less overhead: you avoid the superfluous system calls needed to test each extension variant.
- Better performance for every tool: fewer disk accesses means faster bundlers, linters, and compilers.
- Better readability for humans: just by reading the import, you know exactly which file is targeted.
The trade-off: a bundler becomes necessary
The approach isn’t magic, though, and you need to be aware of its limits:
- TypeScript can’t transpile directly modules that import each other with a
.tsor.tsxextension: theallowImportingTsExtensionsoption is only designed for a workflow where another tool takes over emission. - JavaScript runtimes don’t know how to load these extensions as-is: an
import "./foo.ts"won’t work natively at runtime.
The consequence is that you have to go through a module bundler (or an equivalent tool capable of rewriting these paths). Far from being an annoying constraint, this is actually a winning trade: the bundler knows how to handle these extensions, and since the resolution work is largely pre-chewed for it, it too runs faster.
Conclusion
JavaScript’s module system is complex because it carries the legacy of several standards stacked on top of one another. This complexity has a concrete cost, measurable in disk calls, and it’s the entire tooling ecosystem that pays it on every unresolved import.
The direction the ecosystem is taking is clear and sensible: replace guesswork with explicitness. ECMAScript modules already enforce it, and allowImportingTsExtensions extends that philosophy to the TypeScript world. Writing the exact extension of your imports means gaining readability for yourself and performance for your tools, provided you accept having a bundler in the chain. A trade-off that, given the benefits, is well worth making.