path2, upath, and upath2 are npm packages that extend or replace Node.js’s built-in path module to provide consistent cross-platform path handling, particularly for Windows-style paths in Unix environments or vice versa. They aim to normalize path separators, resolve paths reliably, and avoid common pitfalls when working with file systems across operating systems. While the native path module works well in most cases, these libraries offer additional utilities or stricter guarantees for specific use cases like tooling, bundlers, or cross-platform CLI applications.
When writing JavaScript tools that manipulate file paths—like bundlers, linters, or CLI apps—you quickly run into issues with Windows backslashes (\) versus Unix forward slashes (/). Node.js’s built-in path module helps, but it preserves platform-specific separators, which can break string-based logic. The path2, upath, and upath2 packages aim to solve this—but not all are viable today. Let’s compare them honestly.
path2First things first: path2 is deprecated. Its npm page states it’s no longer maintained, and its GitHub repo is archived. It was an early attempt to improve path handling but has been superseded. Do not use it in new code. We’ll mention it only for completeness.
// ❌ Avoid — path2 is deprecated
import path2 from 'path2';
const p = path2.join('src', 'components'); // Works, but unmaintained
Both upath and upath2 share the same core idea: always return paths with / separators, even on Windows. This avoids bugs like:
// Native path on Windows:
const p = path.join('src', 'utils'); // "src\\utils"
if (p.startsWith('src/utils')) { /* fails on Windows! */ }
By forcing /, string operations become reliable everywhere.
upath: The Original Normalizerupath wraps Node’s path methods and post-processes results to replace \ with /.
// ✅ upath — consistent forward slashes
import upath from 'upath';
const p1 = upath.join('src', 'components'); // "src/components"
const p2 = upath.resolve('./dist'); // "/absolute/path/to/dist" (with /)
const normalized = upath.normalize('a\\b/c'); // "a/b/c"
It also adds convenience methods like .toUnix() for arbitrary strings:
const clean = upath.toUnix('C:\\Users\\file.js'); // "C:/Users/file.js"
upath2: A Modern Repackagingupath2 is a fork of upath that preserves the exact same API but ships as a pure ESM package with built-in TypeScript definitions. Functionally, it behaves identically.
// ✅ upath2 — same behavior, ESM + TS
import upath from 'upath2';
const p1 = upath.join('src', 'components'); // "src/components"
const p2 = upath.resolve('./dist'); // "/absolute/path/to/dist" (with /)
const normalized = upath.normalize('a\\b/c'); // "a/b/c"
There are no additional features or bug fixes in upath2—just packaging differences.
All three claim to “fix” path handling, but their capabilities differ sharply.
| Package | join() | resolve() | Always uses /? |
|---|---|---|---|
path2 | ✅ | ✅ | ❌ (platform-dependent) |
upath | ✅ | ✅ | ✅ |
upath2 | ✅ | ✅ | ✅ |
Example showing the key difference:
// On Windows:
import path from 'path';
import upath from 'upath';
import upath2 from 'upath2';
console.log(path.join('a', 'b')); // "a\\b"
console.log(upath.join('a', 'b')); // "a/b"
console.log(upath2.join('a', 'b')); // "a/b"
Only upath and upath2 offer .toUnix() for sanitizing arbitrary path strings:
// upath
import upath from 'upath';
const clean = upath.toUnix('folder\\sub/file.txt'); // "folder/sub/file.txt"
// upath2
import upath from 'upath2';
const clean = upath.toUnix('folder\\sub/file.txt'); // "folder/sub/file.txt"
// path2 — no such method
All support .relative(), but again, only upath/upath2 guarantee /:
// upath
const rel = upath.relative('/project/src', '/project/src/utils'); // "utils"
// upath2
const rel = upath2.relative('/project/src', '/project/src/utils'); // "utils"
// path2 — may return backslashes on Windows
This is where upath and upath2 diverge meaningfully:
upath: Published as CommonJS. Works everywhere, including older Node versions and bundlers without ESM config. No built-in TypeScript types (you’d install @types/upath).
upath2: Pure ESM. Requires Node 12+ with ESM support or bundler configuration. Ships with .d.ts files, so no extra install needed for TypeScript.
If your project is still using CommonJS or targets older environments, upath is safer. If you’re all-in on ESM and TypeScript, upath2 reduces setup friction.
upath@types/upath separately.// Example: A cross-platform file resolver
import upath from 'upath';
function resolveModule(baseDir, moduleName) {
const candidate = upath.join(baseDir, 'node_modules', moduleName);
// Safe to use string.includes() because separator is always /
if (candidate.includes('/node_modules/react/')) {
return candidate;
}
}
upath2// Example: An ESM-native build script
import upath from 'upath2';
const srcDir = upath.resolve('./src');
const outFile = upath.join(srcDir, 'bundle.js'); // Always ".../src/bundle.js"
path2It hasn’t seen updates in years, lacks security patches, and offers no advantage over maintained alternatives. Migrate existing usage:
// Before (deprecated)
import path2 from 'path2';
const p = path2.join('a', 'b');
// After
import upath from 'upath';
const p = upath.join('a', 'b');
| Aspect | path2 | upath | upath2 |
|---|---|---|---|
| Maintenance | ❌ Deprecated | ✅ Actively maintained | ✅ Actively maintained |
| Path Separator | Platform-native | Always / | Always / |
| ESM Support | CommonJS only | CommonJS | Pure ESM |
| TypeScript | No types | Requires @types/upath | Built-in types |
| Extra Methods | None | .toUnix() | .toUnix() |
| Use in New Code | Never | Yes (CommonJS or mixed envs) | Yes (ESM-only projects) |
For most frontend tooling projects, upath remains the pragmatic choice—it’s stable, widely used, and solves the core problem reliably. Only reach for upath2 if you’re committed to a modern ESM + TypeScript stack and want to avoid type declaration packages. And never start a new project with path2—it’s a legacy artifact with no place in contemporary codebases.
Remember: sometimes the best path is the one that just works everywhere — and that’s what upath delivers.
Choose upath if you need a lightweight, battle-tested wrapper around Node.js’s path module that normalizes all paths to use forward slashes (/) regardless of platform. It’s ideal for build tools, static site generators, or any scenario where consistent path formatting simplifies string operations or avoids Windows-specific bugs.
path2 should not be used in new projects. It is deprecated and unmaintained, as confirmed by its npm registry entry and GitHub repository archive status. Existing usage should be migrated to alternatives like upath or the native Node.js path module.
Choose upath2 only if you require ESM-first support and TypeScript types out of the box, and you’re already familiar with upath’s API. It’s a modern fork of upath but offers no functional improvements—only packaging and type enhancements. For most projects, upath remains sufficient unless ESM compatibility is non-negotiable.
The battle-tested path library that just works -- everywhere.
A drop-in replacement for Node.js path that normalizes all backslashes to forward slashes, adds safe path manipulation and file extension utilities, and stays in sync with every Node.js release automatically.
Trusted for over a decade. 20 million downloads per week. Zero runtime dependencies. 100% tested against NodeJS's own path tests. 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.
@types/node v20 through v25+.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.
To report a security vulnerability, please use the Tidelift security contact. Tidelift will coordinate the fix and disclosure.
MIT -- Copyright (c) 2014-2026 Angelos Pikoulas