upath
Version:
A drop-in replacement / proxy to Node.js path, replacing \\ with / for all results & adding file extension functions.
288 lines (193 loc) • 13.8 kB
Markdown
# upath v3
**The battle-tested path library that just works -- everywhere.**
[](https://www.npmjs.com/package/upath)
[](https://www.npmjs.com/package/upath)
[](https://github.com/anodynos/upath/actions/workflows/ci.yml)
[](https://www.typescriptlang.org/)
[](https://nodejs.org/)
[](https://opensource.org/licenses/MIT)
[](https://www.npmjs.com/package/upath)
[](https://bundlephobia.com/package/upath)
[](https://github.com/sponsors/anodynos)
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.
```typescript
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:
```typescript
// 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](https://github.com/nodejs/node/tree/main/test/parallel/) to verify identical behavior.
## Installation
```bash
npm install upath
```
## Usage
```typescript
// 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`](docs/API.md) for full input/output tables.
#### `upath.toUnix(path)`
Converts all `\` to `/` and consolidates duplicate slashes, without performing any normalization.
```typescript
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 `/`:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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**](https://github.com/paulmillr/chokidar) -- the file watcher behind Webpack, Vite, Rollup, and most dev servers
- [**Nuxt**](https://github.com/nuxt/nuxt) -- the Vue.js framework (v2)
- [**ansi-colors**](https://github.com/doowb/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`](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](CHANGELOG.md) for the full list of changes.
## Contributing
Contributions are welcome! Please open an issue or pull request on [GitHub](https://github.com/anodynos/upath).
```bash
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:
- [GitHub Sponsors](https://github.com/sponsors/anodynos) -- recurring or one-time
- [Polar](https://polar.sh/anodynos) -- commercial-friendly, issue bounties
- [Tidelift](https://tidelift.com/subscription/pkg/npm-upath) -- enterprise supply-chain support
Running `npm fund` in your project will also show you if upath is in your tree.
## License
[MIT](LICENSE) -- Copyright (c) 2014-2026 [Angelos Pikoulas](https://github.com/anodynos)