Working with File Paths
Filesystem code lives and dies by getting paths right. This page focuses on the practical path patterns that surround fs work — anchoring to the current module, resolving user input safely, and avoiding the classic cross-platform and security mistakes. It builds on the API reference in The path Module.
The cwd trap
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
// ✗ Fragile — depends on the caller's working directory
await readFile('config/default.json', 'utf8')
// ✓ Robust — relative to THIS file, wherever node runs
await readFile(join(import.meta.dirname, 'config', 'default.json'), 'utf8')__dirname in both module systems
CommonJS | ES Modules | |
|---|---|---|
Directory of this file |
|
|
This file’s full path |
|
|
As a URL | — |
|
ESM, older Node: reconstruct __dirname
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const dataPath = join(__dirname, 'data.json')Why these names exist (and differ between CJS and ESM) is explained in The Module Wrapper Function.
join vs resolve, for files
join glues segments; resolve produces a guaranteed absolute path. For reading bundled assets, join(import.meta.dirname, …) is the workhorse. For turning user-supplied paths into absolutes, resolve — but then you must validate (next section):
import { join, resolve } from 'node:path'
join('src', 'lib', 'x.js') // 'src/lib/x.js' (relative, normalized)
resolve('src', 'lib', 'x.js') // '/cwd/src/lib/x.js' (absolute)
resolve('/etc', 'passwd') // '/etc/passwd' (absolute arg wins)Path traversal — a real security hole
Safe: confine resolved paths to a base directory
import { join, resolve, sep } from 'node:path'
function safeJoin(baseDir, userPath) {
const base = resolve(baseDir)
const target = resolve(base, userPath)
// target must be base itself or live under base + separator
if (target !== base && !target.startsWith(base + sep)) {
throw new Error('Path traversal blocked')
}
return target
}
safeJoin('/srv/uploads', 'photo.jpg') // ok → /srv/uploads/photo.jpg
safeJoin('/srv/uploads', '../../etc/passwd') // throwsCross-platform pitfalls
Build paths with
path.join/resolve, never string concatenation — separators differ (/vs\\).Use
path.sepandpath.delimiterinstead of hardcoding/or:.For URLs, use
path.posixor theURLclass — web paths are always/, even on Windows.Case sensitivity differs: Linux is case-sensitive, macOS/Windows usually are not — don’t rely on case to distinguish files.
Dissecting and rebuilding paths
import { basename, dirname, extname, parse } from 'node:path'
const p = '/home/ada/report.final.pdf'
basename(p) // 'report.final.pdf'
basename(p, '.pdf') // 'report.final'
dirname(p) // '/home/ada'
extname(p) // '.pdf'
parse(p) // { root, dir, base, name: 'report.final', ext: '.pdf' }Quick reference
Need | Use |
|---|---|
Path relative to this file |
|
Force an absolute path |
|
Confine user input to a dir |
|
Filename / dir / extension |
|
Cross-platform URL path |
|