Warm-up · Activity 1 of 7
// B3.1 · ~38 min · Beginner
Paths and file URLs
After this lesson you can build paths that work on every system and from every folder, and turn paths into file URLs and back.
Lesson 1 of 5 in B3 Files and paths
You will be able to
- Build and take apart paths with node:path, and use path.posix or path.win32 for the same result everywhere
- Tell process.cwd() from import.meta.dirname, and reach files next to a module from any folder
- Use file URLs: new URL with import.meta.url, fileURLToPath and pathToFileURL
Predict · Activity 2 of 7
Predict before you read on. You are in /home/ada and run node shop/src/main.js. What does it print?
// /home/ada/shop/src/main.js import path from "node:path"; console.log(path.resolve("data.json"));Practice · Activity 3 of 7
Fill in the function so that the line prints notes.txt, wherever you run it.
path.posix.____("docs", "..", "notes.txt") // "notes.txt"path.posix.("docs", "..", "notes.txt")Practice · Activity 4 of 7
Match each call on "/home/ada/notes/todo.txt" to what it returns.
Practice · Activity 5 of 7
A Windows path arrives in a program that runs on Linux, and the program uses path.posix. What does it print?
import path from "node:path"; console.log(path.posix.basename("C:\\temp\\myfile.html"));Brain teaser · Activity 6 of 7
Brain teaser. A user types the file name "/etc/passwd" for an upload. What do the two lines print?
import path from "node:path"; const name = "/etc/passwd"; // typed by a user console.log(path.posix.join("/srv/uploads", name)); console.log(path.posix.resolve("/srv/uploads", name));Apply · Activity 7 of 7
Mini-task, local. In a folder shop with "type": "module", put {"tea": 2.5} into data/prices.json and write src/main.js. It builds the path of data/prices.json from import.meta.dirname, reads and parses the file, and prints the path, its file URL (pathToFileURL) and the price of tea. Run it from shop with node src/main.js, then from another folder with the full path to main.js: the output must be the same.
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
Paths for a notes app
main.js works with paths in three ways: pure string work with path.posix and path.win32, the module's own place with import.meta, and a file next to it, found by path and by URL. data.json sits next to main.js. Run node main.js; the last three groups give the same output from any folder.
main.js
// Paths for a notes app: string work with node:path, then the module's own place.
import path from "node:path";
import {readFileSync} from "node:fs";
import {fileURLToPath, pathToFileURL} from "node:url";
// 1. String work. path.posix gives the same result on every system.
const note = "/home/ada/notes/2026/todo.txt";
console.log(path.posix.dirname(note));
console.log(path.posix.parse(note).name, path.posix.extname(note));
console.log(path.posix.join("/home/ada/notes", "..", "photos", "cat.png"));
console.log(path.posix.relative("/home/ada/notes", note));
console.log(path.win32.join("C:\\Users\\ada", "notes", "todo.txt"));
// 2. Where is this module? The same from any folder you run node in.
console.log(path.basename(import.meta.filename));
console.log(import.meta.filename === fileURLToPath(import.meta.url));
console.log(import.meta.dirname === path.dirname(import.meta.filename));
// 3. Two ways to the file next to this module.
const byPath = path.join(import.meta.dirname, "data.json");
const byUrl = new URL("./data.json", import.meta.url);
console.log(byUrl.protocol, fileURLToPath(byUrl) === byPath);
console.log(JSON.parse(readFileSync(byUrl, "utf8")).title);
// 4. In a URL, a space becomes %20.
console.log(pathToFileURL("/home/ada/my notes/todo.txt", {windows: false}).href);
data.json
{"title": "My notes"}
Run it with
node main.jsOutput
/home/ada/notes/2026
todo .txt
/home/ada/photos/cat.png
2026/todo.txt
C:\Users\ada\notes\todo.txt
main.js
true
true
file: true
My notes
file:///home/ada/my%20notes/todo.txt- path.posix and path.win32 compute the same strings on Linux, macOS and Windows, so these lines never change.
- import.meta.filename is fileURLToPath(import.meta.url), and import.meta.dirname is its dirname: three views of one place.
- readFileSync accepts the URL object directly; the path built with join leads to the same file.
- {windows: false} asks for a POSIX path, so the URL is the same on Windows too.
Exercises
Exercise 1 of 3
Your own extname
Write extname(p) for POSIX paths, as path.posix.extname does it: take the last part after the last /, and return everything from its last dot on. If that part has no dot, or its only dot is the first character (".env"), return "". "archive.tar.gz" gives ".gz", "index." gives ".". Pure string work, so it 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
Hint 1
p.lastIndexOf("/") finds the last separator; slice after it to get the last part.
Hint 2
In that part, lastIndexOf(".") finds the last dot.
Hint 3
Only a dot after position 0 starts an extension: return dot > 0 ? base.slice(dot) : "".
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// The extension of the last part of a POSIX path: "todo.txt" -> ".txt".
export function extname(p) {
const base = p.slice(p.lastIndexOf("/") + 1);
const dot = base.lastIndexOf(".");
return dot > 0 ? base.slice(dot) : "";
}
console.log(extname("/home/ada/notes/todo.txt"));
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
// The extension of the last part of a POSIX path: "todo.txt" -> ".txt".
export function extname(p) {
return p.slice(p.indexOf("."));
}
console.log(extname("/home/ada/notes/todo.txt"));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {extname} from './main.js';
const check = (p, want) => assert.equal(extname(p), want, `extname(${JSON.stringify(p)}) returned ${JSON.stringify(extname(p))}, expected ${JSON.stringify(want)}`);
test('a plain file name', () => {
check('todo.txt', '.txt');
check('/home/ada/notes/todo.txt', '.txt');
});
test('only the last dot counts', () => {
check('archive.tar.gz', '.gz');
});
test('a dot in a folder name does not count', () => {
check('/home/ada/v1.2/notes', '');
});
test('no extension: no dot, or only a leading dot', () => {
check('README', '');
check('/home/ada/.env', '');
});
test('a trailing dot is an extension of its own', () => {
check('index.', '.');
});
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 3
Your own POSIX join
Write joinPosix(...parts) like path.posix.join: drop empty parts, join the rest with /, then tidy up. Several slashes become one, "." parts go, ".." removes the part before it, or stays at the start of a relative path; at the root, ".." stays at the root. A path that starts with / stays absolute, and an empty result is ".". Throw a TypeError when a part is not a string. You may ignore a slash at the very end. Pure string work, so it 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
Hint 1
Check every part first, then parts.filter((part) => part !== "").join("/") gives one string.
Hint 2
Split it at "/" and walk the segments: skip "" and ".", and for ".." pop the last kept segment.
Hint 3
Remember whether the string started with /. Keep ".." only in a relative path with nothing to pop, and return "." when nothing is left.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Joins POSIX path parts and tidies the result, like path.posix.join.
export function joinPosix(...parts) {
for (const part of parts) {
if (typeof part !== "string") throw new TypeError("every part must be a string, got " + typeof part);
}
const joined = parts.filter((part) => part !== "").join("/");
if (joined === "") return ".";
const absolute = joined.startsWith("/");
const kept = [];
for (const segment of joined.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") {
if (kept.length > 0 && kept.at(-1) !== "..") kept.pop();
else if (!absolute) kept.push("..");
} else {
kept.push(segment);
}
}
const body = kept.join("/");
return absolute ? "/" + body : body || ".";
}
console.log(joinPosix("docs", "..", "notes.txt"));
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
// Joins POSIX path parts and tidies the result, like path.posix.join.
export function joinPosix(...parts) {
return parts.join("/");
}
console.log(joinPosix("docs", "..", "notes.txt"));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {joinPosix} from './main.js';
const check = (parts, want) => {
const got = joinPosix(...parts);
assert.equal(got, want, `joinPosix(${parts.map((p) => JSON.stringify(p)).join(', ')}) returned ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`);
};
test('parts are joined with /', () => {
check(['/home/ada', 'notes', 'todo.txt'], '/home/ada/notes/todo.txt');
});
test('.. removes the part before it', () => {
check(['docs', '..', 'notes.txt'], 'notes.txt');
check(['/home/ada/shop/src', '..', 'data', 'prices.json'], '/home/ada/shop/data/prices.json');
});
test('several slashes and . parts are tidied', () => {
check(['a//', 'b'], 'a/b');
check(['./a', './b'], 'a/b');
});
test('a leading slash on a later part is glued on', () => {
check(['/srv/uploads', '/etc/passwd'], '/srv/uploads/etc/passwd');
});
test('.. at the start of a relative path stays, at the root it stops', () => {
check(['..', 'a'], '../a');
check(['/', '..', 'etc'], '/etc');
});
test('nothing left gives .', () => {
check([], '.');
check(['a', '..'], '.');
});
test('a part that is not a string throws a TypeError', () => {
assert.throws(() => joinPosix('notes', 2026), TypeError, 'joinPosix("notes", 2026) should throw a TypeError');
});
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 3 of 3
Files next to the module
notes.txt sits next to main.js. Write three functions that work from any folder you run node in. here(name) returns the absolute path of name in the folder of main.js, built with path.join and import.meta.dirname. hereUrl(name) returns a URL object for the same file, built with new URL and import.meta.url. readHere(name) reads that file as UTF-8 text through hereUrl. Run the tests with node --test.
This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
path.join(import.meta.dirname, name) gives the path; join puts the separator in between.
Hint 2
new URL(name, import.meta.url) resolves name against the URL of main.js.
Hint 3
readFileSync accepts a URL object: readFileSync(hereUrl(name), "utf8").
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import path from "node:path";
import {readFileSync} from "node:fs";
export function here(name) {
return path.join(import.meta.dirname, name);
}
export function hereUrl(name) {
return new URL(name, import.meta.url);
}
export function readHere(name) {
return readFileSync(hereUrl(name), "utf8");
}
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 path from "node:path";
import {readFileSync} from "node:fs";
export function here(name) {
return name;
}
export function hereUrl(name) {
return new URL(name);
}
export function readHere(name) {
return readFileSync(name, "utf8");
}
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {here, hereUrl, readHere} from './main.js';
test('here() is the file inside the folder of main.js', () => {
const got = here('notes.txt');
const folder = import.meta.dirname;
assert.ok(got.startsWith(folder) && got.endsWith('notes.txt') && got.length === folder.length + 'notes.txt'.length + 1, `here('notes.txt') returned ${JSON.stringify(got)}; expected notes.txt inside ${folder}`);
});
test('hereUrl() is a file: URL next to main.js', () => {
const got = hereUrl('notes.txt');
assert.ok(got instanceof URL, 'hereUrl() should return a URL object');
assert.equal(got.href, new URL('./notes.txt', import.meta.url).href, `hereUrl('notes.txt') returned ${got.href}`);
});
test('readHere() reads the text of notes.txt', () => {
assert.equal(readHere('notes.txt'), 'buy tea\ncall Ben\n', `readHere('notes.txt') returned ${JSON.stringify(readHere('notes.txt'))}`);
});
notes.txt
buy tea
call Ben
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsCommon mistakes
Gluing a path together with +
// The folder as import.meta.dirname gives it: no slash at the end.
import {readFileSync} from "node:fs";
const folder = "/home/ada/shop/src";
console.log(readFileSync(folder + "notes.txt", "utf8"));
What Node.js prints
Error: ENOENT: no such file or directory, open '/home/ada/shop/srcnotes.txt'Why, and the fix
import.meta.dirname has no slash at the end, so + glues the names together and the file is looked for under a name that does not exist. path.join(import.meta.dirname, "notes.txt") puts in the separator of the system, and it never doubles one either.
A number as a path part
import path from "node:path";
const year = 2026;
console.log(path.join("notes", year, "todo.txt"));
What Node.js prints
TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received type number (2026)Why, and the fix
Every part of path.join must be a string; node:path does not convert numbers for you. Write String(year) or a template literal: path.join("notes", `${year}`, "todo.txt").
A folder path as the base of a URL
// Looks for data.json next to this module, but with the wrong base.
const url = new URL("data.json", import.meta.dirname);
console.log(url.href);
What Node.js prints
TypeError: Invalid URLWhy, and the fix
The base of new URL must itself be a URL, and import.meta.dirname is a path such as /home/ada/shop/src. Use import.meta.url as the base: new URL("data.json", import.meta.url). Or stay with paths: path.join(import.meta.dirname, "data.json").
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.