Skip to content
aviral gupta

// B1.1 · ~32 min · Beginner

Node.js project basics: modules, config and errors

After this lesson you can set up a small Node.js project that runs in the module system you intended, reads its configuration from a .env file without type bugs, and fails with a clear message and a non-zero exit code.

Lesson 1 of 5 in B1 Running Node and project basics

Start of the module

You will be able to

  • Decide from the file extension and package.json "type" whether a file is an ES module or CommonJS
  • Load settings with --env-file and convert process.env strings into numbers and booleans
  • Catch async file errors with try/catch and set process.exitCode
  1. Warm-up · Activity 1 of 7

    Warm-up: a project’s package.json contains the script below. Which command runs it from the project folder?

    {
      "scripts": {
        "start": "node src/server.js"
      }
    }
  2. Predict · Activity 2 of 7

    Predict before reading on. You run node --env-file=.env app.mjs with the .env file and script below. What does it print?

    // .env
    // PORT=3000
    // DEBUG=false
    
    // app.mjs
    const port = process.env.PORT;
    console.log(port + 1);
    console.log(process.env.DEBUG ? 'debug on' : 'debug off');
  3. Practice · Activity 3 of 7

    Match each file to how Node.js loads it and why.

  4. Practice · Activity 4 of 7

    Spot the bug. package.json has "type": "module". Running node index.js fails with “ReferenceError: require is not defined in ES module scope”. Which changes fix it? Pick all that apply.

    // index.js
    const fs = require('node:fs');
    console.log(typeof fs.readFileSync);

    Select all that apply.

  5. Practice · Activity 5 of 7

    Complete the development command so Node.js loads .env and restarts automatically whenever server.js or a module it imports changes. Type the missing flag.

    node --env-file=.env server.js
  6. Brain teaser · Activity 6 of 7

    Brain teaser. missing.json does not exist. The function catches errors and falls back to "{}". What happens when you run node teaser.mjs?

    // teaser.mjs
    import { readFile } from 'node:fs/promises';
    
    async function loadConfig() {
      try {
        return readFile('missing.json', 'utf8');
      } catch {
        return '{}';
      }
    }
    
    console.log(await loadConfig());
  7. Apply · Activity 7 of 7

    Mini-task. Build a tiny config loader as an ES module project: (1) a package.json with "type" and a "dev" script that uses --watch and --env-file-if-exists; (2) src/config.js that turns PORT into a validated number with a default of 3000, turns DEBUG into a boolean, and reads an optional features.json with fs/promises; (3) src/server.js that prints the config, or prints a clear error and sets exit code 1. Test it with no .env, with PORT=abc, and with a broken features.json.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Config from .env, converted, with a missing file handled

This program loads .env itself with process.loadEnvFile, which does what node --env-file=.env main.js would do, so it runs with plain node main.js. The first line shows the trap: PORT arrives as a string, so + 1 appends, and the string "false" is truthy. Then it converts each value once, at the edge of the program, and reads an optional features.json with the await inside try, so the missing file lands in catch and the program falls back to {}.

main.js

import {loadEnvFile} from 'node:process';
import {readFile} from 'node:fs/promises';

// Does what node --env-file=.env main.js does: each line becomes a string in process.env.
loadEnvFile('.env');
console.log(typeof process.env.PORT, process.env.PORT + 1, Boolean(process.env.DEBUG));

// Convert once, at the edge of the program.
const port = Number(process.env.PORT);
const debug = process.env.DEBUG === 'true';
console.log({port, debug});

// Await inside try, so a missing file lands in catch.
let features = {};
try {
  features = JSON.parse(await readFile('features.json', 'utf8'));
} catch (error) {
  if (error.code !== 'ENOENT') throw error;
  console.log('features.json:', error.code, '- using {}');
}
console.log({port, debug, features});

.env

PORT=8080
DEBUG=false

Run it with

node main.js

Output

string 80801 true
{ port: 8080, debug: false }
features.json: ENOENT - using {}
{ port: 8080, debug: false, features: {} }
  • typeof process.env.PORT is string, so PORT + 1 is 80801, and Boolean("false") is true.
  • Number() and === 'true' turn the strings into the number 8080 and the boolean false.
  • The missing features.json is caught because the await sits inside the try. Its error.code is ENOENT.
  • Any other error, such as broken JSON, is thrown again: a missing optional file is fine, a broken one is not.

Exercises

Exercise 1 of 2

Turn environment strings into a config

Write readConfig(env). env is an object of strings, as process.env is after node --env-file=.env. Return {port, debug}: port is PORT as a number, 3000 when PORT is not set; debug is true only when DEBUG is exactly "true". Throw an Error when PORT is not a whole number from 1 to 65535. In a real program you call readConfig(process.env); the tests pass plain objects instead, so this runs in the browser too.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    env.PORT ?? '3000' uses 3000 only when PORT is not set at all.

  2. Hint 2

    Number("abc") is NaN, which is not an integer: Number.isInteger(port) catches it.

  3. Hint 3

    Compare the string: env.DEBUG === 'true'. Anything else, including "false", gives false.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// env is an object of strings, like process.env.
export function readConfig(env) {
  const port = Number(env.PORT ?? '3000');
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    throw new Error('PORT must be a whole number from 1 to 65535, got "' + env.PORT + '"');
  }
  return {port, debug: env.DEBUG === 'true'};
}

console.log(readConfig({PORT: '8080', DEBUG: 'false'}));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// env is an object of strings, like process.env.
export function readConfig(env) {
  return {port: env.PORT, debug: env.DEBUG};
}

console.log(readConfig({PORT: '8080', DEBUG: 'false'}));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readConfig} from './main.js';

test('PORT="8080" becomes the number 8080', () => {
  const got = readConfig({PORT: '8080'}).port;
  assert.equal(got, 8080, `port was ${JSON.stringify(got)}`);
});

test('DEBUG="false" gives false and DEBUG="true" gives true', () => {
  assert.equal(readConfig({DEBUG: 'false'}).debug, false, 'DEBUG="false" should give the boolean false');
  assert.equal(readConfig({DEBUG: 'true'}).debug, true, 'DEBUG="true" should give the boolean true');
});

test('no PORT gives port 3000', () => {
  const got = readConfig({}).port;
  assert.equal(got, 3000, `port was ${JSON.stringify(got)}`);
});

test('PORT="abc" or "70000" throws an Error', () => {
  assert.throws(() => readConfig({PORT: 'abc'}), Error, 'PORT="abc" should throw');
  assert.throws(() => readConfig({PORT: '70000'}), Error, 'PORT="70000" should throw');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

Read an optional JSON file

Write loadJson(path, fallback) with readFile from node:fs/promises. It returns the parsed JSON in the file. When the file does not exist (error.code is ENOENT), it returns fallback instead. Any other error, such as broken JSON, must still be thrown. The last lines of main.js already catch that error, print it and set process.exitCode = 1. Run it with node main.js and check it with node --test.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Put only the readFile call in try, with await, so its rejection lands in your catch.

  2. Hint 2

    In catch, check error.code === 'ENOENT' and return fallback; otherwise throw error again.

  3. Hint 3

    Parse after the try: then a SyntaxError from JSON.parse is not mistaken for a missing file.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {readFile} from 'node:fs/promises';

// The parsed JSON in the file, or fallback when the file does not exist.
export async function loadJson(path, fallback) {
  let text;
  try {
    text = await readFile(path, 'utf8');
  } catch (error) {
    if (error.code === 'ENOENT') return fallback;
    throw error;
  }
  return JSON.parse(text);
}

try {
  console.log(await loadJson('settings.json', {}));
} catch (error) {
  console.error('Cannot read settings:', error.message);
  process.exitCode = 1;
}
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {readFile} from 'node:fs/promises';

// The parsed JSON in the file, or fallback when the file does not exist.
export async function loadJson(path, fallback) {
  const text = await readFile(path, 'utf8');
  return JSON.parse(text);
}

try {
  console.log(await loadJson('settings.json', {}));
} catch (error) {
  console.error('Cannot read settings:', error.message);
  process.exitCode = 1;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadJson} from './main.js';

test('reads and parses settings.json', async () => {
  const got = await loadJson('settings.json', {});
  assert.deepEqual(got, {theme: 'dark'}, `loadJson("settings.json") returned ${JSON.stringify(got)}`);
});

test('a missing file gives the fallback', async () => {
  const got = await loadJson('missing.json', {theme: 'light'});
  assert.deepEqual(got, {theme: 'light'}, `loadJson("missing.json", fallback) returned ${JSON.stringify(got)}`);
});

test('broken JSON still throws a SyntaxError', async () => {
  await assert.rejects(loadJson('broken.json', {}), SyntaxError, 'loadJson("broken.json") should reject with a SyntaxError, not return the fallback');
});

settings.json

{"theme": "dark"}

broken.json

{"theme": }

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

require in an ES module

const fs = require('node:fs');
console.log(typeof fs.readFileSync);

What Node.js prints

ReferenceError: require is not defined in ES module scope, you can use import instead

Why, and the fix

The project's package.json says "type": "module", so main.js is an ES module, and ES modules have no require. Write import fs from 'node:fs'; instead, or, for one file that must stay CommonJS, rename it to .cjs.

__dirname in an ES module

import {join} from 'node:path';
console.log(join(__dirname, 'config.json'));

What Node.js prints

ReferenceError: __dirname is not defined in ES module scope

Why, and the fix

__dirname and __filename belong to CommonJS; ES modules do not have them. Use import.meta.dirname and import.meta.filename, which are stable since v24.0.0: join(import.meta.dirname, 'config.json').

return without await inside try

import {readFile} from 'node:fs/promises';

async function loadConfig() {
  try {
    return readFile('config.json', 'utf8');
  } catch {
    return '{}';
  }
}

console.log(await loadConfig());

What Node.js prints

Error: ENOENT: no such file or directory, open 'config.json'

Why, and the fix

return hands the promise out of the try before it rejects, so the catch never sees the error, and the rejection crashes the program with exit code 1. Write return await readFile(...): the rejection is then thrown inside the try, and the catch returns '{}'.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

The extension and "type" decide the module system

A .mjs file is always an ES module and a .cjs file is always CommonJS, whatever package.json says. A .js file follows the "type" field of the nearest parent package.json: "module" means ES module, "commonjs" means CommonJS. With no field, Node.js runs it as CommonJS unless it finds ES module syntax such as import, in which case it reruns the file as an ES module and warns. The docs tell package authors to always include "type". ES modules use import and have no require, __dirname or module.exports; import.meta.dirname replaces __dirname. Built-ins can be imported as node:fs, and a few, such as node:test and node:sqlite, only exist with the prefix.

Config arrives as strings

node --env-file=.env app.js reads KEY=value lines into process.env. The flag is stable since v24.10.0, and so is process.loadEnvFile('.env'), which does the same from inside a program. If a variable is already set in the real environment, that value wins. A missing file is an error; --env-file-if-exists skips it instead. Every value in process.env is a string, so "false" is truthy and "3000" + 1 is "30001". Convert with Number() and compare booleans with === 'true', and validate before you use them. node --watch restarts the process when the entry file or anything it imports changes.

Async errors must be awaited to be caught

With fs/promises, wrap await calls in try/catch. try/catch only sees a rejection you await inside it: return promise without await leaves the try block before the promise fails. A rejection nobody handles crashes the process by default, with exit code 1, because the default --unhandled-rejections mode is throw. To report a failure and still let cleanup run, set process.exitCode = 1 rather than calling process.exit(). Exit code 0 means success; 1 means an uncaught error; 9 means an invalid argument, such as a missing --env-file.

Sources

Last reviewed September 30, 2026