Configurable types in Swift

When starting to write a new class, struct, or other type, we most often have a very specific goal or use case in mind. We might need a new model to represent some data that we’re working with, or we might want to encapsulate a piece of logic that’s tailored for a new feature that we’re building.

However, over time, we quite often find ourselves wanting to use a highly similar version of that same type or logic, but for something entirely different. We might want to reuse an existing model, but handle it in a slightly different way — or we could be looking to accomplish a task that’s very similar to something we’ve already solved, but with a different type of result.

The question then becomes — how to take our existing code, and refactor it to make it more generic and reusable — without ending up with something messy or unfocused. This week, let’s take a look at a technique for doing just that — that involves making certain types increasingly configurable.

Initially specific

There’s absolutely nothing wrong with writing code that’s specific for a single use case — in fact, it could definitely be argued that that’s how more or less any code should initially be created — to solve a very real use case in the simplest way possible.

Let’s say that we’re working on a note taking app, and that we want to add a feature that lets the user import a set of external notes from a group of text files contained within a folder. For an initial version of this feature, we’ve decided that we’re only going to handle notes that are either plain text files, or formatted using Markdown — so we write our first implementation with just those two use cases in mind, like this:

struct NoteImporter {
    func importNotes(from folder: Folder) throws -> [Note] {
        // Iterate over all the files contained within the
        // folder, and only handle the ones that have an
        // extension matching what we're supporting for now:
        return try folder.files.compactMap { file in
            switch file.extension {
            case "txt":
                return try importPlainTextNote(from: file)
            case "md", "markdown":
                return try importMarkdownNote(from: file)
            default:
                return nil
            }
        }
    }
}

As an initial implementation, the above is most likely good enough. However, as we’ll keep adding support for more and more text formats, we’ll always need to go back and modify the core logic of our above NoteImporter — which isn’t great from a flexibility point of view.

Ideally, we’d like NoteImporter to just have to worry about the task of orchestrating an actual import, rather than being bound to very specific file formats — otherwise the above switch statement is more or less guaranteed to grow out of control, sooner or later.

Different file formats aside, let’s say that the next thing that we want to add support for it audio-based notes — while also enabling the user to use the same import feature to bring their existing audio files into our app. While we’re at it, it would also be great if the user could import photos as well — and perhaps other kinds of media down the line.

With our current setup, each new kind of import requires a brand new implementation — giving us a group of different types that all perform highly similar tasks, using highly similar APIs:

struct AudioImporter {
    func importAudio(from folder: Folder) throws -> [Audio] {
        ...
    }
}

struct PhotoImporter {
    func importPhotos(from folder: Folder) throws -> [Photo] {
        ...
    }
}

While having distinct implementations for different use cases is in some ways a good thing — it separates concerns and enables us to optimize each type for each specific use case. However, without any kind of shared abstraction or common API, we’re also missing out on a ton of potential code reuse — and we’re making it hard to write shared APIs for any kind of file importer.

Time for some protocol-oriented programming?

An initial idea for solving that problem might be to use a protocol-oriented approach — and create a protocol that all of our various file importers can conform to:

protocol FileImporting {
    associatedtype Result
    func importFiles(from folder: Folder) throws -> [Result]
}

Using the above, we could let each type of importer remain separate, while making all of them conform to the same protocol — giving us a shared and consistent API, while still enabling each kind of importer to be tailor-made for each use case:

extension AudioImporter: FileImporting { ... }
extension PhotoImporter: FileImporting { ... }
extension PlainTextImporter: FileImporting { ... }
extension MarkdownImporter: FileImporting { ... }

Protocol-oriented programming is really great, but it does come with a few downsides when used in situations like this. While we’ve now improved the consistency of our code, we’ll still end up with a ton of duplication — especially considering that the bulk of our logic (actually iterating over files and handling them) is going to be exactly the same for all implementors of our protocol.

Configurable types

Instead of creating an abstraction with multiple implementations, let’s try to create a single FileImporter type, that we’ll make configurable enough to be usable for all of our current (and, hopefully, future) use cases.

Since the only logic that’s actually different between our various file importers is how each file is handled, let’s make just that part configurable — while using the same implementation for everything else. In this case, we could do that by creating a struct that contains a dictionary of handlers as its only property:

struct FileImporter<Result> {
    typealias FileType = String

    var handlers: [FileType : (File) throws -> Result]
}

Above we’re using a type alias to make our code slightly more self-documenting, by describing what the dictionary’s keys will be used for, without having to introduce any additional types.

We’ll then implement a single importFiles method — that contains just the logic for iterating over all of the files within a folder, and for handling each file using any handler that was matched based on that file’s extension — like this:

extension FileImporter {
    func importFiles(from folder: Folder) throws -> [Result] {
        return try folder.files.compactMap { file in
            guard let handler = handlers[file.extension] else {
                return nil
            }

            return try handler(file)
        }
    }
}

With the above in place, we now have a much more generic implementation, that can then be specialized in order to solve specific use cases. But we wouldn’t want each call site to have to manually specify all handlers (that’ll quickly lead to a ton of inconsistency), so we’ll perform our specialization in shared, static factory methods. That way, we can create a factory method for each specific kind of import — like this one, for notes:

extension FileImporter where Result == Note {
    static func notes() -> FileImporter {
        return FileImporter(handlers: [
            "txt": importPlainTextNote,
            "text": importPlainTextNote,
            "md": importMarkdownNote,
            "markdown": importMarkdownNote
        ])
    }

    private static func importPlainTextNote(from file: File) throws -> Note {
        ...
    }

    private static func importMarkdownNote(from file: File) throws -> Note {
        ...
    }
}

Above we’re using a same-type constraint to be able to extend FileImporter with functionality that’s specific for importing notes. You can learn more about that Swift language feature in this article.

Notice how we can now both easily add support for new file formats (without having to update our core FileImporter logic), and we can also add specific, private utility methods to our Note-bound extension — which still lets us structure our code in a neat way, even though it’s now more decoupled.

Creating file importer instances is still just as easy as when we were using dedicated types for each kind of import — all we have to do is to call the factory method for the kind of model we wish to import, and Swift’s type inference will figure out the rest:

let notesImporter = FileImporter.notes()
let audioImporter = FileImporter.audio()
let photoImporter = FileImporter.photos()

The beauty of the fact that we can now create different configurations of the same type, using static functions, is that we can also easily add parameters that’s specific to each use case. For instance, let’s say that we wanted to experiment with adding support for the OGG audio format — that could now be done without affecting any of our other file importing code, while still building right on top of the very same FileImporter functionality:

extension FileImporter where Result == Audio {
    static func audio(
        includeOggFiles: Bool = FeatureFlags.Audio.enableOggImports
    ) -> FileImporter {
        var handlers = [
            "mp3": importMp3Audio,
            "aac": importAacAudio
        ]

        if includeOggFiles {
            handlers["ogg"] = importOggAudio
        }

        return FileImporter(handlers: handlers)
    }
}

Above we’re using a feature flag to control whether OGG files should be imported. To learn more about using various kinds of feature flags, check out “Feature flags in Swift”.

By making our FileImporter type configurable, we’ve now gained a ton of flexibility, while also reducing code duplication — which is a pretty big win! But perhaps we could take things even further? 🤔

To infinity, and beyond!

While our current implementation is already quite configurable, it does constrain each import to be completely based on the extensions of the files that are being imported — which might not be what we want in all cases.

For example, let’s say that we wanted to merge some of our current import mechanisms into one single feature — to enable the user to import different kinds of content in one go. To do that, we want to make a single pass through a folder, and turn each compatible file that was found into a member of this enum:

enum FileImportResult {
    case note(Note)
    case audio(Audio)
    case photo(Photo)
}

That’s not easily done with our current setup, since we’re only able to customize what handlers to use for each file extension — we’re not yet able to take complete control over the actual file handling. Let’s fix that!

Instead of hard-wiring our FileImporter to a dictionary of handlers, let’s instead make it configurable with a single handler that each imported file will be passed into:

struct FileImporter<Result> {
    typealias Handler = (File) throws -> Result?
    var handler: Handler
}

We can then retrofit the above new version of FileImporter, to still be configurable with a dictionary of handlers, by extending it with an initializer that matches the one we had before:

extension FileImporter {
    typealias FileType = String

    init(handlers: [FileType : Handler]) {
        handler = { file in
            try handlers[file.extension]?(file) ?? nil
        }
    }
}

With the above change, our importFiles method now becomes really simple — as it only needs to compactMap over the given folder’s files using that single handler that it now contains:

extension FileImporter {
    func importFiles(from folder: Folder) throws -> [Result] {
        return try folder.files.compactMap(handler)
    }
}

By refactoring our FileImporter to use a function instead of a concrete set of options, we’ve more or less made it “infinitely configurable”, at least within the constraints of iterating over files within a folder — since we’re now able to take complete control over how each file will be handled, should we want to.

Using that new power, we’re now able to build our combined file importer — by attempting to handle each file using each underlying importer, all with linear time complexity — like this:

extension FileImporter where Result == FileImportResult {
    static func combined() -> FileImporter {
        let importers = (
            notes: FileImporter<Note>.notes(),
            audio: FileImporter<Audio>.audio(),
            photos: FileImporter<Photo>.photos()
        )

        return FileImporter { file in
            try importers.notes.handler(file).map(Result.note) ??
                importers.audio.handler(file).map(Result.audio) ??
                importers.photos.handler(file).map(Result.photo)
        }
    }
}

What we’ve essentially done during this last step, is to extract a core part of our file importer’s behavior into a configurable function — while still providing a backward compatible default. That combination is really powerful, as it lets us easily setup any FileImporter instance according to our current needs, while still building on a shared set of defaults and fundamental logic.

Conclusion

Enabling some of our types to be configured in various ways can let us unlock new use cases for code that we’ve already written — without making it really complex or hard to maintain.

While a hard-coded implementation that’s specific for a single use case will probably always be the simplest solution, the additional flexibility that configurable types give us can often be worth the effort — especially when we want to build multiple pieces of functionality on top of a shared foundation.

It’s also worth considering using a configurable type for some of the use cases for which we might’ve used a protocol in the past — since doing so could lead to a simpler overall structure, and fewer types to maintain.

What do you think? Have you used some of the techniques from this article before, or is it something you’ll try out? Let me know — along with your questions, comments and feedback — on Twitter @johnsundell or via email.

Thanks for reading! 🚀

Defining testing data in Swift

Shifting paradigms in Swift