A Type-Safe FileWrapper
When I started developing for macOS one of my first discoveries was FileWrapper
, how come that after developing for iOS many years I had never had to use it? If you haven't worked on a document-based app you may have not used as well. You might have used FileManager
for creating folders and adding files to it.
Overview
The documentation describes FileWrapper
as:
A representation of a node (a file, directory, or symbolic link) in the file system.
On a document-based app the FileWrapper
acts as "middleware" between the file system and the actual models in your app, similar to a JSON API that you get from a backend. FileWrapper
can handle single files or packages, which is a directory with files treated as a single file by the system, these packages are usually custom file formats created by the application. For example, Pages have a .pages
and Keynote a .key
extension for their documents.
When the user performs any changes in the file content, the app has to mutate the object. It should return a new or mutated FileWrapper instance, so NSDocument will persist it to the disk.
Another use case for using FileWrapper
are command-line tools. In this case, the need to output complex trees of folders and files is frequent. For example, imagine a CLI tool that generates xcassets
folders.
When decoding data from a JSON API, one could use a plain dictionary. But that's far from ideal, as type-safe objects are a better approach. In a similar way, one can use the FileWrapper
API directly. But a type-safe implementation on top of FileWrapper will simplify reading and writing objects.
Site folders example
For this example, I will show how we can create a simple website folder structure, keep in mind that the same can be applied to a package. Let's consider the following folder structure:
Site
├─ readme.md
├─ Posts/
├─ one.md
├─ two.md
Using FileWrapper
To represent this folder using the regular FileWrapper
's API we could do something like this:
// Post one
let postOne = FileWrapper(regularFileWithContents: Data("# My first post\n\nContent".utf8))
readme.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()
readme.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()
// Post two
let postTwo = FileWrapper(regularFileWithContents: Data("# My second\n\nContent".utf8))
readme.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()
readme.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()
// The posts to a Posts folder
let postsFolder = FileWrapper(directoryWithFileWrappers: [
"one.md": postOne,
"two.md": postTwo
])
postsFolder.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()
postsFolder.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()
// A readme.md
let readme = FileWrapper(regularFileWithContents: Data("File contents".utf8))
readme.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()
readme.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()
// The site folder and add readme and posts to it
let site = FileWrapper(directoryWithFileWrappers: [
"readme.md": readme,
"Posts": postsFolder
])
site.preferredFilename = "Site"
site.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()
site.fileAttributes[FileAttributeKey.modificationDate.rawValue] = Date()
// Write the site to disk
try site.write(
to: URL(fileURLWithPath: "/some/path/Site"),
options: .atomic,
originalContentsURL: nil
)
The structure is a dictionary and it is a lot of work to create a simple folder structure with basic properties. You may notice we are going to have problems as the folder structure grows in complexity.
Can we do better?
Type all the things!
We can break down this folder structure in two types Folder
and File
that can be represented by two structs.
struct File {
var name: String
var createdAt: Date = Date()
var modifiedAt: Date = Date()
var content: String = ""
}
struct Folder {
var name: String
var createdAt: Date = Date()
var modifiedAt: Date = Date()
var children: [FileNode] = []
}
As you can see Folder
and File
share some properties and that can be organized into a protocol so we make sure it is consistent and it will simplify our lives while creating the FileWrapper
:
/// A node that represents files or folders.
protocol FileNode {
var name: String { get set }
var createdAt: Date { get set }
var modifiedAt: Date { get set }
}
/// A representation of a file.
protocol RegularFile: FileNode {
var content: String { get set }
}
/// A representation of a directory with N children.
protocol DirectoryFile: FileNode {
var children: [FileNode] { get }
}
Now make sure that File
and Folder
conforms to these protocols:
struct File: RegularFile {
...
struct Folder: DirectoryFile {
...
Converting objects to FileWrapper
To make easy to convert our objects to FileWrapper
let's create another protocol:
protocol FileWrapperConvertible {
func toFileWrapper() -> FileWrapper
}
Then:
protocol FileNode: FileWrapperConvertible {
...
Because FileNode
conforms to FileWrapperConvertible
we can start using the toFileWrapper
function without knowing if it's a File
or Directory
.
Wrapping it up
Let's use the default implementation of FileWrapperConvertible
protocol to implement func toFileWrapper()
:
// To avoid duplicated code we have a function `create`
// where `Self` is `FileNode` and the file name, creation
// and update dates are available.
extension FileWrapperConvertible where Self: FileNode {
private func create(_ wrapper: FileWrapper) -> FileWrapper {
wrapper.preferredFilename = name
wrapper.fileAttributes[FileAttributeKey.creationDate.rawValue] = createdAt
wrapper.fileAttributes[FileAttributeKey.modificationDate.rawValue] = modifiedAt
return wrapper
}
}
// Where `Self` is a `RegularFile` creates a `FileWrapper`
// with its content.
extension FileWrapperConvertible where Self: RegularFile {
func toFileWrapper() -> FileWrapper {
create(FileWrapper(regularFileWithContents: Data(content.utf8)))
}
}
// Where `Self` is a `DirectoryFile` wrap all
// children to FileWrappers.
extension FileWrapperConvertible where Self: DirectoryFile {
func toFileWrapper() -> FileWrapper {
create(
FileWrapper(directoryWithFileWrappers: children.reduce(into: [:]) {
$0[$1.name] = $1.toFileWrapper()
})
)
}
}
Creating folders and files
At this point, we have everything set up and we can use our strong typed API to create folders and files.
// Creates the folder structure with text files.
let site = Folder(
name: "Site",
children: [
File(name: "readme.md", content: "File contents"),
Folder(name: "Posts", children: [
File(name: "one.md", content: "# My first post\n\nContent"),
File(name: "two.md", content: "# My second post\n\nContent")
])
]
)
// Converts the site to a `FileWrapper`.
let wrapper = site.toFileWrapper()
// Write the site to disk
try wrapper.write(
to: URL(fileURLWithPath: "/some/path/\(site.name)"),
options: .atomic,
originalContentsURL: nil
)
This looks a lot simpler to digest than the first block using pure FileWrapper
, In addition, this code is easier to test and safer.
Compare the code for updating a file:
site.name = "New name"
site.modifiedAt = Date()
VS the standard API:
site.preferredFilename = "New name"
site.fileAttributes[FileAttributeKey.creationDate.rawValue] = Date()
Conclusion
Making FileWrapper
strongly typed enables us to get the better testability and maintainability of our code.
What do you think? Have you used this approach before or would you use it? Let me know via Twitter.