DEV Community

ndesmic
ndesmic

Posted on

Best Practice Tips for Writing Your JS Modules

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.

Top comments (5)

Collapse
 
jespertheend profile image
Jesper van den Ende

I was looking for advice on star imports, specifically when used with index.js like you mentioned. This is some very useful info, thanks!
I wonder though, how does one bundle their library into a single file without using index.js files?

Collapse
 
ndesmic profile image
ndesmic

It depends on what you are doing and what you want to export. Often times I'll have a main file that references several other libraries but also has the main code in it. If you adhere to doing one thing in your module this is pretty typical. If you have a collection of things you want to output and it makes sense to separate them into their own files then you'd use a index.js file (or mod.ts in Deno) to collect them for re-export. Realistically you'll be using a bundler for prod resources so this will be compiled down to a single file. If you aren't bundling, then just have multiple smaller files as they'll fetch concurrently rather than get blocked until index.js downloads.

Collapse
 
jespertheend profile image
Jesper van den Ende

I have a lot of files so I think separating them is the best way to go in that regard. I have a single index.js importing all files and exporting them again. But I'm not a big fan of this. It allows for name clashes and if a script has side effects it will execute them.
But I'm not really sure if there are any alternative methods out there. I could completely ditch index.js and require users of the library to import specific files using their complete path, but I'm not sure if this is generally an accepted way of doing this.

Thread Thread
 
ndesmic profile image
ndesmic

Node users will typically only import the the package's main script as that's just the general convention. However if the codebase it big enough you might break it into separate packages using a monorepo pattern like babel etc. Otherwise if there's code that's internal to the module but not for re-export, or if you have name clashes you'll need to manually write all the imports and exports in the index.js instead of using *. Personally I wouldn't find using multiple import paths to be a big deal but you should make sure it's documented what is in each as it might not be expected.

Thread Thread
 
jespertheend profile image
Jesper van den Ende

That sounds like a good idea. Thanks for the advice!