Path.swift: absolute paths and chainable file operations
Delightful, robust, cross-platform and chainable file-pathing functions.
At a glance
- What is it?
- Path.swift is Max Howell's Swift library for working with file system paths, and its central decision is that a Path is always absolute, so the type cannot represent the relative path that causes a whole class of bugs. Around that sits a chainable API for creating and copying things, a deliberate distinction between copying to a path and copying into a directory, and listing helpers that behave like the terminal commands they are named after.
- Who is it for?
- Adopt Path.swift in a Swift codebase that touches the file system and keeps getting relative path bugs, since making the type absolute is a structural fix rather than a discipline. It is the wrong tool if you need relative paths as a first class concept, because the type refuses to represent them and the workaround, joining against the current directory, is explicit on purpose.
- Can I use it commercially?
- Yes. Unlicense is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 142 days ago.
- What is it written in?
- Mainly Swift, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The whole design in one snippet
The README opens with a block of Swift that shows the library's personality before it explains anything. There is an import, a static member for the home directory, and a joining operator:
import Path
// convenient static members
let home = Path.home
// pleasant joining syntax
let docs = Path.home/"Documents"
// paths are *always* absolute thus avoiding common bugs
let path = Path(userInput) ?? Path.cwd/userInputThen the chaining, which is the part that makes the library pleasant to use:
// elegant, chainable syntax
try Path.home.join("foo").mkdir().join("bar").touch().chmod(0o555)
// sensible considerations
try Path.home.join("bar").mkdir()
try Path.home.join("bar").mkdir() // doesn’t throw ∵ we already have the desired resultThat second pair of lines is a design statement rather than an example. A mkdir that succeeds when the directory already exists is idempotent, which is what a build script or an installer wants, and the comment gives the reason in the author's own words, we already have the desired result.
The library describes itself as focused on developer experience and predictable end results, and emphasises safety and correctness, just like Swift, with a thoughtful and comprehensive yet concise API. That is a fair summary of what the type system is doing here, because most of the API's design decisions are about making the wrong thing hard to express rather than about adding features.
The remaining top-level example shows the copy distinction, which is worth reading twice:
// careful API considerations so as to avoid common bugs
let foo = try Path.root.join("foo").copy(into: Path.root.join("bar").mkdir())
print(foo) // => /bar/foo
print(foo.isFile) // => true
// ^^ the `into:` version will only copy *into* a directory, the `to:` version copies
// to a file at that path, thus you will not accidentally copy into directories you
// may not have realized existed.A copy operation that silently turns a destination into a directory is a classic source of scripts writing files to the wrong place, and this API refuses to make that mistake by having two distinct verbs.
Absolute by construction, and the price of that decision
The absolute path rule is the one that shapes everything else, and the README explains both the benefit and the cost.
The Path initializer returns nil unless it is fed an absolute path. So a relative path is a compile time visible nil, not a Path that quietly behaves differently depending on the working directory. Initializing from user input that may be relative takes a form the README calls explicit rather than hiding anything that code review may miss:
let path = Path(userInput) ?? Path.cwd/userInputThe comment on the initializer is about ergonomics rather than safety. The initializer is nameless, to be consistent with the equivalent operation for converting strings to Int, Float and so on in the standard library, which is a small decision that makes the type feel native to a Swift programmer.
The cost shows up when you already know the string, and the README has a section for that case. If the string is known to be absolute, the joining operator against the root handles it, and if it is known to be relative, you join it to the root as well, which gives the same result:
let absolutePath = "/known/path"
let path1 = Path.root/absolutePath
let pathWithoutInitialSlash = "known/path"
let path2 = Path.root/pathWithoutInitialSlash
assert(path1 == path2)
let path3 = Path(absolutePath)! // at your options
assert(path2 == path3)
// be cautious:
let path4 = Path(pathWithoutInitialSlash)! // CRASH!The last line is the failure mode the whole design is built around, and the comment calls it a crash. Forcing the optional on a relative string traps, and a trap on a path that came from a config file or a command line argument is a crash rather than a wrong file, which is the safer of the two failures but only if you notice the constraint.
For a library that persists paths, the same absolutism creates a second problem, and the Codable section is the answer to it.
Encoding relative paths, and the asymmetry that comes with it
The library supports Codable, as expected, and the first example encodes a pair of absolute paths:
try JSONEncoder().encode([Path.home, Path.home/"foo"])which produces the JSON array of the two absolute paths. But the README recommends encoding relative paths instead, and shows how, with a userInfo key:
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = Path.home
encoder.encode([Path.home, Path.home/"foo", Path.home/"../baz"])The output is then relative, with an empty string for the home directory itself, foo for the child and ../baz for the parent traversal.
The footnote gives the reason, and it is a good one. If you are saving files to a system provided location, for instance Documents, then the directory could change at Apple's choice, or if the user changes their username. Using relative paths also provides the flexibility in future to change where you are storing your files without hassle.
An absolute path baked into a JSON file is a liability on any platform where the user's home directory is not fixed, and a saved path is exactly the kind of thing that gets committed to a repository or shared between machines.
The cost is an asymmetry, and the README flags it. If you encode with the .relativePath key set, you must decode with the key set as well, since the decoder throws if .relativePath is not set. That is a deliberate fail-fast: decoding an absolute path document with the relative key would silently produce a different location, so the library refuses instead. The rule is easy to forget and the error message is not in the README, which is the one gap in this section.
Pathish, DynamicPath and the friction of mixing them
There are two concrete types in play. Path is the value type, and DynamicPath is the result of expressions like Path.root, the thing that supports dynamic member lookup. Both conform to a protocol called Pathish, which contains all the pathing functions.
That is the design that lets Path.root.usr.bin.ls work, since DynamicPath exposes the pathing functions and the dynamic member lookup gives the components. And the protocol exists so that code can be written against either type.
The catch is that the two are not interchangeable in a collection, and the README is direct about it:
let path1 = Path("/usr/lib")!
let path2 = Path.root.usr.bin
var paths = [Path]()
paths.append(path1) // fine
paths.append(path2) // error
paths.append(Path(path2)) // okThe workaround is to convert the DynamicPath into a Path before adding it, and the README concedes that this is inconvenient, and that as Swift stands there is nothing they can think of that would help.
That is an honest admission of a real ergonomic cost, and it is worth weighing against the alternative. The alternative to dynamic member lookup is spelling out every component, which is exactly the sort of noise the feature removes.
The library does restrict the feature deliberately. Dynamic members are only provided for starting members such as Path.home or Bundle.path, and the README gives the reason from practice: it was easy to write incorrect code, since everything would compile if arbitrary variables could take any named property as valid syntax. What they have is what you want most of the time, but much less, potentially, dangerous at runtime. So Path.root.usr.bin.ls is allowed, while arbitrary variables cannot take any named property as valid syntax.
The same reasoning is why the library is a micro-framework, as the topics list says, rather than a general purpose path utility. Small surface, strong opinions, no surprises.
ls() and find(), named after what they do
The directory listing helpers are named for the terminal commands they imitate, and the README explains the reasoning: the name implies the behaviour, that is, that it is not recursive and does not list hidden files.
for path in Path.home.ls() {
//…
}
for path in Path.home.ls() where path.isFile {
//…
}
let dirs = Path.home.ls().directories
// ^^ directories that *exist*
let files = Path.home.ls().files
// ^^ files that both *exist* and are *not* directories
let swiftFiles = Path.home.ls().files.filter{ $0.extension == "swift" }
let includingHiddenFiles = Path.home.ls(.a)The hidden file case uses the short option letter from the ls command, .a, which is a small piece of consistency between the library and the shell it is imitating.
One behaviour is worth knowing because it is unusual, and because the author flags it himself. ls() does not throw. Instead it outputs a warning to the console if it fails to list the directory, and the README says the rationale for this is weak and asks for a ticket to discuss it. So a failed listing is a warning on stderr and an empty result rather than an error, which is a decision to argue with rather than to depend on.
find() is the recursive counterpart, and it behaves like the terminal find, descending all directories and including hidden files by default:
for path in Path.home.find() {
// descends all directories, and includes hidden files by default
// so it behaves the same as the terminal command `find`
}It is configurable with a fluent chain, where depth takes a maximum, extension filters by suffix and type filters by file or directory:
for path in Path.home.find().depth(max: 1).extension("swift").type(.file).hidden(false) {
//…
}and it can be driven with a closure when the fluent form is not enough, where the return value decides whether the walk continues, skips a directory or aborts:
Path.home.find().depth(2...3).execute { path in
guard path.basename() != "foo.lock" else { return .abort }
if path.basename() == ".build", path.isDirectory { return .skip }
//…
return .continue
}The abort case in that example is the one to notice, since a lock file is a legitimate reason to stop the walk entirely, and the closure form is the only way to express that.
The project around the library
Path.swift is by Max Howell, who introduces himself in the README as having written a lot of open source software, generally a good deal of his free time, and asks for sponsorship to justify creating new open source and maintaining it. The library is distributed under the Unlicense, and the LICENSE.md file at the repository root carries the terms. This describes the published terms rather than legal advice.
The distribution is the conventional Swift pair. There is a Package.swift for Swift Package Manager and a Path.swift.podspec for CocoaPods, along with the Sources and Tests directories, and a .travis.yml that dates the continuous integration arrangement, with a .github directory alongside it.
The release record is spread out. Version 1.4.1 came in October 2023, 1.5.0 in March 2026 and 1.6.0 in April 2026, so the project had a long quiet period followed by a burst of work, and the last push was on 2026-06-03. The repository is not archived.
The documentation is a strong point, and it is stated precisely. The online API documentation covers 100 per cent of the public API and is automatically updated for new releases, and the README calls the handbook a section rather than a documentation link dump. For a library whose value is in the details of its API, an index that is complete and current is the difference between adopting it and guessing.
The judgement on the library as a whole comes down to whether you agree with its one big idea. If you think paths should be absolute, and that the type system should enforce it, then everything else in the API is in service of that and the library is a pleasure to use. If your application genuinely needs relative paths as a value, or paths that are not yet known to exist, then the design is fighting you, and the explicit fallbacks it provides, joining against the current directory and the relative encoding mode, are the price of admission rather than a workaround.
Editorial conclusion
Adopt Path.swift in a Swift codebase that touches the file system and keeps getting relative path bugs, since making the type absolute is a structural fix rather than a discipline. It is the wrong tool if you need relative paths as a first class concept, because the type refuses to represent them and the workaround, joining against the current directory, is explicit on purpose. Before integrating, read the copy(to:) and copy(into:) distinction, since the two differ only by a word and produce different results, and note that ls() does not throw, it prints a warning, which the author himself flags as a weak rationale and invites discussion on.
Frequently asked questions
What does Path.swift do in Swift?
It provides a Path type for file system work with chainable operations such as join, mkdir, touch, chmod and copy, plus listing helpers that behave like the ls and find terminal commands. Its central design decision is that a Path is always absolute.
How do I create a Path from a string that might be relative?
The Path initializer returns nil unless the string is absolute, so for user input you write Path(userInput) ?? Path.cwd/userInput. The README presents that form as explicit on purpose, since it prevents relative strings from silently becoming Path objects.
What is the difference between copy(to:) and copy(into:)?
The into version only copies into a directory, producing the new file inside it, while the to version copies to a file at that exact path. The two exist so that you do not accidentally copy into a directory you had not realised existed.
Why should I encode relative paths with Path.swift?
The README recommends it because a system provided location can change at Apple's choice and a user's username can change, so a stored absolute path is a liability. You set the .relativePath key in the encoder's userInfo, and you must set the same key on the decoder or the decode throws.
Does ls() in Path.swift throw when it fails?
No. It writes a warning to the console and returns an empty result instead of throwing, and the README says the rationale for that is weak and invites a ticket for discussion. find() is the recursive counterpart and includes hidden files by default.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/mxcl-path-swift)Was this useful?