Node.js / Basic: the Node.js runtime / Lesson 4
Files, paths, and JSON
Read and write small data files safely while distinguishing bytes, text, paths, and validated application data.
On this page
Lesson goals
- Explain the concepts and trade-offs behind files, paths, and json.
- Predict and verify both examples, including their outputs and cleanup behavior.
- Diagnose the three common failures and apply the worked solutions to related inputs.
Files contain bytes
A filesystem stores bytes rather than JavaScript objects. readFile without an encoding returns a Buffer; requesting utf8 decodes text. JSON.parse then turns JSON text into values, and a separate validation step establishes whether those values satisfy your application contract. These are three different operations with different failures. Valid JSON can still contain the wrong fields or types. For small trusted configuration files, reading the whole file is simple. For large or untrusted files, enforce a size limit or use a streaming format rather than allocating memory proportional to an unlimited input.
Resolve paths deliberately
A relative filesystem path is normally interpreted from process.cwd(), which can change depending on how a program starts. A module-relative resource can instead use new URL with import.meta.url. path.join combines path segments using platform conventions; it does not authorize a user-selected location. Never assume joining a base directory with untrusted text prevents traversal. Prefer server-generated filenames or a constrained identifier lookup. Symbolic links and concurrent changes can also undermine simplistic path checks, so a secure storage design must consider filesystem permissions and who can modify the directory.
Write, read, and clean up
The first example creates an isolated temporary directory, serializes a small object, writes it, and reads it back with an explicit encoding. It validates the field before using it and removes the directory in finally. The output proves the round trip preserved the intended value; it does not prove the file would survive a machine crash. Await each dependent operation so a read cannot race an unfinished write. Never run these demonstrations against an important existing file. Temporary ownership also makes cleanup predictable and prevents one learner’s example from depending on another run.
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const dir = await mkdtemp(join(tmpdir(), 'json-'));
try {
const file = join(dir, 'settings.json');
await writeFile(file, JSON.stringify({ retries: 2 }), 'utf8');
const data = JSON.parse(await readFile(file, 'utf8'));
if (!Number.isInteger(data.retries)) throw new Error('Invalid retries');
console.log(data.retries);
} finally { await rm(dir, { recursive: true, force: true }); }
2
Encoding produces text for JSON.parse. Field validation occurs after syntax parsing, and finally removes only this example’s own directory.
Handle expected filesystem failures
Filesystem errors expose stable codes such as ENOENT or EACCES. Test the code when deciding whether a missing optional file should use a default; do not match an entire operating-system-specific message. The second example catches only the missing-file condition and rethrows anything else. Converting every read error into empty data can hide permission failures or a damaged deployment. Avoid checking existence and then reading as separate guarantees: the file may change between those operations. Attempt the needed operation and handle its actual result instead.
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const dir = await mkdtemp(join(tmpdir(), 'missing-'));
try {
try { await readFile(join(dir, 'absent.json'), 'utf8'); }
catch (error) {
if (error.code !== 'ENOENT') throw error;
console.log('optional file absent');
}
} finally { await rm(dir, { recursive: true, force: true }); }
optional file absent
The directory is guaranteed to start empty. Only ENOENT selects the optional-file fallback.
Know the limits of file persistence
Writing JSON is useful for exercises, exports, and some controlled local tools. It is not automatically a transaction system. Two requests that read the same value and then overwrite the file can lose one update. A temporary-file-and-rename strategy can reduce exposure to partial replacement, but durability and cross-platform guarantees require careful analysis, and multi-process coordination remains separate. Use a transactional database when concurrent updates and recovery matter. Close any manually opened file handles in finally. Treat cleanup as a resource obligation, while treating successful persistence as an explicit contract you must define and verify.
Common errors and fixes
JSON.parse throws SyntaxError
- Why it happens
- The text is incomplete JSON or contains unsupported syntax.
- How to fix it
- Report malformed content and preserve evidence without logging secrets.
- Prevent it next time
- Serialize with JSON.stringify and test interrupted or malformed input.
ENOENT in a different working directory
- Why it happens
- A relative path depended on where Node was launched.
- How to fix it
- Use an intentional base directory or module-relative URL.
- Prevent it next time
- Test launching from another directory.
An update disappears
- Why it happens
- Concurrent read-modify-write operations overwrote each other.
- How to fix it
- Use transactions or enforce an appropriate single-writer design.
- Prevent it next time
- Test competing updates and document persistence guarantees.
Check your understanding
1. A JSON file contains {"retries":"2"}. Should the first example accept it?
Show worked solution
No. Parsing succeeds, but retries remains a string, so Number.isInteger returns false. Reject it unless the documented schema explicitly permits conversion. Syntax validity and business validity are separate checkpoints; silent coercion would change the file contract.
2. Explain why exists-then-read does not guarantee a successful read.
Show worked solution
Another actor can remove or replace the file after the existence check. Call readFile directly, catch ENOENT when absence is expected, and rethrow unrelated errors. This reduces the race and bases the decision on the operation whose result actually matters.
Key takeaways
- A filesystem stores bytes rather than JavaScript objects.
- A relative filesystem path is normally interpreted from process.cwd(), which can change depending on how a program starts.
- Writing JSON is useful for exercises, exports, and some controlled local tools.