# pathe makes path handling behave the same on Windows and on Linux

> pathe is a small MIT licensed drop-in replacement for Node.js's path module that normalises every operation to forward slashes regardless of the platform, with extra utilities on a utils subpath and no dependency on Node.js. It suits a library author whose code must behave identically on Windows and POSIX, and the one exception is the delimiter constant, which stays platform specific.

**unjs/pathe** — 🛣️ Drop-in replacement of the Node.js's path module module that ensures paths are normalized

- Repository: https://github.com/unjs/pathe
- Stars: 595 · Forks: 42
- Language: TypeScript
- License: NOASSERTION
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/unjs-pathe

## The historical reason Windows paths broke portability

pathe describes itself as universal filesystem path utils, and the reason it exists is a piece of operating system history.

For historical reasons, Windows followed MS-DOS and used backslash for separating paths rather than the slash used by macOS, Linux and other POSIX systems. Nowadays Windows supports both slash and backslash for paths. The problem is Node.js's built-in path module, whose default behaviour varies based on the operating system the application runs on. When running on Windows, the path module assumes Windows style paths are being used.

The documentation puts the consequence plainly: this makes inconsistent code behavior between Windows and POSIX.

That inconsistency is the kind of bug that passes a developer's whole test suite on a Mac and then fails in CI, or works in the Linux container and breaks on a developer's Windows laptop. A path separator that differs by platform is invisible in code review, because the string looks the same either way.

The fix pathe applies is to behave as if the platform were always POSIX. Every operation is normalised with a forward slash, and the result is the same string on every machine.

The one exception the documentation names is the delimiter constant export, which is set to a semicolon on Windows, because the delimiter is the character that joins entries in a PATH style variable and that has to match the platform's own convention.

## Identical exports, so the import is the only change

The design goal is stated in one sentence: compared to upath, pathe provides identical exports of Node.js with normalization on all operations, and is written in modern ESM and TypeScript with no dependency on Node.js.

Identical exports is the load bearing part. There is no patheResolve or patheJoin. If the exported names are the same as the Node.js path module, then replacing the import is the entire migration, and the code below it keeps working.

```js
// ESM / Typescript
import { resolve, matchesGlob } from "pathe";

// CommonJS
const { resolve, matchesGlob } = require("pathe");
```

Both module systems are supported, which matters for a package that gets consumed by everything from modern ESM applications to CommonJS build setups.

The no dependency on Node.js claim is the second reason this is more than a path stringifier. Because the implementation does not reach into Node's internals, the module can run in a browser or in a bundler target where node:path is not available. For a tool that runs in the browser, in a worker, or in a server, one implementation covers all three.

And the glob support is a bonus rather than a separate dependency, since the documentation says glob support is powered by zeptomatch, a package by the same author.

## Extra utilities live behind a separate subpath

pathe also exports utilities that do not exist in the standard Node.js path module, and they are not on the main entry point. You import them from the pathe/utils subpath.

```js
import { filename, normalizeAliases, resolveAlias, reverseResolveAlias } from "pathe/utils";
```

Four names, and their naming tells you what they are for. filename is the small one. The other three deal with aliases: normalizeAliases normalises them, resolveAlias turns an alias into a resolved path, and reverseResolveAlias does the opposite, turning a resolved path back into an alias form. That last one is the interesting member of the set, because it is the operation you need when you want to display a short alias to a user while the code internally deals with absolute paths.

The split into a subpath is a deliberate packaging decision, and the package.json explains it. The exports map has two entries, the root pointing at ./dist/index.mjs and ./utils pointing at ./dist/utils.mjs. The published files are dist and utils.d.ts, so the type declarations for the extra utilities are shipped separately from the main bundle.

The practical consequence for an adopter is that there are two import sites to keep straight. The drop-in part is one import, the extra helpers are another, and a reader who sees two different import paths from the same package may assume there are two packages.

## The build is ESM only, with a type entry for each format

The package manifest describes a modern, small build.

The package is named pathe at version 2.0.3, the type field is module, and sideEffects is false, which is the flag that tells a bundler the package has no side effects at import time and can therefore be tree shaken. The main and module fields both point at ./dist/index.mjs, the types field points at ./dist/index.d.mts, and the exports map covers the root and the utils subpath as described above.

The build script is obuild, and the dev script is vitest. The test command runs the linter first and then vitest with coverage, and there is a separate type test script using tsgo with noEmit. The release script runs the tests, the build, changelogen for the changelog and version bump, then publishes and pushes the tags, which is a conventional release setup with the changelog generated from commit messages rather than written by hand.

Lint and format are handled by oxlint and oxfmt, with configuration in .oxlintrc.json and .oxfmtrc.json, and the repository is a pnpm workspace with a pnpm-lock.yaml and pnpm-workspace.yaml, with the package manager field pinned to pnpm 11.10.0.

The dev dependency on @typescript/native-preview is worth noting for anyone tracking TypeScript versions, because it means the type tests run against the native compiler preview rather than the standard tsc.

## Where the normalisation is not free

The trade-off is that pathe is POSIX shaped, and POSIX path semantics are not the same as Windows path semantics in every case.

The documentation's own framing is that behaviour is consistently like POSIX regardless of your input paths format and running platform, with the delimiter constant as the only exception. That is a strong promise, and it is exactly what makes the library predictable across platforms. It is also a promise that Windows-specific behaviour is not preserved.

The second limitation is about the filesystem underneath. Normalising a path string to forward slashes does not change what the operating system does with it. On Windows, a path with forward slashes still resolves against the Windows filesystem rules, so a path built as a POSIX absolute path, one starting with a slash, does not necessarily mean the same thing as the same string on Linux. The library makes the string consistent, and that consistency is what the vast majority of code needs, but code that genuinely depends on drive letters, UNC paths or root-relative Windows paths is not fixed by normalisation.

There is also the question of what a resolved path means. Node's path.resolve on Windows produces a drive qualified path, and a POSIX resolve produces a path starting with a slash. If your code compares resolved paths to detect whether something is inside a base directory, that comparison is affected by which platform produced the string, and the documentation does not claim to normalise across that boundary.

In practice the library is a good fit for path handling that crosses machines, and a poor foundation for code that needs to reason about Windows specific locations.

## Licence, provenance and the maintenance picture

The licence is MIT, and the documentation says the package was made with love and published under the MIT license, with the LICENSE file at the root of the repository. The project metadata lists the licence as one GitHub cannot classify, so the file is the authority rather than the badge.

The provenance note matters if you care about where the code came from. The documentation says some code was used from the Node.js project, which is the natural consequence of being a drop-in replacement for a Node core module, and that glob support is powered by zeptomatch.

The repository structure is small and conventional. There is a src/ directory, a test/ directory, a build.config.ts, a vite.config.ts for the test tooling, a CHANGELOG.md, a renovate.json for dependency updates, a .editorconfig, and a utils.d.ts file at the root, which is the hand written declaration file shipped for the utils subpath.

The maintenance picture is calm rather than fast. The releases listed are v2.0.3 on 2025-02-11, v2.0.2 on 2025-01-17 and v2.0.1 on 2025-01-09, so the last published version is from early 2025, and the last push was on 2026-10-18 with the repository not archived. There is no GitHub release history to consult, since the project metadata lists no releases, and the version is tracked through npm.

For a package whose whole job is to behave predictably, that is the right profile. A path utility that changes its output format between versions would be worse than no utility at all.

## Conclusion

pathe fits a library or application whose path handling has to behave the same on Windows and on Linux, since every operation is normalised to forward slashes and the module has no dependency on Node.js, so it also works in a browser or a bundler target. It does not fit code that reads the delimiter constant and expects a platform native value, since that stays a semicolon on Windows, and it does not fit a project that wants Windows specific path semantics such as drive letter handling that POSIX rules do not cover. Before adopting it, swap the import in one module and check your path comparisons, and remember that the extra helpers live behind the pathe/utils subpath rather than the main entry point, so the two are separate imports to keep straight.

## FAQ

### What is the difference between pathe and Node.js's path module?

pathe provides identical exports but normalises every operation to forward slashes regardless of the platform, so the same code produces the same path string on Windows and on POSIX. The one exception is the delimiter constant, which is set to a semicolon on Windows.

### Where do I import the extra pathe utilities from?

From the pathe/utils subpath, for example importing filename, normalizeAliases, resolveAlias and reverseResolveAlias. The main entry point carries the Node.js path module exports, and the package exports map points the utils subpath at its own build file.

### Does pathe depend on Node.js?

No. The documentation states that it is written in modern ESM and TypeScript and has no dependency on Node.js, which is what lets the same implementation work in Node.js and in browsers.

### How is pathe built and tested?

The build uses obuild, the dev script is vitest, and the test command runs oxlint and oxfmt checks before vitest with coverage. There is a separate type test using tsgo with noEmit, and the release script runs tests, build, changelogen, publish and a tag push.

## Sources

- [Issues](https://github.com/unjs/pathe/issues)
- [README](https://github.com/unjs/pathe/blob/main/README.md)
- [Releases](https://github.com/unjs/pathe/releases)
- [unjs/pathe on GitHub](https://github.com/unjs/pathe)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/unjs-pathe
