Back/Module F-3 Working with Files, Paths, and the Environment
Module F-3·20 min read

The fs module, sync vs async variants, path.join and path.resolve, process.env and process.argv — reading from and writing to the real world.

Module F-3 — Working with Files, Paths, and the Environment

What this module covers: The moment you write a server, a script, or a CLI tool, you need to read files, write logs, parse paths, and pull configuration from the environment. This module covers Node.js's fs module for file I/O, the path module for platform-safe path manipulation, and the process object for environment variables and command-line arguments. These three are in practically every Node.js program you will ever write.


The fs Module

The fs (file system) module is how Node.js reads and writes files on disk. Every operation comes in two flavours: synchronous (blocks until complete) and asynchronous (non-blocking, uses a callback or Promise). We'll cover when to use each.

Reading a File

Async with callback (old style — you will see this in legacy code):

javascript
const fs = require('node:fs'); fs.readFile('./data.txt', 'utf8', (err, contents) => { if (err) { console.error('Failed to read file:', err.message); return; } console.log(contents); }); console.log('This runs BEFORE the file contents are logged');

The callback receives two arguments by convention: err first, then the result. If err is not null, something went wrong. This error-first callback pattern is a Node.js convention you will see everywhere in older code.

Async with Promises (modern style — use this):

javascript
const fs = require('node:fs/promises'); async function readConfig() { try { const contents = await fs.readFile('./config.json', 'utf8'); const config = JSON.parse(contents); console.log(config); } catch (err) { console.error('Failed to read config:', err.message); } } readConfig();

node:fs/promises is the Promise-based version of the fs module, available since Node.js 14. Always prefer this in new code — it works with async/await and avoids callback nesting.

Synchronous (avoid in servers, fine in scripts):

javascript
const fs = require('node:fs'); try { const contents = fs.readFileSync('./data.txt', 'utf8'); console.log(contents); } catch (err) { console.error('Failed:', err.message); }

readFileSync blocks the entire Node.js process until the file is read. In a script that runs once and exits, this is fine. In a server handling concurrent requests, never use it — you will block every other request while one request waits for a file read.

Writing a File

javascript
const fs = require('node:fs/promises'); async function writeLog(message) { await fs.writeFile('./app.log', message + '\n', { flag: 'a' }); // flag: 'a' = append (don't overwrite) // flag: 'w' = overwrite (default) } await writeLog('Server started at ' + new Date().toISOString());

Common flags:

  • 'w' — write (creates or overwrites)
  • 'a' — append (creates or adds to end)
  • 'wx' — write, but fail if file already exists (useful for "create once" files)

Checking if a File Exists

javascript
const fs = require('node:fs/promises'); async function fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } if (await fileExists('./config.json')) { console.log('Config found'); } else { console.log('No config — using defaults'); }

Avoid the old fs.existsSync() pattern. It creates a race condition: by the time you act on the result (open the file), another process may have deleted it. The correct pattern is to attempt the operation and handle the error — exactly what try/catch around fs.access does.

Creating Directories

javascript
const fs = require('node:fs/promises'); // Create a single directory await fs.mkdir('./logs'); // Create nested directories (like mkdir -p) await fs.mkdir('./data/uploads/images', { recursive: true }); // recursive: true won't throw if the directory already exists

Listing Directory Contents

javascript
const fs = require('node:fs/promises'); const entries = await fs.readdir('./src'); console.log(entries); // [ 'app.js', 'config.js', 'routes', 'utils' ] // Get full details (type, size, etc.) const detailed = await fs.readdir('./src', { withFileTypes: true }); for (const entry of detailed) { const type = entry.isDirectory() ? 'dir' : 'file'; console.log(`${type}: ${entry.name}`); }

Deleting Files and Directories

javascript
const fs = require('node:fs/promises'); // Delete a file await fs.unlink('./temp.txt'); // Delete a directory and all its contents (Node 14.14+) await fs.rm('./old-uploads', { recursive: true, force: true }); // force: true won't throw if the path doesn't exist

Renaming and Moving Files

javascript
const fs = require('node:fs/promises'); // Rename (works as move within the same filesystem) await fs.rename('./draft.txt', './published.txt'); // Move to a different directory await fs.rename('./uploads/temp_abc123.jpg', './uploads/processed/final.jpg');

Getting File Metadata

javascript
const fs = require('node:fs/promises'); const stats = await fs.stat('./package.json'); console.log('Size:', stats.size, 'bytes'); console.log('Is file:', stats.isFile()); console.log('Is directory:', stats.isDirectory()); console.log('Last modified:', stats.mtime); console.log('Created:', stats.birthtime);

The path Module

File paths are one of the most common sources of bugs in cross-platform code. On Windows, paths use backslashes (C:\Users\jatin). On macOS and Linux, they use forward slashes (/home/jatin). The path module abstracts this so your code works everywhere.

javascript
const path = require('node:path');

path.join — Concatenate path segments safely

javascript
const filePath = path.join('src', 'routes', 'users.js'); // On macOS/Linux: 'src/routes/users.js' // On Windows: 'src\routes\users.js' // Never do this — breaks on Windows: const badPath = 'src' + '/' + 'routes' + '/' + 'users.js'; // ❌ // Always do this: const goodPath = path.join('src', 'routes', 'users.js'); // ✅

path.join also normalises the result — it collapses double slashes and resolves . and ..:

javascript
path.join('src', '..', 'config') // 'config' path.join('src', '.', 'routes') // 'src/routes' path.join('src//routes///') // 'src/routes'

path.resolve — Build an absolute path

javascript
// path.resolve works from right to left, stopping at the first absolute segment path.resolve('src', 'routes') // → '/Users/jatin/projects/myapp/src/routes' (prepends cwd) path.resolve('/absolute', 'path', 'to', 'file') // → '/absolute/path/to/file' path.resolve('/home', '/tmp', 'file.txt') // → '/tmp/file.txt' (the /tmp resets the path)

Use path.resolve when you need a full absolute path — for example, when passing paths to other modules or when reading files relative to the project root.

path.dirname and path.basename

javascript
const filePath = '/Users/jatin/projects/myapp/src/server.js'; path.dirname(filePath) // '/Users/jatin/projects/myapp/src' path.basename(filePath) // 'server.js' path.basename(filePath, '.js') // 'server' (strips extension) path.extname(filePath) // '.js'

__dirname and __filename (CommonJS only)

In CommonJS modules, __dirname is the absolute path of the directory containing the current file, and __filename is the absolute path of the current file itself.

javascript
// In /Users/jatin/projects/myapp/src/server.js console.log(__dirname); // /Users/jatin/projects/myapp/src console.log(__filename); // /Users/jatin/projects/myapp/src/server.js // Load a config file relative to the current file, not the cwd const configPath = path.join(__dirname, '..', 'config', 'database.json');

This is the correct way to load files relative to the current file. Never use relative paths directly with fs — they resolve relative to process.cwd(), which changes depending on where you run node from.

javascript
// ❌ WRONG — breaks if you run node from a different directory const config = await fs.readFile('./config/database.json', 'utf8'); // ✅ CORRECT — always works regardless of cwd const config = await fs.readFile( path.join(__dirname, '..', 'config', 'database.json'), 'utf8' );

ESM equivalent of __dirname

In ES Modules, __dirname is not available. Use import.meta.url instead:

javascript
import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Now use exactly as before const configPath = join(__dirname, '..', 'config', 'database.json');

This boilerplate is verbose. Many projects put it in a __dirname.js utility or use a bundler that handles it automatically.

path.parse and path.format

javascript
// Break a path into its components path.parse('/home/jatin/app/server.js') // { // root: '/', // dir: '/home/jatin/app', // base: 'server.js', // ext: '.js', // name: 'server' // } // Build a path from components path.format({ dir: '/home/jatin/app', name: 'server', ext: '.js' }) // '/home/jatin/app/server.js'

The process Object

process is a global object — no import needed — that gives you access to the Node.js runtime environment. It is one of the most-used globals in Node.js.

Environment Variables

Environment variables are the correct way to configure a Node.js application. They keep secrets out of source code and allow the same code to behave differently in development, staging, and production.

javascript
// Access environment variables const dbUrl = process.env.DATABASE_URL; const port = process.env.PORT || 3000; // with a default const nodeEnv = process.env.NODE_ENV; // 'development', 'production', 'test' if (!dbUrl) { console.error('DATABASE_URL is required'); process.exit(1); }

Set them when running Node.js:

bash
# Inline (macOS/Linux) DATABASE_URL=postgres://localhost/mydb PORT=8080 node server.js # Or export first export DATABASE_URL=postgres://localhost/mydb node server.js

In practice you use the dotenv package to load a .env file during development:

bash
# .env (never commit this file to git) DATABASE_URL=postgres://localhost:5432/myapp_dev PORT=3000 JWT_SECRET=local-dev-secret-change-in-production
javascript
// At the very top of your entry file require('dotenv').config(); // Now process.env has everything from .env const port = process.env.PORT; // '3000'

We cover dotenv and environment configuration properly in P-6. For now, know that process.env is where you read configuration.

Command-Line Arguments

javascript
// node script.js --name=Jatin --count=5 console.log(process.argv); // [ // '/usr/local/bin/node', // process.argv[0] — path to node binary // '/Users/jatin/script.js', // process.argv[1] — path to your script // '--name=Jatin', // process.argv[2] — first argument // '--count=5' // process.argv[3] — second argument // ] // Your arguments start at index 2 const args = process.argv.slice(2);

For anything beyond trivial argument parsing, use a library like minimist or the built-in util.parseArgs (Node 18+):

javascript
import { parseArgs } from 'node:util'; const { values } = parseArgs({ args: process.argv.slice(2), options: { name: { type: 'string' }, count: { type: 'string' }, verbose: { type: 'boolean', short: 'v' }, }, }); console.log(values.name); // 'Jatin' console.log(values.count); // '5' console.log(values.verbose); // true if --verbose or -v was passed

Other Useful process Properties

javascript
process.version // 'v22.11.0' — Node.js version process.platform // 'darwin', 'linux', 'win32' process.arch // 'x64', 'arm64' process.pid // Process ID (e.g. 12345) process.cwd() // Current working directory process.uptime() // Seconds since process started process.memoryUsage() // { rss, heapTotal, heapUsed, external }

Exiting the Process

javascript
// Exit with success (code 0) process.exit(0); // Exit with failure (non-zero code) process.exit(1); // Conventional codes: // 0 = success // 1 = general error // 2 = misuse of shell built-ins

Use process.exit(1) when your application detects a fatal configuration error at startup — missing required environment variables, unable to connect to the database, etc. Under normal operation, Node.js exits naturally when there is no more work to do.

Listening for Process Events

javascript
// Handle uncaught exceptions (last resort — don't rely on this) process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); process.exit(1); // always exit after uncaughtException }); // Handle unhandled Promise rejections process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); process.exit(1); }); // Graceful shutdown on SIGTERM (sent by Docker, Kubernetes, PM2) process.on('SIGTERM', async () => { console.log('SIGTERM received — shutting down gracefully'); await server.close(); await db.disconnect(); process.exit(0); }); // Graceful shutdown on Ctrl+C process.on('SIGINT', async () => { console.log('SIGINT received — shutting down'); process.exit(0); });

Graceful shutdown is covered in depth in the Practitioner phase. For now, know that SIGTERM is the signal your process receives when Docker or Kubernetes is stopping it, and you should listen for it to clean up open connections before exiting.


Putting It Together: A Practical Script

Here is a small utility that reads a JSON config file, uses environment variables for overrides, and logs the result to a file — combining everything from this module:

javascript
// scripts/show-config.js import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main() { // 1. Read config file relative to this script's location const configPath = path.join(__dirname, '..', 'config', 'app.json'); let config; try { const raw = await fs.readFile(configPath, 'utf8'); config = JSON.parse(raw); } catch { console.log('No config file found — using defaults'); config = { port: 3000, env: 'development' }; } // 2. Allow environment variables to override if (process.env.PORT) config.port = Number(process.env.PORT); if (process.env.NODE_ENV) config.env = process.env.NODE_ENV; // 3. Write the resolved config to a log file const logDir = path.join(__dirname, '..', 'logs'); await fs.mkdir(logDir, { recursive: true }); const logPath = path.join(logDir, 'config.log'); const logLine = `[${new Date().toISOString()}] ${JSON.stringify(config)}\n`; await fs.writeFile(logPath, logLine, { flag: 'a' }); console.log('Resolved config:', config); console.log('Logged to:', logPath); } main().catch((err) => { console.error(err); process.exit(1); });

Run it:

bash
PORT=8080 NODE_ENV=production node scripts/show-config.js

Summary

  • fs/promises is the module to use for all file operations in new code. It returns Promises and works with async/await. Avoid callback-style fs and never use sync variants (readFileSync) in servers.
  • path.join for safe path concatenation. path.resolve for absolute paths. Always use these instead of string concatenation.
  • __dirname (CommonJS) or the import.meta.url pattern (ESM) to get the directory of the current file. Never use bare relative paths with fs — they depend on cwd, which is fragile.
  • process.env for configuration and secrets. Read at startup, fail fast if required variables are missing.
  • process.argv for command-line arguments. Slice at index 2. Use util.parseArgs for structured parsing.
  • process.exit(1) for fatal startup errors. Handle SIGTERM for graceful shutdown in long-running processes.

Next: asynchronous JavaScript — callbacks, Promises, async/await, and a first look at how the event loop model means Node.js never has to wait.

Discussion