In-Memory Filesystem
mkdir, writeFile, readFile on a tree of maps. Normalize paths, reject escape via .., and decide whether writeFile creates parents.
- javascript
- trees
- interview
- filesystem
The prompt is mkdir, writeFile, readFile. People split('/') and treat .. as a folder named two dots, or they pop the segment stack until they walk off the root into whatever happens to sit next to it. An in-memory filesystem is a tree of maps under one root. Path normalization is the API. .. must not escape that root.
This is not Node fs, not the File System Access API, and not a disk. No file descriptors, no Buffer encodings, no permissions, no cwd beyond the virtual root. If they want a UI on top, that is the file explorer tree. This page is the store.
Key takeaways
- Directories are maps of names to nodes. Files are leaves with string content.
- Normalize: split on
/, drop empty and., pop on.., throw if..would leave the root.mkdircan take{ recursive }. Without it, the parent must already exist.writeFileoverwrites files and, in this implementation, does not create parent directories.readFilethrows on a missing path and on a directory. It does not returnundefined.
mkdir, writeFile, readFile, and paths that must not escape root
Each node is either a directory (type: 'dir' plus a Map of children) or a file (type: 'file' plus a string content). A Map beats a plain object for names like __proto__ and "constructor". The root is a directory you never replace.
class MemoryFS {
constructor() {
this.root = { type: 'dir', children: new Map() };
}
normalize(path) {
if (typeof path !== 'string' || path.length === 0) {
throw new TypeError('path must be a non-empty string');
}
const parts = [];
for (const part of path.split('/')) {
if (part === '' || part === '.') continue;
if (part === '..') {
if (parts.length === 0) {
throw new Error('EACCES: path escapes root');
}
parts.pop();
continue;
}
parts.push(part);
}
return parts;
}
dirAt(parts, create) {
let dir = this.root;
for (const name of parts) {
let next = dir.children.get(name);
if (!next) {
if (!create) throw new Error('ENOENT: ' + name);
next = { type: 'dir', children: new Map() };
dir.children.set(name, next);
} else if (next.type !== 'dir') {
throw new Error('ENOTDIR: ' + name);
}
dir = next;
}
return dir;
}
mkdir(path, { recursive = false } = {}) {
const parts = this.normalize(path);
if (parts.length === 0) return;
if (recursive) {
this.dirAt(parts, true);
return;
}
const parent = this.dirAt(parts.slice(0, -1), false);
const name = parts[parts.length - 1];
const existing = parent.children.get(name);
if (existing) {
if (existing.type === 'dir') {
throw new Error('EEXIST: ' + name);
}
throw new Error('EEXIST: file in the way');
}
parent.children.set(name, { type: 'dir', children: new Map() });
}
writeFile(path, content) {
const parts = this.normalize(path);
if (parts.length === 0) throw new Error('EISDIR: root');
const parent = this.dirAt(parts.slice(0, -1), false);
const name = parts[parts.length - 1];
const existing = parent.children.get(name);
if (existing && existing.type === 'dir') {
throw new Error('EISDIR: ' + name);
}
parent.children.set(name, { type: 'file', content: String(content) });
}
readFile(path) {
const parts = this.normalize(path);
if (parts.length === 0) throw new Error('EISDIR: root');
const parent = this.dirAt(parts.slice(0, -1), false);
const node = parent.children.get(parts[parts.length - 1]);
if (!node) throw new Error('ENOENT');
if (node.type !== 'file') throw new Error('EISDIR');
return node.content;
}
}
The E* strings are documentation, not Node SystemError objects. Say that. Catch by message or, better, subclass Error with a code field if they want err.code === 'ENOENT' (error types).
Normalize, then walk
| Input | normalize |
Notes |
|---|---|---|
/ |
[] |
The root. mkdir is a no-op. readFile / writeFile throw EISDIR. |
/a/b |
['a', 'b'] |
Absolute and a/b are the same: one root, no cwd. |
/a/./b |
['a', 'b'] |
. dropped. |
/a/x/../b |
['a', 'b'] |
.. pops x. |
/../etc/passwd |
throw | Would pop below root. This is the security question. |
//a//b/ |
['a', 'b'] |
Empty segments from repeated slashes dropped. |
'' |
TypeError |
Not a path. |
Numbered walk after normalize:
- Start at
this.root. - For each segment except you may stop before the last (parent lookup),
Map.getthe name. - Missing +
create === false:ENOENT. - Missing +
create === true: insert a new dir (mkdir recursive). - Present but
type === 'file'when you needed a dir:ENOTDIR.
That walk is the same shape as get/set nested objects, with maps instead of plain objects and an explicit “do not leave the root” rule .. does not have in dot paths.
writeFile does not create parents
Node’s fs.writeFile does not mkdir the parent. This implementation matches that: writeFile('/a/b.txt', 'hi') throws ENOENT unless /a already exists. State it in the first sentence of the method if they did not specify.
The other legal product is { createParents: true } that calls dirAt(parentParts, true) before writing. Pick one. Mixing “sometimes mkdir” without a flag is how callers lose files into surprise directories.
writeFile does overwrite an existing file. Second write replaces content. Writing where a directory sits is EISDIR, not a clobber.
mkdir('/a/b', { recursive: true }) creates both segments. mkdir('/a/b') without recursive requires /a and throws EEXIST if /a/b already exists as a directory (Node does this). Existing-dir-as-success is a fine variant if you say it; do not silently convert a file into a directory.
readFile throws
Missing path: throw. Directory: throw. Do not return undefined or null. Callers cannot tell “empty file” from “no file” if you return a falsey sentinel. Empty file is ''.
const fs = new MemoryFS();
fs.mkdir('/docs', { recursive: true });
fs.writeFile('/docs/readme.txt', 'hello');
fs.readFile('/docs/readme.txt'); // 'hello'
fs.writeFile('/docs/readme.txt', 'world');
fs.readFile('/docs/readme.txt'); // 'world'
fs.readFile('/docs'); // throws EISDIR
fs.readFile('/nope'); // throws ENOENT
fs.writeFile('/missing/x.txt', 'z'); // throws ENOENT (parent not created)
fs.mkdir('/../../etc'); // throws: escapes root
Relative .. inside the tree is allowed: /docs/../docs/readme.txt normalizes to /docs/readme.txt. Escape is only “pop when the stack is empty.”
What you are not building
Not Node fs: no open, no flags, no encoding: 'utf8' vs Buffer, no fs.promises, no umask. Not File System Access: no user prompt, no real disk. Not a trie of characters; names are whole path segments in a Map.
Out of scope unless they add a round: rm, readdir, stat, rename, copy, watches, binary files. readdir is [...dir.children.keys()] if they do. Flattening the tree into dot paths is a different prompt (flatten / unflatten).
Footguns of an in-memory FS
..as a literal key. If you skip normalize,children.get('..')grows a folder that looks like parent but is not. Escape bugs hide there.- Popping past root into
undefined. Thenundefined.childrenthrows, or worse, you create a second tree. Throw instead. - Plain objects as directories.
{'__proto__': file}is a prototype mess. UseMap. writeFilemkdir by accident. Decide. This page’s code does not create parents.readFileon a dir returning the Map. Leaks the store. ThrowEISDIR.- Treating
''or'/'as a file name. Root is a dir. Empty string is not a segment after normalize (leading slash). A file named''would only appear if you refused to drop empty segments; don’t. - Shared mutable content.
readFilereturns the string. Strings are immutable. If you later store objects as content, clone on read.
Windows backslashes are not /. This API is POSIX-shaped. Do not split(/[/\\]/) unless they ask.
Interview answer (out loud)
“I keep a directory tree where each dir is a Map of names to file or dir nodes. Every operation normalizes the path: split on slash, ignore empty and
., pop on.., and throw if..would leave the root.mkdirhas a recursive flag; without it the parent must exist.writeFileoverwrites files and does not create parents in my implementation, so a missing/aon/a/b.txtis ENOENT.readFilethrows on missing and on directories. This is not Node fs. I would not use a plain object as the directory, because keys like__proto__are a mess.”
Further reading
Related on this site
- Get and set nested object paths
- File explorer tree view
- Flatten and unflatten objects
- Map and Set
- Error types and custom errors
Related guides
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.