Skip to content

Path

The Path service builds and inspects file system paths without hard-coding separators or assumptions about the host OS. By depending on the abstract Path service and providing a platform implementation (NodePath.layer on Node.js), the same code produces POSIX-style paths on Linux and macOS and Windows-style paths on Windows. Most methods are pure string operations; the two that can fail (fromFileUrl and toFileUrl) return an Effect with a BadArgument error.

import { NodePath } from "@effect/platform-node"
import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path
// `join` concatenates segments using the platform separator and collapses
// redundant slashes — never build paths with string templates.
const config = path.join("home", "ada", "project", "effect.config.ts")
// "home/ada/project/effect.config.ts" on POSIX
// `resolve` produces an absolute path, resolving against the current
// working directory and processing "." and ".." segments.
const absolute = path.resolve("project", "..", "shared", "util.ts")
// Decompose a path into its parts.
yield* Effect.log(path.basename(config)) // "effect.config.ts"
yield* Effect.log(path.dirname(config)) // "home/ada/project"
yield* Effect.log(path.extname(config)) // ".ts"
yield* Effect.log(`${config}\n${absolute}`)
}).pipe(
// Provide the Node implementation of the Path service.
Effect.provide(NodePath.layer)
)

Both combine segments, but they answer different questions.

  • join is pure concatenation: it glues segments with the separator, collapses redundant slashes, and normalizes ./.. — but it never reaches outside the string you gave it. The result is absolute only if the first segment already is.
  • resolve computes an absolute path. It processes segments right-to-left until it finds an absolute one; if none is absolute, it anchors the result to the current working directory (process.cwd() on Node).
import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path
// join: relative in, relative out.
yield* Effect.log(path.join("a", "b", "..", "c")) // "a/c"
yield* Effect.log(path.join("/var", "log", "app")) // "/var/log/app"
// resolve: always absolute. Relative to cwd when no segment is rooted.
yield* Effect.log(path.resolve("a", "b")) // e.g. "/current/working/dir/a/b"
// The right-most absolute segment wins; earlier segments are discarded.
yield* Effect.log(path.resolve("/etc", "/usr", "bin")) // "/usr/bin"
})

Use join to assemble a path under a known base. Use resolve when you need a canonical absolute path (e.g. to compare two locations or pass to the file system regardless of where the process started).

These pure helpers pull a path apart. basename optionally strips a known suffix, and parse / format round-trip an entire path through a structured object.

import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path
const file = "/home/ada/project/main.ts"
yield* Effect.log(path.basename(file)) // "main.ts"
yield* Effect.log(path.basename(file, ".ts")) // "main" (suffix stripped)
yield* Effect.log(path.dirname(file)) // "/home/ada/project"
yield* Effect.log(path.extname(file)) // ".ts"
yield* Effect.log(path.isAbsolute(file)) // true
yield* Effect.log(path.sep) // "/" (POSIX) or "\\" (win32)
// parse -> Path.Parsed, format rebuilds the string. They are inverses.
const parsed = path.parse(file)
// { root: "/", dir: "/home/ada/project", base: "main.ts", ext: ".ts", name: "main" }
yield* Effect.log(path.format(parsed)) // "/home/ada/project/main.ts"
})

parse turns a path string into a structured Path.Parsed object, and format rebuilds a path from such an object. This pair is the cleanest way to change one component of a path — for example, swapping a file extension:

import { Effect, Path } from "effect"
// Replace the extension of a path, e.g. ".ts" -> ".js".
const changeExtension = Effect.fn("changeExtension")(function*(
file: string,
ext: string
) {
const path = yield* Path.Path
const parsed = path.parse(file)
// `parsed` is { root, dir, base, ext, name }. When `base` is set it wins,
// so clear it and let `format` rebuild from `name` + `ext`.
return path.format({ dir: parsed.dir, name: parsed.name, ext })
})
const program = Effect.gen(function*() {
const result = yield* changeExtension("src/index.ts", ".js")
yield* Effect.log(result) // "src/index.js"
})

relative computes the path from one location to another, isAbsolute tells you whether a path is already rooted, and normalize collapses ./.. segments and redundant separators. These are useful when displaying paths relative to a project root or cleaning up user-supplied input:

import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path
const from = "/home/ada/project"
const to = "/home/ada/project/src/main.ts"
yield* Effect.log(path.relative(from, to)) // "src/main.ts"
yield* Effect.log(path.relative(to, from)) // "../.."
yield* Effect.log(path.isAbsolute(to)) // true
yield* Effect.log(path.normalize("a/./b/../c")) // "a/c"
// toNamespacedPath is a no-op on POSIX; on Windows it adds the \\?\ prefix.
yield* Effect.log(path.toNamespacedPath("/home/ada")) // "/home/ada" (POSIX)
})

Converting between paths and file:// URLs is the one place Path can fail — an invalid URL or non-file: scheme yields a BadArgument (a PlatformError). Because these return effects, the failure is typed and handled like any other:

import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path
// `import.meta.url` is a file:// URL; convert it to a filesystem path.
const filePath = yield* path.fromFileUrl(new URL("file:///home/ada/app.ts"))
yield* Effect.log(filePath) // "/home/ada/app.ts"
const url = yield* path.toFileUrl("/home/ada/app.ts")
yield* Effect.log(url.href) // "file:///home/ada/app.ts"
}).pipe(
Effect.catchTag("BadArgument", (error) =>
Effect.logError(`invalid path or URL: ${error.message}`)
)
)

fromFileUrl rejects URLs whose scheme is not file:, that carry a hostname, or that contain percent-encoded path separators (%2F). Every other method on the service is a pure, synchronous string operation that cannot fail.

NodePath.layer follows the host operating system, but you can force a specific style with NodePath.layerPosix (always /) or NodePath.layerWin32 (always \). This is handy when generating paths for a different target than the machine you are running on:

import { NodePath } from "@effect/platform-node"
import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path
yield* Effect.log(path.join("a", "b", "c"))
}).pipe(
// Always emit POSIX paths regardless of the host OS.
Effect.provide(NodePath.layerPosix)
)

The core effect package also ships a built-in POSIX implementation as Path.layer, so code that does not depend on @effect/platform-node can still satisfy the Path requirement (with / separators and POSIX semantics):

import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path
yield* Effect.log(path.join("a", "b", "c")) // "a/b/c"
}).pipe(Effect.provide(Path.layer))

Paths produced here are exactly what the FileSystem service expects, so the two services compose naturally.

The Path service is accessed through the Path.Path tag. Every method below is a member of the Path interface. All are pure synchronous string operations except fromFileUrl and toFileUrl, which return an Effect.

The Context.Service tag used to access the current path implementation from the environment. Provide it with Path.layer, NodePath.layer, or a custom implementation.

import { Effect, Path } from "effect"
const program = Effect.gen(function*() {
const path = yield* Path.Path // resolve the service
return path.join("a", "b")
}) // => Effect<string, never, Path.Path>

A Layer providing the built-in POSIX implementation from the core effect package. Separator is /; semantics are POSIX.

import { Effect, Path } from "effect"
const run = Effect.gen(function*() {
const path = yield* Path.Path
return path.sep // => "/"
}).pipe(Effect.provide(Path.layer))

The platform path-segment separator as a string.

path.sep // => "/" on POSIX, "\\" on Windows

Returns the last portion of a path. An optional second argument is a suffix to strip from the result.

path.basename("/foo/bar/baz.html") // => "baz.html"
path.basename("/foo/bar/baz.html", ".html") // => "baz"
path.basename("/foo/bar/") // => "bar"

Returns the directory portion of a path (everything before the last segment).

path.dirname("/foo/bar/baz.html") // => "/foo/bar"
path.dirname("file.txt") // => "."
path.dirname("/foo") // => "/"

Returns the extension of the path — the substring from the last . to the end of the last segment — or an empty string when there is none.

path.extname("index.html") // => ".html"
path.extname("index.") // => "."
path.extname("index") // => ""
path.extname(".gitignore") // => "" (leading dot is not an extension)

Builds a path string from a partial Path.Parsed object. base takes precedence over name + ext, and dir takes precedence over root.

path.format({ dir: "/home/ada", name: "file", ext: ".ts" }) // => "/home/ada/file.ts"
path.format({ root: "/", base: "file.txt" }) // => "/file.txt"
path.format({ name: "file", ext: ".txt" }) // => "file.txt"

Converts a file: URL into a filesystem path. Fails with BadArgument when the scheme is not file:, a hostname is present, or the path contains an encoded separator (%2F). Returns Effect<string, BadArgument>.

import { Effect, Path } from "effect"
Effect.gen(function*() {
const path = yield* Path.Path
return yield* path.fromFileUrl(new URL("file:///home/ada/app.ts"))
// => "/home/ada/app.ts"
})

Returns whether the path is absolute (rooted).

path.isAbsolute("/foo/bar") // => true
path.isAbsolute("foo/bar") // => false
path.isAbsolute("") // => false

Joins all segments with the platform separator, then normalizes the result. Empty segments are ignored; with no segments it returns ".".

path.join("/foo", "bar", "baz/asdf", "quux", "..") // => "/foo/bar/baz/asdf"
path.join("a", "", "b") // => "a/b"
path.join() // => "."

Resolves . and .. segments and collapses redundant separators. Does not make the path absolute and preserves a trailing separator if present.

path.normalize("/foo/bar//baz/asdf/quux/..") // => "/foo/bar/baz/asdf"
path.normalize("a/./b/../c") // => "a/c"
path.normalize("") // => "."

Decomposes a path into a Path.Parsed object: { root, dir, base, ext, name }. The inverse of format.

path.parse("/home/ada/file.txt")
// => { root: "/", dir: "/home/ada", base: "file.txt", ext: ".txt", name: "file" }
path.parse("file.txt")
// => { root: "", dir: "", base: "file.txt", ext: ".txt", name: "file" }

Returns the relative path from from to to. Both arguments are resolved to absolute paths first; equal paths yield an empty string.

path.relative("/data/orandea/test/aaa", "/data/orandea/impl/bbb")
// => "../../impl/bbb"
path.relative("/home/ada/project", "/home/ada/project/src/main.ts")
// => "src/main.ts"

Resolves segments right-to-left into an absolute path, anchoring to the current working directory if no absolute segment is found. Processes . and ...

path.resolve("/foo/bar", "./baz") // => "/foo/bar/baz"
path.resolve("/foo/bar", "/tmp/file") // => "/tmp/file"
path.resolve("wwwroot", "static_files/png/", "../gif/image.gif")
// => "<cwd>/wwwroot/static_files/gif/image.gif"

Converts a filesystem path into a file: URL. The path is resolved to absolute first and special characters are percent-encoded. Returns Effect<URL, BadArgument>.

import { Effect, Path } from "effect"
Effect.gen(function*() {
const path = yield* Path.Path
const url = yield* path.toFileUrl("/home/ada/app.ts")
return url.href // => "file:///home/ada/app.ts"
})

Returns the namespace-prefixed path. A no-op on POSIX; on Windows it prefixes absolute paths with \\?\ (the extended-length namespace).

// POSIX implementation:
path.toNamespacedPath("/home/ada") // => "/home/ada"
// Windows implementation:
// path.toNamespacedPath("C:\\foo") // => "\\\\?\\C:\\foo"

The structured object produced by parse and consumed by format. All fields are strings.

interface Parsed {
readonly root: string // filesystem root, e.g. "/" (or "" if relative)
readonly dir: string // full directory, e.g. "/home/ada"
readonly base: string // file name with extension, e.g. "file.txt"
readonly ext: string // extension including the dot, e.g. ".txt"
readonly name: string // file name without extension, e.g. "file"
}

For a worked example, parse a path and rebuild it after changing one field — see Parsing and formatting above.

Path.Path is an ordinary service tag, so you can supply your own implementation (for tests, virtual file systems, or alternate platforms) with Layer.succeed. The implementation must include the Path.TypeId marker:

import { Effect, Layer, Path } from "effect"
const fakePath = Path.Path.of({
[Path.TypeId]: Path.TypeId,
sep: "/",
basename: (p) => p.split("/").pop() ?? "",
dirname: (p) => p.split("/").slice(0, -1).join("/") || "/",
extname: (p) => p.match(/\.[^.]*$/)?.[0] ?? "",
format: ({ dir, name, ext }) => `${dir ?? ""}/${name ?? ""}${ext ?? ""}`,
fromFileUrl: (url) => Effect.succeed(url.pathname),
isAbsolute: (p) => p.startsWith("/"),
join: (...ps) => ps.join("/"),
normalize: (p) => p.replace(/\/+/g, "/"),
parse: (p) => ({ root: "", dir: "", base: p, ext: "", name: p }),
relative: (from, to) => to.replace(from, ""),
resolve: (...ps) => ps.join("/"),
toFileUrl: (p) => Effect.succeed(new URL(`file://${p}`)),
toNamespacedPath: (p) => p
})
const testLayer = Layer.succeed(Path.Path)(fakePath)