$ cnpm install upath
The battle-tested path library that just works -- everywhere.
Trusted for over a decade. 20 million downloads per week. Zero runtime dependencies. One import and every path in your project is consistent -- no more \ vs / headaches across Windows, Linux, and macOS.
import upath from 'upath' // use exactly like path — but it always works
Node.js path is platform-dependent. Run the same code on Windows and you get \ separators that break everything:
// On Windows, path gives you this:
path.normalize('c:\\windows\\..\\nodejs\\path') // 'c:\\nodejs\\path' ← backslashes everywhere
path.join('some/nodejs\\windows', '../path') // 'some/path' ← WRONG result
path.parse('c:\\Windows\\dir\\file.ext') // { dir: '', base: 'c:\\Windows\\dir\\file.ext' } ← BROKEN
// upath gives you this — on ALL platforms:
upath.normalize('c:\\windows\\..\\nodejs\\path') // 'c:/nodejs/path' ✓
upath.join('some/nodejs\\windows', '../path') // 'some/nodejs/path' ✓
upath.parse('c:\\Windows\\dir\\file.ext') // { dir: 'c:/Windows/dir', base: 'file.ext' } ✓
The irony? Windows works perfectly fine with forward slashes inside Node.js. The \ convention is purely cosmetic -- and it breaks everything downstream: path comparisons, URLs, template literals, config files, CI pipelines, globs.
upath fixes this. It wraps every path function to normalize \ to / in all results. Same API, same behavior, zero surprises.
upath is a thin dynamic proxy over Node's built-in path module. Zero runtime dependencies -- its only import is node:path itself.
path via Object.entries()sep, which is forced to '/')path functions added in future Node versions are automatically wrapped -- no code changes neededThis means upath is always in sync with your Node.js version. It adds nothing, removes nothing -- just normalizes. Its test suite includes 421 tests, with test vectors extracted directly from Node.js's own path test suite to verify identical behavior.
npm install upath
// ESM
import upath from 'upath'
// or import specific functions
import { normalize, joinSafe, addExt } from 'upath'
// CJS
const upath = require('upath')
upath proxies all functions and properties from Node.js path (basename, dirname, extname, format, isAbsolute, join, normalize, parse, relative, resolve, toNamespacedPath, matchesGlob), converting any \ in results to /.
Additionally, upath.sep is always '/' and upath.VERSION provides the package version string.
path vs upathEvery path function works the same, but with \ → / normalization. Here's where it matters:
upath.normalize(path)upath.normalize('c:\\windows\\nodejs\\path') ✓ 'c:/windows/nodejs/path'
path.normalize → 'c:\\windows\\nodejs\\path'
upath.normalize('/windows\\unix/mixed') ✓ '/windows/unix/mixed'
path.normalize → '/windows\\unix/mixed'
upath.normalize('\\windows\\..\\unix/mixed/') ✓ '/unix/mixed/'
path.normalize → '\\windows\\..\\unix/mixed/'
upath.join(paths...)upath.join('some/nodejs\\windows', '../path') ✓ 'some/nodejs/path'
path.join → 'some/path' ← WRONG
upath.join('some\\windows\\only', '..\\path') ✓ 'some/windows/path'
path.join → 'some\\windows\\only/..\\path' ← BROKEN
upath.parse(path)upath.parse('c:\\Windows\\dir\\file.ext')
✓ { root: '', dir: 'c:/Windows/dir', base: 'file.ext', ext: '.ext', name: 'file' }
path.parse('c:\\Windows\\dir\\file.ext')
✗ { root: '', dir: '', base: 'c:\\Windows\\dir\\file.ext', ext: '.ext', name: 'c:\\Windows\\dir\\file' }
These solve real pain points that path ignores entirely. See docs/API.md for full input/output tables.
upath.toUnix(path)Converts all \ to / and consolidates duplicate slashes, without performing any normalization.
upath.toUnix('.//windows\\//unix//mixed////') // './windows/unix/mixed/'
upath.toUnix('\\\\server\\share') // '//server/share'
upath.toUnix('C:\\Users\\test') // 'C:/Users/test'
upath.normalizeSafe(path)The pain: path.normalize() silently strips leading ./ from relative paths and // from UNC paths. Your ./src/index.ts becomes src/index.ts, breaking ESM imports, webpack configs, and anything that depends on the explicit relative prefix.
normalizeSafe normalizes the path but preserves meaningful leading ./ and //:
upath.normalizeSafe('./dep') ✓ './dep'
path.normalize → 'dep' ← lost ./
upath.normalizeSafe('./path/../dep') ✓ './dep'
path.normalize → 'dep' ← lost ./
upath.normalizeSafe('//server/share/file') ✓ '//server/share/file'
path.normalize → '/server/share/file' ← lost / (broken UNC)
upath.normalizeSafe('//./c:/temp/file') ✓ '//./c:/temp/file'
path.normalize → '/c:/temp/file' ← lost //. (broken UNC)
upath.normalizeTrim(path)The pain: Normalized paths often end with / -- which breaks string comparisons and some file-system APIs. './src/' !== './src' even though they're the same directory.
Like normalizeSafe(), but also trims any trailing /:
upath.normalizeTrim('./../dep/') // '../dep'
upath.normalizeTrim('.//windows\\unix/mixed/') // './windows/unix/mixed'
upath.joinSafe([path1][, path2][, ...])The pain: path.join() has the same ./ and // stripping problem as path.normalize(). Your './config' becomes 'config' after joining, silently breaking the relative import semantics you needed.
joinSafe works like path.join() but preserves leading ./ and //:
upath.joinSafe('./some/local/unix/', '../path') ✓ './some/local/path'
path.join → 'some/local/path' ← lost ./
upath.joinSafe('//server/share/file', '../path') ✓ '//server/share/path'
path.join → '/server/share/path' ← lost / (broken UNC)
upath.addExt(filename, [ext])The pain: if (!file.endsWith('.js')) file += '.js' scattered across your codebase -- and it still has the bug where file.json doesn't get .js appended but file.cjs does.
Adds .ext to filename, but only if it doesn't already have the exact extension:
upath.addExt('myfile', '.js') // 'myfile.js'
upath.addExt('myfile.js', '.js') // 'myfile.js' (unchanged — already has it)
upath.addExt('myfile.txt', '.js') // 'myfile.txt.js'
upath.trimExt(filename, [ignoreExts], [maxSize=7])The pain: path has no function to strip an extension while keeping the directory. path.basename(f, ext) loses the directory. And what counts as an "extension" when your file is app.config.local.js?
Trims the extension from a filename. Extensions longer than maxSize chars (including the dot) are not considered valid. Extensions in ignoreExts are not trimmed:
upath.trimExt('my/file.min.js') // 'my/file.min'
upath.trimExt('my/file.min', ['min'], 8) // 'my/file.min' (.min ignored)
upath.trimExt('../my/file.longExt') // '../my/file.longExt' (too long, not an ext)
upath.removeExt(filename, ext)The pain: path.basename('file.json', '.js') turns 'file.json' into 'file.json'? Actually no -- it turns 'file.js' into 'file' but it also corrupts 'file.json' into... wait, it depends on the platform. Just use removeExt.
Removes the specific ext from filename, if present -- and only that exact extension:
upath.removeExt('file.js', '.js') // 'file'
upath.removeExt('file.txt', '.js') // 'file.txt' (unchanged — different ext)
upath.changeExt(filename, [ext], [ignoreExts], [maxSize=7])The pain: Changing .coffee to .js means trimming the old extension and adding the new one -- with edge cases around dotfiles, multi-segment extensions, and files with no extension at all. Every hand-rolled version of this has bugs.
Changes a filename's extension to ext. If it has no valid extension, the new extension is added. Extensions in ignoreExts are not replaced:
upath.changeExt('module.coffee', '.js') // 'module.js'
upath.changeExt('my/module', '.js') // 'my/module.js' (had no ext, adds it)
upath.changeExt('file.min', '.js', ['min'], 8) // 'file.min.js' (.min ignored)
upath.defaultExt(filename, [ext], [ignoreExts], [maxSize=7])The pain: You want to ensure a file has an extension, but only if it doesn't already have one. And you need control over what counts as "already having one" -- is .min an extension or part of the name?
Adds .ext only if the filename doesn't already have any valid extension. Extensions in ignoreExts are treated as if absent:
upath.defaultExt('file', '.js') // 'file.js'
upath.defaultExt('file.ts', '.js') // 'file.ts' (already has extension)
upath.defaultExt('file.min', '.js', ['min'], 8) // 'file.min.js' (.min ignored)
Note: In all extension functions, you can use both .ext and ext -- the leading dot is always handled correctly.
upath is a foundational dependency in the Node.js ecosystem, trusted by 1,300+ packages on npm including:
If you run npm ls upath in a non-trivial Node.js project, there's a good chance it's already there.
import and require() out of the box via package.json exports.docs/API.md for complete input/output tables generated from the test suite.const upath = require('upath') works as before. All functions are available directly on the module (no .default needed).join(), resolve(), and joinSafe() params narrowed from any[] to string[]. Add explicit casts if you pass non-string args: join(myVar as string)._makeLong removed -- use toNamespacedPath instead (available since Node 8.3).import { normalize, join, toUnix } from 'upath' works in addition to the default import.String objects rejected -- new String('foo') no longer accepted; use plain string primitives.See CHANGELOG.md for the full list of changes.
Contributions are welcome! Please open an issue or pull request on GitHub.
git clone https://github.com/anodynos/upath.git
cd upath
npm install
npm test # 421 tests
npm run test:integration # CJS/ESM integration tests
upath has been free and MIT-licensed for over a decade. If it saves you time or your company depends on it, please consider sponsoring its continued maintenance:
Running npm fund in your project will also show you if upath is in your tree.
MIT -- Copyright (c) 2014-2026 Angelos Pikoulas
Copyright 2013 - present © cnpmjs.org | Home |