@archont561/pathway
v0.1.2
Published
One Path value for native, virtual and URL paths in TypeScript
Readme
@archont561/pathway
One Path value for native filesystem paths, virtual paths and URL paths — lexical, cross-platform and inspired by Python's pathlib.
Every operation is a pure, synchronous function of its arguments: no filesystem access, no working directory, no environment variables, no clock, no randomness. The same input produces the same result on every host for the same explicit path style, and every value is canonical — construction collapses duplicate and trailing separators rather than keeping the input byte for byte.
v0.1.0 contains the lexical core only. Filesystem I/O, traversal, storage adapters and text codecs are not part of this release; see Scope.
Install
bun add @archont561/pathwayThe package is ESM-only and runs on any ES2022 runtime: Bun ≥ 1.4.2 and Node ≥ 18 on Windows, macOS and Linux, and modern browsers. See Runtimes and portability.
Quick start
import { Path } from '@archont561/pathway';
const path = Path.posix('/srv/app/logs/2026-10-17.log');
path.kind; // 'native'
path.style; // 'posix'
path.parts; // ['srv', 'app', 'logs', '2026-10-17.log']
path.name; // '2026-10-17.log'
path.stem; // '2026-10-17'
path.suffix; // '.log'
path.parent.toString(); // '/srv/app/logs'
path.isAbsolute; // true
Path.posix('/srv/app').join('logs', 'app.log').toString();
// '/srv/app/logs/app.log'
Path.posix('/srv/app/./logs/../config').resolve().toString();
// '/srv/app/config'
Path.posix('/srv/app/logs').relativeTo(Path.posix('/srv/app')).toString();
// 'logs'
Path.windows(String.raw`C:\Users\me\file.txt`).parts;
// ['Users', 'me', 'file.txt']One Path, three domains
The domain is explicit at construction and fixed for the life of the value. path.kind reports it,
and path.style reports the native style.
| Factory | kind | style | Root forms |
| :-- | :-- | :-- | :-- |
| Path.native(input, style?) | 'native' | 'posix' or 'windows', host platform by default | POSIX /; Windows C:\ drive roots, \\server\share\ UNC roots, plus drive-relative C: and root-relative \ |
| Path.posix(input) | 'native' | 'posix' | / |
| Path.windows(input) | 'native' | 'windows' | C:\, \\server\share\, \, C: |
| Path.virtual(input) | 'virtual' | undefined | / |
| Path.url(input) | 'url' | undefined | / |
Path.posix('/a/b').kind; // 'native'
Path.posix('/a/b').style; // 'posix'
Path.windows('C:/a/b').style; // 'windows'
Path.virtual('/a/b').kind; // 'virtual'
Path.url('/a/b').kind; // 'url'Path.virtual is POSIX-like on every host, with the backslash as an ordinary character.
Path.url accepts an RFC 3986 path reference only and preserves percent-encoding verbatim. Because
the style is explicit, Windows rules can be created and tested on Linux and vice versa.
Domains never mix: a Path keeps its kind and style through every operation, equals() is
false across domains, and relativeTo() across domains — or across native styles — raises
PathError.
Operations
| Member | Result |
| :-- | :-- |
| native, posix, windows, virtual, url | Construct a value in canonical form; toString() and toJSON() return that string |
| kind / style | The domain, and the native style (undefined for virtual and URL paths) |
| parts | The segments, without the root and without . segments. The url domain keeps empty segments, because /a//b and /a/b are different URLs |
| name | The last segment, or '' |
| stem / suffix | The name split at its last dot; a leading dot does not start a suffix (.bashrc has none) |
| parent | The path without its last segment. A path with no segments is its own parent; a has parent . |
| isAbsolute / isRelative | Whether the value starts at a root. A Windows drive-relative C:a and a root-relative \a are not absolute |
| join(...segments) | Appends segments; an absolute segment replaces the accumulated path. Does not resolve dot segments |
| resolve(...segments) | join(...segments), then . and .. removal. .. at a root is dropped; leading .. of a relative path are kept |
| relativeTo(base) | The relative path from base, or . when the two are equal. Both values must share a domain and style |
| equals(other) | True when both values share a domain and style and resolve to the same string |
Construction is canonical but keeps . and .. segments: removing .. lexically is not the same
as resolving it on a filesystem, because a parent directory may be a symlink, so resolve() is an
explicit call. resolve() is lexical: it never follows symlinks and never checks that a path
exists. Equality is byte-exact — nothing is case-folded.
relativeTo() resolves both operands before comparing them, so 'a/./b'.relativeTo('a/b') is '.'
and 'a/b/../c'.relativeTo('a/x') is '../c'.
Errors
- A non-string path, an empty segment, or an argument that is not a
Pathraises aTypeError. - An invalid value or an impossible operation raises the exported
PathError: an empty path, a malformed Windows UNC root, a URL path that carries a scheme, authority, query or fragment, relativizing across domains or native styles, and relativizing paths that do not share a root.
import { Path, PathError } from '@archont561/pathway';
try {
Path.posix('/a').relativeTo(Path.virtual('/a'));
} catch (error) {
error instanceof PathError; // true
}Runtimes and portability
The library is plain ES2022 TypeScript: it imports no Node built-in, reads no ambient state, and ships one ESM entry with no runtime-specific builds.
| Runtime | Default style from Path.native(input) |
| :-- | :-- |
| Bun ≥ 1.4.2 on Windows | 'windows' |
| Bun ≥ 1.4.2 on macOS or Linux | 'posix' |
| Node ≥ 18 on Windows | 'windows' |
| Node ≥ 18 on macOS or Linux | 'posix' |
| Browser, or any runtime without process | 'posix' (never throws) |
Only Path.native(input) without a style consults the host. Path.posix, Path.windows,
Path.virtual and Path.url are host-independent, so a Windows path built on Linux is a Windows
path, and every explicit style gives a byte-identical result on every host. That is what makes the
Windows matrix testable on a Linux runner, and it is the recommended way to write portable code:
pass the style.
Two deliberate limits, both documented rather than hidden:
- Case is not folded. Comparison is byte-exact on every host. Native Windows filesystems and the default macOS filesystem are case-insensitive, so two values differing only in case can name the same file.
- Unicode is not normalized. Values are code-unit exact, so NFC and NFD spellings of the same name are different values. macOS stores names decomposed.
Both belong to the future filesystem layer, where the encoding and the filesystem identity are
observable. A browser has no native filesystem; virtual and url values are the natural fit
there, and native values stay lexical and usable.
Scope
| Concern | v0.1.0 |
| :-- | :-- |
| Lexical path operations, one Path value with three domains | In scope |
| Any ES2022 runtime (Bun, Node, browsers), TypeScript-only | In scope |
| Filesystem I/O, traversal, storage adapters, codecs | Deferred to a later release |
| Sandboxing, locks, signals and cancellation | Out of scope: nothing is read or written, and lexical operations are synchronous |
Development
pixi install --locked
pixi run test
pixi run typecheck
pixi run build
pixi run lintPre-push checks use Bun's change-aware test selection against origin/main; if that base ref is not
available yet, the hook runs the full test suite.
