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.