Lachlan Miller

Like my content? Sign up to get occasional emails about new blog posts and other content.

Unsubscribe anytime here.

Why You Should Always Use an Extension for JavaScript Files

Back in the day, JavaScript only ran in the browser. The way to do something simple was:

<script>
  /* your code here
</script>

If you wanted to write a lot of JavaScript, though, which some people did, you could put it in a separate file:

<script src="/code.js"></script>

This also made is easy to load third party modules. Great!

Notice the extension? code.js. It tells you the file is a JavaScript file! The browser will know via the Content-Type header, but having an extension makes it more clear to humans. Extensions are good, they help other programs, and programmers, know what to expect when encountering a file.

CommonJS

CommonJS is a project to standardize the module ecosystem for JavaScript outside of web browsers. At the time, browsers didn't have a module system (they'd get one eventually, more on that soon). Node.js made CommonJS popular. CommonJS is the one with require and module.exports.

One weird thing about CommonJS is you don't need an extension. If you have

// code.js
module.exports = 'FOO'

You can load it with

require('./foo')

Node.js figures it out! Even in the early days, there were other extensions available in Node.js, such as .json (JSON format) and .node (native add-on). Now there is .mjs (ES module) and .cjs (CommonJS) as well.

When you require a module in Node.js, it does something called module resolution. Node.js module resolution is very complex, partially because extensions are optional. This is something Ryan Dahl lamented about Node.js. In his new project, Deno, extensions are required!

ES Modules

CommonJS is a module format for the server. For various reasons, some discussed here, it was not adopted for the browser. After lots of back and forth, a new specification, ES Modules, or ESM, was finalized and are now supported across all major browsers and in Node.js. ESM is the one with import and export.

A single module system to rule them all! Kind of!

ESM works great. You can have a static import:

import { foo } from './foo.js'

// do foo things
foo()

Or a dynamic import, which is fetched asynchronously:

async function main () {
  const mod = await import('./foo.js')
  // do something with mod
}

You don't have a Content-Type header in Node.js, so the extension is even more useful here. It helps Node.js know what type of module you are using.

Historical Baggage

While we waited for ESM to be finalized and implemented, bundlers like webpack became popular. JavaScript developers don't like waiting for things. Webpack implemented its own module resolution algorithm. It works with everything - CommonJS, ESM, and a bunch of other things. It also works for ESM without extensions! Which isn't really ESM, because ESM has extensions as part of the specification.

Webpack lets you write:

import './foo'

This is not valid in ESM - you need an extension. The reason you can get away with this is that the code you write isn't the code your bundler produces. Bundlers can be set to target various module formats - if you are targeting ESM, it will generally output a .js extension. Or, in the case of webpack, they ship their own runtime that handles modules differently to a browser.

The Future

If you are writing code to target a real ESM runtime without using a bundler, like Node.js you need an extension. In a browser it's not technically required, since browsers look at the Content-Type header, which is set by the server. On the other hand, If you are using a bundler, you (generally) have the option of omitting the extension. You can include one, though, if you want (and you should)!

Other than aethestics, there isn't really a good reason to NOT have an extension. Even if you aren't targeting a real ESM runtime right now, you might want to in the future. If you are writing TypeScript, it will work fine too - if you have a foo.ts, you can still write:

import './foo.js'

And TypeScript knows what to do.

It works fine for .tsx too - you can write .jsx. Knowing if a file is going to contain JSX before hand is very useful information for compilers and preprocessors.

moduleResolution: bundler

TypeScript 5.0 recently landed a new moduleResolution configuration option that handles the CommonJS / ESM hybrid format, where you can do things like omit the .js extension for import statements. I've been calling it ChimeraJS.

This hybrid format doesn't have an official spec, though. I'd still recommend using an extension, even if you are using this format - extensions work with CommonJS, ESM and ChimeraJS. It's free! Why not? You've got nothing to lose, and you'll be writing ESM compliant modules, which is the only module system that is officially supported and works in the browser, Node.js, Deno, and more.

We finally have a single module system - let's use it and rejoice, instead of carrying forward the historical baggage. CommonJS served us well, but it's time to move forward.


Like my content? Sign up to get occasional emails about new blog posts and other content.

Unsubscribe anytime here.