A typed path ADT and Effect-native utilities for the Effect ecosystem.
@kitz/effect layers kitz enhancements on top of Effect, exposed under Effect's own
domain terms:
Path— a typed path ADT (AbsFile|AbsDir|RelFile|RelDir) with schema-backed parsing. Values carry instance getters (.name,.stem,.extension,.diron files,.parenton dirs,.ancestors,.asDir/.asFile,.atRoot,.fileUrl, …); multi-path operations are flat functions (join,joinAll,relativeTo,ensureAbs,isDescendantOf,isWithin,order, …); common ancestors are group statics (Abs.commonAncestor,Rel.commonAncestor); file component writes are per-model statics (AbsFile.setParts,RelFile.setParts);Path.makeinfers the precise variant from a string literal, while each target schema'smakeoverload accepts either a validated literal or its original structured Type-side input.Schema— small additions to Effect Schema (e.g.NaturalInt).String— string utilities.Tuple— tuple type operators that complement Effect's tuple helpers (e.g.Last).Types— reusable type predicates and branded diagnostics (IsLiteral,StaticError).
import { Path } from '@kitz/effect'
import { Option, pipe, Schema } from 'effect'
const config = Path.make('/home/user/config.json') // typed AbsFile
const cwd = Path.AbsDir.make('/home/user') // dir targets accept no trailing slash
Path.join(cwd, Path.make('./notes/todo.md')) // AbsFile /home/user/notes/todo.md
Path.joinAll(cwd, ['./notes/', './archive/', './todo.md']) // tuple left fold
config.dir.toString() // '/home/user/' — a file's tree parent is its containing dir
Path.AbsFile.setParts(config, { extension: Option.none() }) // /home/user/config
pipe(config, Path.AbsFile.setParts({ stem: 'config.local' })) // /home/user/config.local.json
Schema.decodeSync(Path.Any)(process.argv[2] ?? '.') // runtime strings decode to the unionLiteral duality. Every path-typed operation position accepts either a decoded Path value or a
statically known string literal. Literals desugar through Path.make, so the
direct form keeps the same runtime meaning and precise variant return type as
constructing the value first:
Path.join(cwd, './.env')
Path.join(cwd, Path.make('./.env')) // equivalent desugared form
Path.joinAll(cwd, ['./generated/', './types/', './index.ts'])Plain runtime string values are intentionally rejected by these signatures;
decode them through the appropriate Schema before calling the operation. This
separates compile-time literal convenience from runtime validation without
creating a second parser. The complete laws and type-level doctrine are in the
literal-duality design spec.
Path values implement Effect Equal, Hash, and PrimaryKey, so structurally
equal values work as keys in Effect HashMap/HashSet. Native JavaScript
Map/Set use object reference identity instead: two separately decoded path
values can encode to the same canonical string but still miss each other as
native keys. For native collections, key by path.toString() or another stored
canonical string.
AbsFile.make and RelFile.make default an omitted dir to the matching
anchor. That default is useful for literals and schema construction, but
application code that is building a non-root file should pass dir explicitly
or prefer join(dir, relFile). Accidentally omitting dir silently constructs
an anchor-rooted file.
AbsFile.FromUrl and AbsDir.FromUrl are target-typed codecs for native
file: URL instances. They replace the old union-shaped file URL helper: pick
the target schema you want, then choose the usual Schema channel
(decodeSync, decodeResult, decodeEffect, …).
import { Path } from '@kitz/effect'
import { Schema } from 'effect'
const thisFile = Schema.decodeSync(Path.AbsFile.FromUrl)(new URL(import.meta.url))
const thisDir = Schema.decodeSync(Path.AbsDir.FromUrl)(new URL('.', import.meta.url))
Schema.encodeSync(Path.AbsFile.FromUrl)(thisFile) // URLFor structured JSON transports, each model also has a flat primitive
FromStruct codec:
const Payload = Schema.Struct({ entry: Path.AbsFile.FromStruct })
Schema.encodeSync(Payload)({ entry: thisFile })
// { entry: { segments: [...], fileName: 'README.md' } }Path.Cwd is an Effect Context.Service whose value is the AbsDir itself.
Path.Cwd.layer snapshots process.cwd() when the layer is provided; later
process.chdir calls do not update the service value. This is deliberate:
deep code should take an explicit AbsDir base parameter, while edge code pays
the yield* Path.Cwd ceremony where ambient process state enters the program.
import { Path } from '@kitz/effect'
import { Effect, Schema } from 'effect'
const program = Effect.gen(function* () {
const cwd = yield* Path.Cwd
return Path.join(cwd, Path.make('./config.json'))
}).pipe(Effect.provide(Path.Cwd.layer))For non-Effect edge code, the one-liner is still just the schema decode:
const cwd = Schema.decodeSync(Path.AbsDir)(process.cwd())These are the most common false friends when moving from node:path strings to
typed Path values:
| node:path | @kitz/effect | note |
|---|---|---|
basename(p) |
file.name |
|
parse(p).name |
file.stem |
node is the industry outlier — Python/Rust/C++ call this stem |
extname(p) |
file.extension |
Option; includes the dot (like node/C++/.NET; Rust omits it) |
dirname(p) |
file.dir / dir.parent |
files and dirs answer "up" with different words by design |
parse(p).dir |
file.dir |
node:path.resolve combines three different intents. Typed paths keep those
intents separate:
node:path shape |
Path shape |
|---|---|
resolve(base, relative) |
Path.join(base, relative) |
resolve(base, relA, relB, …) |
Path.joinAll(base, [relA, relB, …]) |
resolve(base, maybeAbsolute) |
Path.ensureAbs(maybeAbsolute, base) |
resolve() |
yield* Path.Cwd |
Node's rightmost-absolute reset law is deliberately not reproduced. It hides
control flow inside string data by silently discarding everything to the left
of a later absolute argument. join/joinAll require relative parts, while
ensureAbs makes the absolute-or-relative branch explicit.
pnpm add @kitz/effect effecteffect is a peer dependency — you install it yourself, so your app and this
package share a single Effect instance (Effect relies on module-level singletons;
two copies break Context/Schema identity).
Pre-release: this package targets Effect v4 (
effect@^4.0.0-rc.117), which is still a release candidate. Pin accordingly.
import { Path, Schema, String, Tuple, Types } from '@kitz/effect'Explicit subpaths expose each module directly:
import * as Path from '@kitz/effect/Path'
import * as Schema from '@kitz/effect/Schema'
import * as String from '@kitz/effect/String'
import * as Tuple from '@kitz/effect/Tuple'
import * as Types from '@kitz/effect/Types'
type Last = Tuple.Last<readonly ['first', 'last']> // 'last'
type IsFixed = Types.IsLiteral<'fixed'> // true
type LiteralError = Types.StaticError<'Expected a string literal.'>Every model schema derives an arbitrary with Effect's native arbitrary module. Each model's schema encodes all of its invariants, so the derived arbitrary yields only valid, canonical values and covers the model's whole domain, including non-ASCII, control, and astral text:
import { Arbitrary } from 'effect/unstable/arbitrary'
Arbitrary.schema(Path.AbsFile) // full domain — use for lawsVitest matchers for path values (toBeAbs, toEncodeTo, toBeWithinPath, …)
live in the companion package @kitz/vitest.
Kitz aligns with the Effect ecosystem's idioms and vocabulary, not Node's.
Effect is a categorical rejection of Node/JS standard-library design, and kitz
inherits that stance: where this library matches node:path behavior (lexical
.. folding, /.. clamping) that is a migration convenience and a
compatibility observation — never the design authority. When Effect idiom and
Node idiom conflict, Effect wins (e.g. setParts follows DateTime.setParts,
not a node:path shape).
The vocabulary was audited against POSIX, Python pathlib, Rust std::path,
C++17 std::filesystem, Node path, Java NIO, .NET System.IO.Path, Go
filepath, and the typed-path lineage (Haskell path, PureScript pathy).
Foundations adopted:
stem— Pythonstem, Rustfile_stem(), C++stem(). Node'sparse().namemeaning the stem is the industry outlier.extension(dot included) — C++/Node/.NET/Python(suffix) include the dot; Rust omits it.name(final component incl. extension) — Pythonname, Rustfile_name(), .NETGetFileName, GoBase.parent(dirs) /dir(files) — Python/Rust/Javaparent; Nodeparse().dir/dirname. Files and dirs deliberately answer "up" with different words (see the.parentledger row).ancestors— Rustancestors()(Rust's includes self; ours does not).join/joinAll,relativeTo— universal verbs; Pythonrelative_to()contributes the latter spelling verbatim.segments— Node's docs prose ("path segments"), RFC 3986/WHATWG. POSIX/Rust say "component", Python "parts", Java "name elements"; no industry consensus exists, and Node's prose is the most relevant anchor for a node:path successor.AbsFile/RelFile/AbsDir/RelDir— exactly Haskellpath(Path Abs File) / PureScriptpathyvocabulary: the typed-path niche's established language.Abs.commonAncestor/Rel.commonAncestor— "common" peros.path.commonpathand git merge-base ("best common ancestor"). The earlier namegetSharedBasewas rejected: "base" means the final component across the industry (basename,filepath.Base), the opposite end of the path. The operation is total and Option-free because the anchor is a genuine common ancestor:/floors absolute pairs, and the pure-ascent line floors relative pairs with unequal ascent. Round-2 P1 exposed the oldNoneas a special case for exactly the valueAbsDir.anchornow names.isWithin(inclusive) vsisDescendantOf/isAncestorOf(strict) — the industry reserves prefix/containment words for self-inclusive semantics (Pythonis_relative_to, Ruststarts_with, JavastartsWith) because genealogy words imply strictness; we honor both registers by splitting the ops. For relatives, equal-ascent prefix containment is extended by pure-ascent containers:../contains./a, while./does not contain../../.
Deliberate divergences (reviewed, kept):
setParts/Parts—pathlib.partsmeans the segment tuple; ourPartsis a component record (nearest analog: Node'sparse()object, whosename/basefields we render as the better-foundedstem/name). Effect-internal consistency (DateTime.setParts) outranked neighbor-language UL. The deletedwith_*family (pathlibwith_name/with_stem/with_suffix; Rustwith_file_name/with_extension) was the cross-industry-founded alternative — traded knowingly.ensureAbs— fs-extra'sensure*means idempotent-create-on-disk, but thePathnamespace structurally excludes disk semantics (a futureFs.*owns those), so the collision does not arise at call sites (Path.ensureAbsvsFs.ensureDir).ascent— no ecosystem noun exists for a leading-..count (Python/Rust model..as components, never a count); see the ledger.
The start of a path's domain is its anchor: / for absolute paths, ./ for
relative ones — pathlib's term, extended (pathlib assigns relatives an empty
anchor rather than naming ./). The canonical encodings already spell the
anchor at position zero of every path. Algebraically each domain has a
distinguished start: / is the minimal element of the absolute tree; ./ is
the identity of the relative-path monoid. Dirs carry isAnchor; files compose
the question through .dir (the old file.isRoot conflated "is the start" with
"is directly in the start"). AbsDir.anchor / RelDir.anchor are the named
values. "Root" remains in the vocabulary as the NAME of the absolute anchor
(atRoot correctly means it). Pure-ascent relatives (../../) sit above the
anchor, not at it.
The path model is POSIX-only. Windows drive prefixes (C:\), UNC paths
(\\server\share), drive-relative paths (C:foo), backslash separators,
case-insensitive comparison, and reserved device names are all out of scope and
unrepresentable. In practice, modern Windows APIs and runtimes commonly accept
forward-slash paths without drive prefixes, so POSIX-shaped paths often work on
Windows — but kitz makes no Windows guarantees and performs no Windows-specific
validation. If Windows support ever became a goal, the model's anchor would grow
into a drive-bearing component (cf. pathlib's PureWindowsPath.anchor = drive +
root).
Why the decoded dir shape is (ascent, segments) and the decoded file shape is
(dir, fileName), audited for illegal and redundant states.
The shape is the lexical normal form. Every POSIX relative path normalizes
to exactly (../)ⁿ seg₁/…/segₖ — all traversal collapses to a leading prefix —
so (ascent: ℕ, segments: Segment[]) is a bijective representation. It is
total (every inhabitant is a valid path: (0, []) is ./, (2, []) is
../../) and canonical (every path has exactly one value: a/../b, ./b,
and b all decode to (0, ['b']) — the analyzer folds interior .. by
pop-or-increment). Non-normal paths are not merely rejected; they are
unrepresentable.
- Absolute variants have no
ascentfield at all, so "absolute paths can't ascend" is enforced by field absence, not by a validation rule. fileNamelives outsidesegments, so thedirname/basenameoff-by-one ambiguity ("is the last segment the filename?") cannot arise; no cross-field invariant exists to violate.- The algebra confirms the shape: relative dirs form a monoid under
join(ascent eats base segments from the right, overflow accumulates; identity./), absolute dirs are the action target (overflow clamps at root), files are the productDir × FileName.join/relativeTo/isWithinare the monoid's multiplication, division, and divisibility order.
Comparison on this axis (none of the surveyed libraries occupy the same
cell): strings admit everything; component sequences (Rust Components,
pathlib parts, Java name elements) deliberately preserve interior .. as
distinct values; PureScript pathy allows ParentIn nodes anywhere and ships
a separate canonicalize; Haskell path achieves normality by banning ..
from relative paths entirely (cannot represent ../x). Kitz alone is
normal-by-construction with full expressiveness.
The commitment this buys into (reviewed, kept): lexical .. folding is
unsound under symlinks (a/../b ≠ b when a is a symlink — the classic
normpath caveat; the reason Rust/pathlib refuse to fold). Kitz path equality
is therefore lexical-normal-form identity, not filesystem-target identity,
irreversibly. Kept because Path models pure paths while symlink-aware
resolution requires disk access, which belongs to Fs.* by the namespace
charter, and because normal-form-by-construction is the model invariant. Node's
own normalize/join fold identically, so migrators get incumbent semantics as
a compatibility observation. Same family: over-ascent on absolutes
(/a/../../b) clamps at root (/b) — POSIX's own /.. semantics.
Files nest their directory. AbsFile/RelFile are literally
{ dir, fileName } — the product File ≅ Dir × FileName is the stored shape,
not a derived view. Both shapes are equally total and canonical; nesting won on
composability: a file's directory is a value of a type consumers already know,
setParts' dir axis is a plain field, and .dir is a field read rather than
a per-access construction. segments/ascent remain as delegating getters.
MIT