loading...

Best Practice Tips for Writing Your JS Modules

ndesmic profile image ndesmic ・7 min read

There's a lot of rich history for modules in Javascript and things have changed a lot. We've moved from no modules, to CJS, AMD, and now ESM. We've added Typescript and development is dominated by fancy bundler flows to the point it would not be surprising to write only ESM and yet never have actually used it natively in browser. As such I've compiled a few tips to making the lives of downstream users a bit easier, especially ones who may want to use different tools or even go buildless.

Author in ESM

You should write your modules in ESM format (or Typescript if you'd like) and have bundler output them to CJS if you need. This naturally prevents you from using some of CJS's cursed patterns like dynamic exports

if(condition){
 exports = foo;
} else {
 exports = bar;
}
Enter fullscreen mode Exit fullscreen mode

or

function foo() {
 console.log("Hello World!");
};
foo.bar = "data";
exports = { foo };
Enter fullscreen mode Exit fullscreen mode

You won't always be able to immediately tell though. Bundlers like webpack have a lot of magic that can deal with mixed modules and so you can get away with having import statements and require in the same module. If this is the case, you can lint for require usages.

Output ESM

ESM is supported in all modern browsers, node and is native in Deno. There is no excuse not to ship your code as ESM at the very least from npm. This gives greater flexibility to clients. Anyone can use the code natively, but also, it's better for modern bundlers. The static analyzability means they can tree-shake better. It's possible to ship multiple versions of code too. Simply author your code in ESM and then when you build transpile it to CJS. Node lets your package.json specify a main which is used for CJS clients and module which is used for ESM clients letting you have the best of both worlds. If you can only have one, always prefer ESM it's the easiest to transpile.

Keep the extensions

While many have gotten into the habit of using node conventions these simply don't work well in the browser. If I import ./foo what does that mean? Node knows because it can scan the filesystem, see what exists, maybe even follow the package.json to some other random directory. Maybe it means ./foo/index.js or maybe it's ./foo.js. Browsers don't do that, ./foo is simply ./foo. If you wanted ./foo.js you need to say that or it will not get the right path. Consumers aren't going to re-write paths in 3rd party modules, at least not without wide support of importmaps and even then it would be better to not have to use those, as large dependency trees means a lot of path rewrites. You need to do this correctly or people using your code in the browser natively won't be able to use it.

Sadly, Typescript (at the time of writing) does the wrong thing by default. Typescript usually prefers extensionless paths. However, you can specify imports like import { foo } from "my-module.js even if the file is actually my-module.ts. This is a little bit weird but you need to do this to properly export for the browser. Worse is that some typescript tools and plugins enforce extensionless paths. Please open issues against projects that do this.

Luckily, if you do happen to use ESM in node natively it will not let you omit them. So start breaking the habit now.

Do not use node resolving functionality

Mentioned above but bears repeating as it's a slightly different issue. Try not to rely on things like ./foo resolving to .foo/index.js or other package.json rerouting. I do realize this is quite an ask as nearly all bundler flows tend to expect everything to come from npm. It's certainly possible if you work at it though, you just need to externally host the dependencies. I won't blame you if it feels too hard though. You can try tools like Snowpack to help you with this. It will also update CJS modules you import from npm into usable ESM!

Prefer named exports

Default exports were largely created for compatibility with CJS. Since in CJS you can do things like exports = function() { console.log("Hello World!"); } in CJS it was thought you should be able to do things like export default function() { console.log("Hello World!"); } in ESM. Unfortunately, and perhaps ironically there's a lot of subtle compatibility problems with this idea. It's not uncommon to get back a default export as { default : () => { console.log("Hello World!"); } when transpiled and imported into CJS, breaking your expectations. In fact, typescript has specific syntax just to work around these cases. This can be a headache to debug. Unless there's a very specific thing you're trying to do such as writing a config.js file, always prefer named exports as they work more reliably.

Avoid fake import syntax

Modules are great, wouldn't it be great if we could import JSON, CSS, HTML or SVGs like that? It would indeed except (at least right now) nothing is standardized and despite it being particularly popular with bundler plugins none of those are likely to look like final syntax (see: import assertions for what this might be). The problem has to do with how urls are interpreted. ./script.js isn't any different than ./script.css to the browser, it's just a URL, only once downloaded does it know what it contains. Servers can also totally lie about the mime type in headers too. So You don't want to write import myCss from "styles.css" and it turn out that, actually, a bad actor switched "styles.css" with a javascript module that steals your data. This is going to take syntax to fix and anything you write today is going to be forever tied to build plugins.

But even when you can add build plugins it's no fun to get import myCss from "styles.css" and try to figure out what the intent was. What does this even mean? Is it supposed to inline those styles in the document? Is it an object of key values? A CSSStyleSheet object? You have to look at the build itself. This code is not re-usable outside your project.

Do not transpile or minify published modules

Yes, transpilation and minification are good when you are outputting your final bundle but do not do this at the intermediate stage, let the client do it. Transpilation at the library level means that downstream clients will have to bundle in your polyfills and other boilerplate code even if they don't need it and even if they are using the same polyfills. Even downgrading to ES5 is a problem because ES5 class prototypes are substantially wordier than class syntax and increase bundle size even when not needed. Certain common polyfills like tslib can be marked as peer-dependencies to help avoid double bundling but even better is to let that be transpiled to ES20XX and let the client figure out what to do with the standards compliant code. It may well be they don't need polyfills at all. Minification also messes up developer experience because now you're stuck with minified code to dig through. Just output the code as authored or as close to it as possible and let the client figure out if they want it minified or not.

Publishing Typescript

If you are doing ESM and CJS bundles you might be tempted to export typescript code as well. Keep in mind it's unlikely the client will be able to use it directly. Typescript changes by version and differs by tsconfig. If typescript was stable like JS this wouldn't be a problem but if the user has stricter rules than the ones you authored the package with it will fail to compile. Instead, for usage, output the highest version of ECMAScript supported as well as the typing data.

You can however still include the Typescript source for the purpose of source mapping and many popular tools like VSCode will support this.

Using * Imports

import * as foo from "foo.js" is generally not a very disciplined way to go about things, you should import things by name so that it's very clear which things you actually intended to use. While modern bundlers are smart enough to catch over importing it's not always as obvious to the humans reading the code especially if they don't know what's in scope. * imports can also behave strangely when mixing module types, consider a CJS module:

//foo.js
export = function(){ console.log("Hello!"); };
Enter fullscreen mode Exit fullscreen mode

You should always import this like import foo from "foo.js";. If you tried import * as foo from "foo.js" this works, but it's really weird. The CJS export is exporting a value but the ESM is importing a namespace which shouldn't be callable (and if you updated the module to ESM it will no longer work). Another common pattern with * is re-exporting. Projects might have many files that are then re-exported under an index.js:

/foo
  foo-bar.js
  foo-baz.js
  foo-qux.js
  index.js
Enter fullscreen mode Exit fullscreen mode

where

//index.js

export * from "./foo-bar.js"
export * from "./foo-baz.js"
export * from "./foo-qux.js"
Enter fullscreen mode Exit fullscreen mode

The purpose is to group your public interface in a single file. This is a good idea in principal, but you need to consider a few things. One thing that can happen is that you might export the same name twice which will result in an error. But even if you don't it'll often require clients to bounce between lots of files to track down where the thing actually came from and in a browser this directly ties into latency resolving the modules as you need to make more requests. That, and if some of the exports are co-dependent you can get circular dependencies (try not to reference index.js in the modules). Depending on the bundler these might not resolve the way you think and it's best to avoid this problem if you can.

Discussion

pic
Editor guide