Converting Codable Models To CSV

Converting Codable Models To CSV
Photo by Mika Baumeister / Unsplash

Recently, while working on a side project, I needed to convert an array of Codable models into a CSV file. While there are several 3rd-party solutions available, I was reluctant to introduce yet another dependency into my project for what seemed like a fairly straightforward task. Instead, I'd like to share an implementation you can use to perform this task.

Both of these implementations work well when you can make some assumptions about the variability of your data. If you have highly dynamic and variable input data, then by all means use a more robust 3rd-party solution.

This is all about creating a simple solution for simple use cases.

Basic Implementation

Let's start by creating a model and adding some fake data.

struct Employee: Codable {
    var id: Int
    var name: String
    var department: String
}

let employees = [
    Employee(id: 1, name: "Alice", department: "HR"),
    Employee(id: 2, name: "Bob", department: "IT"),
    Employee(id: 3, name: "Charlie", department: "Finance")
]

Our first step in creating the CSV file is to set up the header row that specifies and outlines the columns.

To do this, we can use the Mirror API in Swift, which gives us a way to list out all the properties of an object as key and value pairs. If you want to learn more about the Mirror API, I recommend checking out this article.

According to Apple, the Mirror API "provides a representation of the substructure and display style of an instance of any type."

We'll use the first item in our input as the template for the header row:

func convertToCSV<T: Codable>(_ items: [T]) -> String {
    var csvString = ""

    // Reflection to get property names for the header row
    if let firstItem = items.first {
        let mirror = Mirror(reflecting: firstItem)

        // The label is the name of the property in the object
        // being reflected (i.e. id, name, department)
        let headers = mirror.children.compactMap { $0.label }
        csvString += headers.joined(separator: ",") + "\n"
    }
}

Now, to build the rows, we'll use Mirror again, but we'll extract the value this time. We’ll also include some basic escaping and validation logic to ensure everything runs smoothly.

func convertToCSV<T: Codable>(_ items: [T]) -> String {
    var csvString = ""

    // Reflection to get property names for the header row
    if let firstItem = items.first {
        let mirror = Mirror(reflecting: firstItem)
        let headers = mirror.children.compactMap { $0.label }
        csvString += headers.joined(separator: ",") + "\n"
    }

    // Convert each instance to a CSV row
    for item in items {
        let mirror = Mirror(reflecting: item)
        let values = mirror.children.map { child -> String in
            let value = "\(child.value)"

            // Basic escaping; handles potential commas and quotes in values
            if value.contains(",") || value.contains("\"") {
                return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
            }
            return value
        }

        // This trailing newline is required for a valid .csv file
        csvString += values.joined(separator: ",") + "\n"
    }

    return csvString
}

// Usage
// convertToCSV(employees)
//
// Output
// id,name,department
// 1,Alice,HR
// 2,Bob,IT
// 3,Charlie,Finance
//

Advanced Implementation

Great! We have a working implementation, but there's a few things I'd like to address:

  • How can I make the column names capitalized? How can I create more readable column names?
  • How might we pre-format values, like dates, before exporting them?
  • Can we selectively choose which properties to include in the output file?

Let's start by creating a struct to encapsulate the types of customizations we want to make.

struct CSVCustomization {
    var headerTransform: ((String) -> String)?
    var valueTransform: ((String, Any) -> String)?
    
    // Set of property names to exclude
    var excludedProperties: Set<String> = []  
}

Next, we'll pass that into our convertToCSV function:

func convertToCSV<T: Codable>(
        _ items: [T],
        customization: CSVCustomization? = nil
    ) -> String { ... }

Now, when constructing the header, we'll first verify that the current property isn't listed under excludedProperties and then proceed to apply any necessary transformations using headerTransform:

var csvString = ""

// Reflection to get property names for headers
if let firstItem = items.first {
    let mirror = Mirror(reflecting: firstItem)
    var headers = mirror.children.compactMap { $0.label }

    // Exclude headers
    if let excluded = customization?.excludedProperties {
        headers = headers.filter { !excluded.contains($0) }
    }

    // Apply header transformations if provided
    if let headerTransform = customization?.headerTransform {
        headers = headers.map(headerTransform)
    }

    csvString += headers.joined(separator: ",") + "\n"
}

We'll make a similar adjustment to our row-building logic. Note the shift from using map to compactMap.

// Convert each instance to a CSV row
for item in items {
    let mirror = Mirror(reflecting: item)
    let values = mirror.children.compactMap { child -> String? in
        guard let label = child.label, !(customization?.excludedProperties.contains(label) ?? false) else {
            return nil
        }
        var value = "\(child.value)"

        // Apply value transformations if provided
        if let valueTransform = customization?.valueTransform {
            value = valueTransform(label, child.value)
        } else {
            // Handle potential commas and quotes in values
            if value.contains(",") || value.contains("\"") {
                value = "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
            }
        }
        return value
    }
    csvString += values.joined(separator: ",") + "\n"
}

Here's the final implementation with a tweaked Employee model:

struct Employee: Codable {
    var id: Int
    var firstName: String
    var lastName: String
    var department: String
    var ssn: String
}

struct CSVCustomization {
    var headerTransform: ((String) -> String)?
    var valueTransform: ((String, Any) -> String)?

    // Set of property names to exclude
    var excludedProperties: Set<String> = []
}

func convertToCSV<T: Codable>(
    _ items: [T],
    customization: CSVCustomization? = nil
) -> String {

    var csvString = ""

    // Reflection to get property names for headers
    if let firstItem = items.first {
        let mirror = Mirror(reflecting: firstItem)
        var headers = mirror.children.compactMap { $0.label }

        // Exclude headers
        if let excluded = customization?.excludedProperties {
            headers = headers.filter { !excluded.contains($0) }
        }

        // Apply header transformations if provided
        if let headerTransform = customization?.headerTransform {
            headers = headers.map(headerTransform)
        }

        csvString += headers.joined(separator: ",") + "\n"
    }

    // Convert each instance to a CSV row
    for item in items {
        let mirror = Mirror(reflecting: item)
        let values = mirror.children.compactMap { child -> String? in
            guard let label = child.label, !(customization?.excludedProperties.contains(label) ?? false) else {
                return nil
            }
            var value = "\(child.value)"

            // Apply value transformations if provided
            if let valueTransform = customization?.valueTransform {
                value = valueTransform(label, child.value)
            } else {
                // Handle potential commas and quotes in values
                if value.contains(",") || value.contains("\"") {
                    value = "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
                }
            }
            return value
        }
        csvString += values.joined(separator: ",") + "\n"
    }

    return csvString
}

// Usage
let employees = [
    Employee(id: 1, firstName: "Brad", lastName: "Pitt", department: "HR", ssn: "123-45-1234"),
    Employee(id: 2, firstName: "Bruce", lastName: "Willis", department: "IT", ssn: "987-45-1234"),
    Employee(id: 3, firstName: "Tom", lastName: "Cruise", department: "Finance", ssn: "123-46-1234"),
    Employee(id: 4, firstName: "Jackie", lastName: "Chan", department: "Legal", ssn: "315-45-1234")
]

let customizations = CSVCustomization(
    headerTransform: {
        if $0 == "firstName" {
            return "First Name"
        } else if $0 == "lastName" {
            return "Last Name"
        }

        return $0.capitalized
    },
    valueTransform: nil,
    excludedProperties: ["ssn"]
)

convertToCSV(employees, customization: customizations)

// Output
// Id,First Name,Last Name,Department
// 1,Brad,Pitt,HR
// 2,Bruce,Willis,IT
// 3,Tom,Cruise,Finance
// 4,Jackie,Chan,Legal
//

If you're interested in more articles about iOS Development & Swift, check out my YouTube channel or follow me on Twitter.

And, if you're an indie iOS developer, make sure to check out my newsletter! Each issue features a new indie developer, so feel free to submit your iOS apps.

Ace The iOS Interview
The best investment for landing your dream iOS jobHey there! My name is Aryaman Sharda and I started making iOS apps way back in 2015. Since then, I’ve worked for a variety of companies like Porsche, Turo, and Scoop Technologies just to name a few. Over the years, I’ve mentored junior engineers, bui…
Indie Watch
Indie Watch is an exclusive weekly hand-curated newsletter showcasing the best iOS, macOS, watchOS, and tvOS apps from developers worldwide.

Subscribe to Digital Bunker

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
[email protected]
Subscribe