Converting Codable Models To CSV
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.
Subscribe
New articles straight to your inbox.
No spam. Unsubscribe anytime.