The Code Etymologist is a multi-part series that covers typical software knowledge that deserves to be documented and shared within development teams.
- Preface
- Part 1: Project setup
- Part 2: Coding guidelines
- Part 3: Development workflows
- Part 4: Product requirements
- Part 5: UI Components library
- Part 6: Data structures
- Part 7: Technical decisions
- Closing thoughts
One of the first tasks as a new team member is to setup the project locally. With JavaScript projects, as soon as we get access to the code repository, part of the setup process is self-documented:
- clone the repository;
- install dependencies using
npm ci
or other package managers; - and search the
package.json
file for a script that starts the project in development mode, usuallynpm start
ornpm run dev
.
While these steps might be sufficient for small and personal projects, large products usually require a number of additional steps:
- what prerequisites do we need to install?
- how to setup and connect to the database server(s)?
- what environment variables are needed and where to get their values from? etc.
If this information is already written down, we're in the right team. If not, we have to do detective work and ask around. Or, we might get help from "the person(s) that knows how to setup the project".
Now, this is the ideal occasion to write down all the necessary steps to setup the project locally. If we don't do it now, when we have the most fresh information, it's likely that it won't happen in the future neither. So, the next team member that will join the team will have to go through the same process all over again.
The Readme
Usually, any kind of documentation should be placed as close to where readers might look for it. For the project setup, the most common place is a README.md
file in the root folder of the project's repository.
While setting up the project, we should write down in the README.md
all the required steps to get the project running locally, by adding missing steps, updating old and incorrect information, and delete obsolete content.
One way to verify that the documentation is accurate is to delete the project entirely and start fresh by strictly following the written documentation, and update it where necessary. However, we might have had some global configs or preinstalled applications that we missed to document, like Docker, VPN, etc.
Therefore, the true test is when the next team member will join. Ask them to follow the documentation and see if they can setup the project without any additional help. They might have a different operating system than us, thus the documentation should be updated to include all the missing puzzle pieces.
Dockerfiles
The README file described above can also include any prerequisites like what servers are required for database persistence. However, there is much better alternative to ensure that all team members use the same infrastructure.
Dockerfiles provide a formal way to define infrastructure details, which stands for living documentation. In addition, the same definition can be used to build images automatically and provide the actual infrastructure.
version: "3.8"
services:
mysql:
image: mariadb:10.3.36
ports:
- 55008:3306
environment:
DB_PASS: idiguplostknowledge
DB_NAME: local-db
redis:
image: redis:6.2.6
ports:
- 55006:6379
The above code is an example of a Dockerfile that defines a MariaDB server and a Redis service. Even if we're not familiar with the syntax, this file is easy to read and understand the prerequisites of the project.
Node.js version
Even though the Node projects might work with multiple versions in development, when going to production we need to decide which specific version we use and support.
We could mention the required version in the README.md
file, but it will likely be overlooked. A more suitable place to specify the required Node.js version is inside the package.json
file, within the engines
section:
{
// ...
"engines": {
"node": "22.14.x"
}
}
The above change won't have any additional effect. It's just a more formal way to define the Node.js version in code.
However, there are a few more tricks we can do to enforce an explicit Node.js version. We can add a .npmrc
file and enable the engine-strict
flag.
engine-strict=true
This will make sure that the Node.js version specified in the package.json
file is used. If the current Node.js version doesn't match the required one while running npm install
, the installation will fail.
npm ERR! code EBADENGINE
npm ERR! engine Unsupported engine
npm ERR! notsup Not compatible with your version of node/npm
npm ERR! notsup Required: {"node":"22.14.x"}
npm ERR! notsup Actual: {"npm":"9.8.1","node":"v18.18.2"}
If we're using nvm or fnm to manage multiple Node.js versions, we can also add a .nvmrc
file. This file should also specify the required Node.js version for the project.
22.14
Now, when we switch between projects, we can run nvm use
, or fnm use
respectively, to switch to the required Node.js version.
Env vars
I assume any web-based project released on a production server requires at least a few environment variables for common configurations. For example, the product that I'm currently working on contains more than 200 variables to initialize the entire system on multiple environments.
One common practice to output environment variables in a .env
file and parse it using a package such as dotenv.
SOME_CLIENT_SECRET="..."
SOME_API_KEY="..."
SOME_SERVICE_URL="..."
ENABLE_METRICS="false"
# ...
This approach provides a great way to inject and load different variables, during different deployments, on different environments. Since these files are environment-specific, we cannot commit them to the main repository. As a side note, it's also a bad practice to commit any details about the content of these files, as they might include sensitive information.
SharingSo, how do we share the variables for local development? One common practice is maintaining a .env.sample
file with all the variable names, but without any actual value. Now, we should be able to compare our own .env
file with the sample file for any missing variables.
Manually comparing these files might work for several variables, but dealing with 50+ values can easily get out of control. We have to rely on verbal communication to inform our team members to update their .env
file whenever we add or change a variable. Missing variables could cause difficult to detect runtime errors.
There is a better alternative to sample files, which are cumbersome to parse, as they include plain text, not a standard format.
We could use a schema validator and use executable code to define our environment variables:
import { z } from "zod";
const envSchema = z.object({
SOME_CLIENT_SECRET: z.string(),
SOME_API_KEY: z.string(),
SOME_SERVICE_URL: z.string(),
ENABLE_METRICS: z.boolean().optional().default(false),
});
Now, we can validate the process.env
object at runtime during app initialisation, ensuring that all required environment variables were loaded.
const ENV_VARS = envSchema.parse(process.env);
This validation is equally beneficial during local development and any other pre-production or production enviroment, to guarantee there are no missing variables.
For more details about this approach, please checkout Typesafe environment variables with Zod by Jacob Paris. In addition, it also covers:
- how to infer the TypeScript type from the schema definition;
- how to patch the ProcessEnv type of Node.js to enforce using only defined variables;
- how to display the list of missing variables required by the application;
To conclude, writing down specific project settings, even in plain text files, is a great first step. However, many details can be described in parsable and executable code, using industry standard formats. The strongest advantage of code is that it enables automation and enforcement, a crucial step for preventing accidental misuse.
Continue reading Part 2: Coding guidelines