I keep my JavaScript in a library of small modules, each a folder that any project can load with require('Name'). The folder they live in isn't an npm one: it's a node_modules folder at the top of the code tree, and Node's usual lookup finds it from any project underneath. Every module follows the same few rules, which make the code easy to find your way around and easy to search. Let's build a small one.
One tree per module
A module exports a single object, and that object is the whole of the module: every function and type it has sits somewhere in its tree. Here's a small Game module:
Game
├── Player
│ ├── Create
│ └── Rename
└── Score
├── Add
└── Format
Everything is called by its path through the tree: Game.Player.Create('Bob'), Game.Score.Add(score, 10). There are no classes to construct and nothing else to import. You load the module and walk down to what you need.
Files follow the tree
Each branch of the tree gets a file, named after its full path, inside a folder named after the branch above it:
Game/
index.js
src/
Game.Player.js
Game.Score.js
Player/
Game.Player.Create.js
Game.Player.Rename.js
Score/
Game.Score.Add.js
Game.Score.Format.js
Because a file is named after its full path, there's never any doubt about which file holds Game.Player.Create, and no two files in the whole library share a name.
Building the tree with require
The tree is put together with require. index.js holds the top of it, with a require for each branch:
const Game =
{
Player: require('./src/Game.Player'),
Score: require('./src/Game.Score'),
}
module.exports = Game
A branch file does the same one level down. It writes out the tree from the root to its own branch, fills in only that branch, and exports just that part:
const Game =
{
Player:
{
Create: require('./Player/Game.Player.Create'),
Rename: require('./Player/Game.Player.Rename'),
}
}
module.exports = Game.Player
And at the leaves are the functions themselves, each in the same shape, with the whole path written out above it:
const Security = require('Security')
const Game =
{
Player:
{
Create: (name) => {
Security.String(name)
return {
_is: 'Game.Player',
name,
score: 0,
}
}
}
}
module.exports = Game.Player.Create
Every file looks like a small piece of the same object. Open any of them and the first thing you see is exactly where in the module you are.
Using the rest of the module
When a function needs another part of its own module, the file requires it into the same place in its own copy of the tree. Then the call inside the file is written exactly as it's written everywhere else:
const Security = require('Security')
const Game =
{
Player:
{
Create: require('./Game.Player.Create'),
Rename: (player, name) => {
Security.Object('Game.Player')(player)
Security.String(name)
const renamed = Game.Player.Create(name)
renamed.score = player.score
return renamed
}
}
}
module.exports = Game.Player.Rename
Other modules
Other modules are required at the top of the file, one line each, by their bare name, into a variable with exactly that name:
const Security = require('Security')
const Collection = require('Collection')
The list at the top of a file is then a complete list of what it depends on.
Full names everywhere
This is the rule that matters most: anything is always referred to by its whole dotted name, inside its own module and in every other. Nothing is copied into a variable with a shorter name, nothing is destructured, nothing is renamed:
// Not like this
const { Create } = require('Game').Player
const player = Create('Bob')
// Like this
const Game = require('Game')
const player = Game.Player.Create('Bob')
The name in the call is the name in the definition is the name of the file. So a search of the whole code base for Game.Player.Create finds the file that defines it and every single place it's used, and nothing else. Renaming something is a search and replace. And reading any line, you know where the function it calls lives without looking anything up.
Nothing runs on load
Requiring a module builds its tree of functions and does nothing else. No timers start, no files are read, no connections are opened and no state is set up when a file is included. Work only happens when you call a function.
That makes requiring anything safe: tests, tools and scripts can load any module without side effects, and the order modules are loaded in never matters. Where a module does keep state, it sits in its tree under a name starting with an underscore, such as Security._stack, which marks it as the module's own business.
Checking every argument
Every function starts by checking each of its arguments with the Security module, one line per argument, before it does anything else. You can see it in Game.Player.Create and Game.Player.Rename above. The checks catch a bad value in the function it was passed to, and the first few lines of any function say what it takes.
Naming
- Modules, branches and functions are capitalised words:
Game,Player,Create. - Files are the full dotted name with
.json the end, in a folder named after the branch above. - Arguments and local variables are camel case, and an argument that can be left out starts with
optional:optionalCharacter,optionalCallback. - Typed objects carry their full type name in an
_isfield, soGame.Player.Createmakes a'Game.Player'. - Names starting with an underscore are for the module's own use.
With these rules, a module's list of files is also a table of contents for it.
See also
- Simple typechecking goes a long way - The Security module that every function uses to check its arguments
- A Docker module for Node.js - A complete module built this way, from the daemon up to whole services
- AI models in Docker containers - The Docker module's AI branch, and the Ai module that starts its models when they're needed
- Self-describing code - The module namespace tree used as the first building block of a language