upath
A drop-in replacement / proxy to Node.js path, replacing \\ with / for all results & adding file extension functions.
Last updated a day ago by GitHub Actions .
MIT · Repository · Bugs · Original npm · Tarball · package.json
$ cnpm install upath 
SYNC missed versions from official npm registry.

upath v3

The battle-tested path library that just works -- everywhere.

npm version npm downloads CI TypeScript Node.js License: MIT Zero Dependencies Bundle Size GitHub Sponsors

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

The Problem

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.

How It Works

upath is a thin dynamic proxy over Node's built-in path module. Zero runtime dependencies -- its only import is node:path itself.

  1. At load time, iterates over every export of path via Object.entries()
  2. Functions get wrapped: string arguments are normalized on the way in, string results on the way out
  3. Non-function properties are copied as-is (except sep, which is forced to '/')
  4. New path functions added in future Node versions are automatically wrapped -- no code changes needed

This 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.

Installation

npm install upath

Usage

// ESM
import upath from 'upath'
// or import specific functions
import { normalize, joinSafe, addExt } from 'upath'

// CJS
const upath = require('upath')

API

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.

Proxied functions -- path vs upath

Every 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' }

Extra functions

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.

Who Uses upath

upath is a foundational dependency in the Node.js ecosystem, trusted by 1,300+ packages on npm including:

  • Chokidar -- the file watcher behind Webpack, Vite, Rollup, and most dev servers
  • Nuxt -- the Vue.js framework (v2)
  • ansi-colors -- terminal color styling
  • Countless Webpack plugins, build tools, and CLI frameworks

If you run npm ls upath in a non-trivial Node.js project, there's a good chance it's already there.

What's New in v3

  • TypeScript rewrite -- full type safety, source-of-truth types shipped with the package.
  • Dual CJS/ESM -- works with import and require() out of the box via package.json exports.
  • Node >= 20 -- drops legacy Node support.
  • Auto-generated API docs -- see docs/API.md for complete input/output tables generated from the test suite.
  • UNC path support -- carried forward from v2, with comprehensive test coverage.

Migrating from v2

  • Node >= 20 required -- v2 supported Node >= 4. Update your CI matrix.
  • CJS usage unchanged -- const upath = require('upath') works as before. All functions are available directly on the module (no .default needed).
  • TypeScript: stricter params -- 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).
  • Named ESM imports now available -- import { normalize, join, toUnix } from 'upath' works in addition to the default import.
  • Boxed String objects rejected -- new String('foo') no longer accepted; use plain string primitives.

See CHANGELOG.md for the full list of changes.

Contributing

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

Sponsor

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.

License

MIT -- Copyright (c) 2014-2026 Angelos Pikoulas

Current Tags

  • 3.0.6                                ...           latest (a day ago)

23 Versions

  • 3.0.6                                ...           a day ago
  • 3.0.5                                ...           5 days ago
  • 3.0.0                                ...           5 days ago
  • 2.0.1                                ...           5 years ago
  • 2.0.0                                ...           6 years ago
  • 1.2.0                                ...           7 years ago
  • 1.1.2                                ...           7 years ago
  • 1.1.1                                ...           7 years ago
  • 1.1.0                                ...           8 years ago
  • 1.0.5                                ...           8 years ago
  • 1.0.4                                ...           8 years ago
  • 1.0.3                                ...           8 years ago
  • 1.0.2                                ...           8 years ago
  • 1.0.0                                ...           9 years ago
  • 0.2.0                                ...           10 years ago
  • 0.1.7                                ...           10 years ago
  • 0.1.6                                ...           11 years ago
  • 0.1.5                                ...           11 years ago
  • 0.1.4                                ...           11 years ago
  • 0.1.3                                ...           11 years ago
  • 0.1.2                                ...           11 years ago
  • 0.1.1                                ...           11 years ago
  • 0.1.0                                ...           11 years ago

Copyright 2013 - present © cnpmjs.org | Home |