00:06 Brandon is in Berlin for the Functional Swift
Conference to talk about phantom types, a
technique that can make file paths more type-safe. We'll discuss parts of that
technique here, but Brandon's
talk dives deeper into the matter.
The Problems
00:42 Let's say we're creating an API that opens a file path:
let path = "/Users/chris/test.md"
func open(path: String) {
}
open(path: path)
01:28 If the method takes a string, there are many things that could go
wrong. The compiler doesn't enforce the fact that the string we're passing into
the method is actually a path. We could just try to use a random string, like an
email address. But as users of this API, we're responsible for using it
correctly ourselves, so that's not ideal.
02:14 Even if the strings we work with are proper paths, things can
still go wrong. We may want to append components to a path, but we have to make
sure we only append a file to a directory path, and not to a path that already
points to a file:
let path = "/Users/chris/test.md"
path + "/" + "test.md"
03:04 Additionally, when we're dealing with an absolute path like the
above, we shouldn't be allowed to prepend to it.
Path
03:38 In order to make working with paths safer, we start by introducing
a Path
struct that wraps the string:
struct Path {
var path: String
init(_ path: String) {
self.path = path
}
}
let path = Path("/Users/chris/test.md")
04:40 The value we created now has a more descriptive type, Path
, and
an API that asks for a Path
reminds us that we shouldn't pass in, say, an
email string:
func open(path: Path) {
}
04:55 That's a small win, though it doesn't prevent us from making
mistakes when we want to transform the path. But before looking into fixing
these problems, we first add the ability to append to the path:
struct Path {
func appending(_ component: String) -> Path {
return Path(path + "/" + component)
}
}
06:03 Now we can append components to a path:
let path = Path("/Users/chris")
let path2 = path.appending("test.md")
print(path2)
06:26 Using this API, we end up with nonsense paths if we append to a
path that has a trailing slash or if we append a file name twice:
let path = Path("/Users/chris/")
let path2 = path.appending("test.md")
let path3 = path2.appending("test.md")
print(path3)
07:13 Instead of wrapping a single string, we can let Path
wrap an
array of path components. A label on the initializer adds a little more clarity:
struct Path {
var components: [String]
init(directoryComponents: [String]) {
self.components = directoryComponents
}
func appending(_ component: String) -> Path {
return Path(directoryComponents: components + [component])
}
}
09:18 We add a property that renders the components to a path string.
Here we make the decision that we only work with absolute paths — so we start
the rendered path with a slash:
struct Path {
var rendered: String {
return "/" + components.joined(separator: "/")
}
}
Directory or File
10:23 We still have to prevent appending two file names. We can catch
this violation at runtime by checking a Boolean that indicates whether or not
we're still allowed to append to the path:
struct Path {
var components: [String]
var isFile: Bool
init(directoryComponents: [String]) {
isFile = false
self.components = directoryComponents
}
}
11:20 We need separate appending methods for directories and files. In
the file-appending method, we want to initialize a path with isFile
set to
true
. By moving our custom initializer into an extension, we gain back the
memberwise initializer, which we'll call in the file-appending method:
struct Path {
var components: [String]
var isFile: Bool
func appending(directory: String) -> Path {
return Path(directoryComponents: components + [directory])
}
func appending(file: String) -> Path {
return Path(components: components + [file], isFile: true)
}
var rendered: String {
return "/" + components.joined(separator: "/")
}
}
extension Path {
init(directoryComponents: [String]) {
isFile = false
self.components = directoryComponents
}
}
12:58 We can check the Boolean at runtime with a precondition:
func appending(directory: String) -> Path {
precondition(!isFile)
return Path(directoryComponents: components + [directory])
}
func appending(file: String) -> Path {
precondition(!isFile)
return Path(components: components + [file], isFile: true)
}
13:33 Now we get a failure when we run the code and try to append a file
twice:
let path = Path(directoryComponents: ["Users", "chris"])
let path2 = path.appending(file: "test.md")
let path3 = path2.appending(file: "test.md")
13:36 This error reporting works as long as we have good test coverage.
But it'd be even better if the compiler gives us an error before runtime. This
means the compiler has to understand the difference between directory paths and
file paths, so we introduce a new type parameter for Path
:
struct Path<FileType> {
}
14:32 Now that we have a type parameter, we can insert a File
or
Directory
type into it. We create these types as enums without any cases. This
way, the types don't have any constructors, which means there can't be a value
of the types at runtime; they can only exist at compile time:
enum File {}
enum Directory {}
15:04 We get rid of the isFile
Boolean, and instead we use the type
parameter of Path
to express that it points to a file or a directory. We
implement this by only appending components within an extension constrained to
Directory
paths:
struct Path<FileType> {
var components: [String]
var rendered: String {
return "/" + components.joined(separator: "/")
}
}
extension Path where FileType == Directory {
init(directoryComponents: [String]) {
self.components = directoryComponents
}
func appending(directory: String) -> Path<Directory> {
return Path(directoryComponents: components + [directory])
}
func appending(file: String) -> Path<File> {
return Path<File>(components: components + [file])
}
}
16:40 The appending methods now return a path with an explicit type. If
we'd simply return a Path
, the compiler would implicitly return a path with
the same file type as the path we're appending to.
17:14 We add a private initializer that overwrites the default public
memberwise initializer so that users outside the framework can't mess things up:
struct Path<FileType> {
var components: [String]
private init(_ components: [String]) {
self.components = components
}
}
extension Path where FileType == Directory {
func appending(file: String) -> Path<File> {
return Path<File>(components + [file])
}
}
18:39 We get an ambiguous reference error from the compiler when we try
to append a file to a file path. It's not a very helpful error message, but
it'll become clearer when we give the appending methods different names:
func appending(directory: String) -> Path<Directory> {
return Path(directoryComponents: components + [directory])
}
func appendingFile(_ file: String) -> Path<File> {
return Path<File>(components + [file])
}
19:25 Now we get the error we want, saying that Path<File>
is not
convertible to Path<Directory>
:
let path = Path(directoryComponents: ["Users", "chris"])
let path2 = path.appendingFile("test.md")
let path3 = path2.appendingFile("test.md")
19:33 To confirm our code works, we first append a directory component:
let path = Path(directoryComponents: ["Users", "chris"])
let path1 = path.appending(directory: "Documents")
let path2 = path1.appendingFile("test.md")
Then we check that appending a directory after a file fails:
let path = Path(directoryComponents: ["Users", "chris"])
let path1 = path.appendingFile("test.md")
let path2 = path1.appending(directory: "Documents")
20:34 This works much better already. We could also expand Path
to
understand absolute and relative paths and support combining the two. In
Brandon's conference talk, he
solves that challenge with another type parameter.
Phantom Types
20:56 As a member of Pinterest's Core Platform team, Brandon works on
tooling and build systems that read and write files all the time. An
implementation of Path
like the one we wrote today helps him prevent many
mistakes.
21:49 For an app that loads one or two files, the Path
implementation
might be too much overhead. But the technique we used can be applied to whatever
is most important for your domain.
22:15 Path
is a phantom type: at least one of its type parameters
isn't used in constructors, but only as a constraint. For an app that does
currency conversion, we might use a phantom type to keep track of which currency
we're dealing with.
23:12 Another way to look at today's challenge is to think of the
program as a state machine. Starting out with a directory path, we can keep
appending directory components and we'll stay in the same state. As soon as we
append a file, we switch to a different state, where different rules apply.
Anytime a program can be seen as a state machine, phantom types can be helpful.