Skip to content
aviral gupta

// 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

Start of the module

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
  1. Warm-up · Activity 1 of 7

    Warm-up from B2.4: which statements about an ES module are true? Pick all that apply.

    Select all that apply.

  2. 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"));
    
  3. 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")
  4. Practice · Activity 4 of 7

    Match each call on "/home/ada/notes/todo.txt" to what it returns.

  5. 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"));
    
  6. 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));
    
  7. 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.js

Output

/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
  1. Hint 1

    p.lastIndexOf("/") finds the last separator; slice after it to get the last part.

  2. Hint 2

    In that part, lastIndexOf(".") finds the last dot.

  3. 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.js

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

node --test
Download learnrun.js

Exercise 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
  1. Hint 1

    Check every part first, then parts.filter((part) => part !== "").join("/") gives one string.

  2. Hint 2

    Split it at "/" and walk the segments: skip "" and ".", and for ".." pop the last kept segment.

  3. 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.js

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

node --test
Download learnrun.js

Exercise 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
  1. Hint 1

    path.join(import.meta.dirname, name) gives the path; join puts the separator in between.

  2. Hint 2

    new URL(name, import.meta.url) resolves name against the URL of main.js.

  3. 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.js

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

node --test
Download learnrun.js

Common 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 URL

Why, 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.

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

node:path is string work

path.join("docs", "..", "notes.txt") glues the parts with the separator and tidies the result: notes.txt. path.resolve works from right to left until it has an absolute path, and uses the current folder if none is given. dirname, basename, extname and parse take a path apart; relative gives the way from one path to another. None of them looks at the disk: a path to a missing file is computed just the same. By default node:path follows the system it runs on: / on Linux and macOS, \ on Windows (path.sep). path.posix and path.win32 give the same result on every system, which is what you want for tests and for paths that came from somewhere else.

Relative to what?

A relative path such as "data/prices.json", given to path.resolve or to node:fs, is resolved against process.cwd(): the folder you ran node from, not the folder of the file. Run the same script from another folder and it looks somewhere else. import.meta.dirname is the folder of the module itself, and import.meta.filename its full path; both stay the same wherever you start node. So a file that belongs to your code is found with path.join(import.meta.dirname, "..", "data", "prices.json"). Build paths with join, not with +: import.meta.dirname has no slash at the end, so dirname + "notes.txt" gives ...srcnotes.txt.

File URLs

import.meta.url is the module's place as a file: URL, such as file:///home/ada/shop/src/main.js. new URL("./data.json", import.meta.url) resolves a name against it, and node:fs accepts the URL object as a path. A URL is not a path: a space is %20 in it, and on Windows it starts file:///C:/. fileURLToPath(url) turns it into a real path for this system and decodes %20; url.pathname does neither. pathToFileURL(path) goes the other way and encodes what a URL needs. new URL needs a URL as its base, so new URL("data.json", import.meta.dirname) throws TypeError: Invalid URL.

Sources

Last reviewed October 3, 2026