Back Send feedback to ilkka.kuivanen@me.com

Guide to Modules in Javascript

Intro

This post is about my approach to authoring standard Javascript module. There is no shortage of resources available and I recommend checking at least MDN module guide as a primer. All opinions are my own. The code provided here might be broken or might not work as intended. No guarantees of any sorts. I hope this works as inspiration, thought piece or even reference for someone working on the same area.

The Journey (so far)

After several years of building JavaScript apps (in Typescript), I’ve seen things done in many different ways. My own approach and opinions have shifted several times on the same topics.

Disclaimer: I am not after debating the characteristics of the JavaScript language itself. I’m focused on getting things done — within the known constraints.

My approach is guided by a few key principles, applicable across all contexts – whether hobbies, professional work, experiments, or production-level applications:

Reducing the needs even further to get to the essence:

Let's start.

My challenges (so far)

A great module is logical to follow, easy to change, isolated, error resistant and has nice API for consumers.

Consider the following:

But why modules and not classes?

Modules are inferior to classes in many ways, for instance one can't instantiate modules and many of the concepts mentioned here relates to classes as well. So, why am I not talking about just classes?

There is strong conceptual overlap on how I see Javascript modules and classes and their purpose although there are huge differences. I think Javascript modules are conceptually easier than classes. They are also easier to read and write. They are more primitive. They don't have the extra layer of abstraction sprinkled on top of them and they don't implicitly carry the OOP baggage. And in context of Javascript – the language – they are not real classes (but that's beside the point).

Let's address the issues one at the time.

1: Make the high-level file structure understandable

File structures – and filenames – are usually related to the underlying software design pattern used, even if the author did not use any pattern knowingly. There are more formal design patterns, such as adapter, facade, proxy, service, factory, etc. I don't think it makes sense to predescribe which patterns are allowed or how they should be applied. Rather, I think it makes sense to have logical relation between modules utilising these patterns. Conceptually, an easy to understand way is to split modules into hierarchical structure. Either the module is the "main thing" or it supports whatever is the thing main thing above it. Consider the following:

- integration.module.ts
  - api.module.ts
  - transform-responses.module.ts
- server-state.module.ts
- auth.module.ts

(Note: I am not proposing any naming convention here.)

Sanest approach is to limit the depth in the hierarchy. I think two-level is almost always enough. I recommend avoiding the temptation of creating too fancy modules. If the business logic is not finalised and things need to be changed later it is usually unnecessarily large effort to rethink complicated logic that might seem to solve some reusability or composition problem. This relates to the art of optimisation, which is not the topic of this post, but still relates to the underlying challenge. It's about not getting too fancy and having a good taste for what is understandable.

So what about the boundaries of a module? How to design them and what is the right level of abstraction? Typing out the API of the module is a good place to start. By starting the design in consumer-centric manner it often becomes relatively obvious what is the main point of a module, or should it even exist.

This might feel like TDD, but I think it's more than testability of the code. This is important because no module should exist without a reason. If your module is getting too large, it should not be fixed by splitting the file as-is. It requires logical restructuring to make it make sense. The module has its purpose in the world and it's your responsibility to define it.

What about auxillary content, such as types and other shared assets? There are many ways to structure your application in literal sense, be that frontend or backend part of the code. Here's what I suggest in practice:

Consider the following example:

/modules
  └── auth/
      ├── auth.service.ts
      ├── auth.controller.ts
      └── types.ts

/types
  └── api.ts

/../shared-package/types
  └── user.types.ts

Key point: make the reason for the module existence be unambiguous and justified.

2: Make timing and order of startup understandable

The initialisation of module and its content might turn out to be more complicated that initially thought. Javascript engine has a detailed process for parsing through the dependency chain and initialising stuff in order to make the content executable. Things might become quite complex especially if your module has local variables. One of the not-so-obvious things is temporal dead zone (TDZ).

There are many valid cases for a module to have top-level local variables that are not wrapped in functions. In order to use them safely and to avoid issues related timing I use an approach that always uses explicit initialisation and getter functions for accessing the local variables. Module's local variables should be set to null by default. The accessor functions throw an error if the variable has not been initialised. This enforces two things: structured initialisation of your application's modules (e.g. in app.ts) and clarity on when module content is accessed (see Github gist).

One fun thing (it's debatable) to do in order to understand your application better is to remove the initialisation from the root file and see when the application crashes when it attempts to access undefined variables. In more complex event driven systems this might be different from your mental model. This rather simple convention has saved me a lot of time, especially when things are refactored and startup order has changed. When the timing is under control the module does not really care about when it's imported by whom. It only cares about the time it is initialised and the lifecycle onwards. If you want to add some extra you can also throw an error if the initialisation is invoked more than once.

To restate: I think a module should not care about who imports it – as it cannot control it – but it should be extremely careful of its own imports. If the module imports other than library code (i.e. npm packages) be aware of the possibility of circular dependencies. The import is the most important statement of the module: it implies the structure and purpose of your module in relation to other parts of the code. In case you end up with circular dependencies it is not the end of the world and there are rather straightforward approaches to resolve them (e.g. lift up or extract the common logic). It only becomes bigger problem if it sneaks in early and you realise much later through some weird issues.

Key point: use explicit initialisation and getter functions to be in control of the timing.

3: Make moments of error understandable

Module that handles async functions – such as external requests – should return errors in expected format. The most obvious way is to type out the responses with discriminated unions of two types with properties: ok, data and error. In practice, the response is either

(Link to Github gist)

But what about the errors themselves?

Here's what I propose: make yourself your own error:

For this purpose I have written Better Error. It make the standard error object a bit better and handier to use. The class BetterError can be extended to more specific error class, or just use as is. Depending on your needs. I recommend extending if more than couple of different types of error you want to manage. Example of using Better Error can be found here.

There are cases when throwing and not catching an error within the module is justified. I see that as a statement: when x happens, I don't think you (the consumer)should continue running. If you want to continue running, you really need to address it yourself, but I don't recommend it.

Key point: use errors as values in your module so your consumers know what they are getting. Don't make me catch stuff you should had caught yourself.

4: Make tests so changes to the module are understandable

This will be rather short. This point is actually not about the philosophy and conventions of the module as much as it is about having a structured approach to actually working with it. This post is not about promoting testing or attempting to describe the best way to approach tests. Rather, what I think is important and worth saying in this context is: write tests that appreciate the purpose of your module. If the API (your exports) is the promise of your module's logic, at least make sure it delivers on that. I really like the fact that Node has its own test runner (no deps needed), and in the advent of language models, it's now a matter of a few clicks to get some basic tests running. Even in some rapid prototypes some simple tests that validate the expected logic improves the overall speed of moving forward. Modules – and their APIs – are good abstraction to focus on when thinking what to test in the first place.

The canonical Javascript module

Below is my take on how a module should be written. This example module is written for my hobby project to manage env files. It attempts to incorporate all the areas discussed here and reflect how I see building modules. Link to the reference.

In summary:

Key points