Type-Safe Columns
Define columns using Swift's type system for compile-time safety and cleaner code.
Type-safe columns let you define your table structure using Swift's type system. Instead of manually converting your models to arrays of strings, you tell SwiftDataTables which properties to display — and it handles the rest.
The result: compile-time errors instead of runtime crashes, autocompletion that knows your model's properties, and code that's easier to read and maintain.
Before and After
Here's what changes when you use the typed API:
// OLD: Manual array conversion
let rows = users.map { [$0.name, String($0.age), $0.email] }
let dataTable = SwiftDataTable(data: rows, headers: ["Name", "Age", "Email"])
// NEW: Type-safe columns
let columns: [DataTableColumn<User>] = [
.init("Name", \.name),
.init("Age", \.age),
.init("Email", \.email)
]
let dataTable = SwiftDataTable(data: users, columns: columns)The typed approach eliminates several classes of bugs:
- Typos in property names —
\.nmaewon't compile, but"nmae"would silently break - Column order mismatches — The column definition is the source of truth, no array index juggling
- Type conversion errors — The compiler knows
ageis anInt, not aString - Refactoring safety — Rename a property and the compiler shows every place to update
The DataTableColumn API
DataTableColumn has several initializers, each suited to different needs:
KeyPath Columns
The simplest form — point directly at a property:
struct User: Identifiable {
let id: Int
let name: String
let age: Int
let balance: Double
}
let columns: [DataTableColumn<User>] = [
.init("Name", \.name), // String property
.init("Age", \.age), // Int property
.init("Balance", \.balance) // Double property
]KeyPath columns preserve the property's type. An Int stays an Int internally, which means numeric sorting works correctly — 9 comes before 10, not after.
Extraction Closures
When you need computed or formatted values, provide a closure:
let columns: [DataTableColumn<User>] = [
// Combine properties
.init("Full Name") { "\($0.firstName) \($0.lastName)" },
// Format numbers
.init("Balance") { "$\(String(format: "%.2f", $0.balance))" },
// Compute values
.init("Age Group") { $0.age < 18 ? "Minor" : "Adult" }
]The closure receives each row's model and returns the display value. You can return any type that conforms to DataTableValueConvertible — the library handles the conversion.
Header-Only Columns
For custom cells that handle their own rendering:
let columns: [DataTableColumn<User>] = [
.init("Avatar"), // Custom cell renders this
.init("Name", \.name), // Standard text cell
.init("Actions") // Custom cell with buttons
]Header-only columns define the column header but extract no value. Your custom cell provider handles the content. These columns are not sortable since there's no value to compare.
How Sorting Works
Every cell value is stored as a DataTableValueType:
public enum DataTableValueType {
case string(String)
case int(Int)
case float(Float)
case double(Double)
}When users tap a column header to sort, the type matters:
| Type | Sort Behavior | Example Order |
|---|---|---|
.string | Alphabetical (lexicographic) | "10", "2", "9" |
.int | Numeric | 2, 9, 10 |
.double | Numeric | 1.5, 2.0, 10.99 |
.float | Numeric | 1.5, 2.0, 10.99 |
KeyPath columns preserve the correct type automatically. But when you use a closure that returns a formatted string like "$99.99", the value becomes a string and sorts alphabetically.
Typed Sorting: Display One Way, Sort Another
Often you want to display a formatted value but sort by the underlying data. SwiftDataTables provides several initializers for this:
Format with KeyPath Sorting
Display a formatted string but sort by the original property:
let columns: [DataTableColumn<Product>] = [
// Displays "$49.99", sorts by 49.99
.init("Price", \.price) { "$\(String(format: "%.2f", $0))" },
// Displays "Jan 15, 2024", sorts chronologically
.init("Created", \.createdAt) { $0.formatted(date: .abbreviated, time: .omitted) },
// Displays "75%", sorts by 0.75
.init("Progress", \.progress) { "\(Int($0 * 100))%" }
]The first argument after the header is the keyPath to sort by. The trailing closure formats that value for display. Users see $49.99 but sorting uses the numeric 49.99.
Sort by a Specific Property
When displaying combined values but sorting by one property:
let columns: [DataTableColumn<Person>] = [
// Shows "Alice Smith", sorts by lastName
.init("Name", sortedBy: \.lastName) { "\($0.firstName) \($0.lastName)" },
// Shows "Widget ($49.99)", sorts by price
.init("Product", sortedBy: \.price) { "\($0.name) ($\(String(format: "%.2f", $0.price)))" }
]Use sortedBy: when the display combines multiple properties but you want to sort by just one.
Sort by Computed Values
When the sort value isn't a direct property:
let columns: [DataTableColumn<Task>] = [
// Sort by title length (shortest first)
.init("Title", sortedBy: { $0.title.count }) { $0.title },
// Sort by computed total
.init("Total", sortedBy: { $0.price * Double($0.quantity) }) {
"$\(String(format: "%.2f", $0.price * Double($0.quantity)))"
},
// Sort by custom priority order
.init("Priority", sortedBy: { $0.priority.sortOrder }) { $0.priority.displayName }
]The sortedBy: closure extracts the sortable value, and the trailing display: closure generates what users see.
Full Custom Comparison
For complete control over sort logic:
let columns: [DataTableColumn<Contact>] = [
// Case-insensitive sorting
.init("Name", sortedBy: {
$0.name.localizedCaseInsensitiveCompare($1.name)
}) { $0.name },
// Nulls last
.init("Due Date", sortedBy: { lhs, rhs in
switch (lhs.dueDate, rhs.dueDate) {
case (nil, nil): return .orderedSame
case (nil, _): return .orderedDescending // nil goes last
case (_, nil): return .orderedAscending
case (let a?, let b?): return a.compare(b)
}
}) { $0.dueDate?.formatted() ?? "No date" },
// Semantic version sorting (1.9 < 1.10)
.init("Version", sortedBy: {
$0.version.compare($1.version, options: .numeric)
}) { $0.version }
]The comparator receives two rows and returns a ComparisonResult. This handles edge cases like optional values, locale-aware string comparison, and version numbers.
Supported Types
The following types work automatically with KeyPath columns:
| Swift Type | Converts To | Sort Behavior |
|---|---|---|
String | .string | Alphabetical |
Int | .int | Numeric |
Float | .float | Numeric |
Double | .double | Numeric |
Optional<T> | Unwrapped value or empty string | Depends on T |
Adding Custom Type Support
Conform your types to DataTableValueConvertible:
extension Date: DataTableValueConvertible {
public func asDataTableValue() -> DataTableValueType {
let formatter = DateFormatter()
formatter.dateStyle = .short
return .string(formatter.string(from: self))
}
}
extension Decimal: DataTableValueConvertible {
public func asDataTableValue() -> DataTableValueType {
return .double(NSDecimalNumber(decimal: self).doubleValue)
}
}Once a type conforms, you can use it directly with KeyPath columns:
.init("Created", \.createdAt) // Date property, uses your conformanceReal-World Examples
Financial Data
struct Transaction: Identifiable {
let id: UUID
let description: String
let amount: Double
let date: Date
let category: String
}
let columns: [DataTableColumn<Transaction>] = [
.init("Date", \.date) { $0.formatted(date: .numeric, time: .omitted) },
.init("Description", \.description),
.init("Category", \.category),
.init("Amount", \.amount) {
let formatted = String(format: "%.2f", abs($0))
return $0 < 0 ? "-$\(formatted)" : "$\(formatted)"
}
]User Directory
struct Employee: Identifiable {
let id: Int
let firstName: String
let lastName: String
let department: String
let startDate: Date
let salary: Double
}
let columns: [DataTableColumn<Employee>] = [
.init("Name", sortedBy: \.lastName) { "\($0.firstName) \($0.lastName)" },
.init("Department", \.department),
.init("Started", \.startDate) { $0.formatted(date: .abbreviated, time: .omitted) },
.init("Tenure", sortedBy: \.startDate) {
let years = Calendar.current.dateComponents([.year], from: $0.startDate, to: Date()).year ?? 0
return "\(years) years"
}
]Inventory with Optional Values
struct Product: Identifiable {
let id: String
let name: String
let stock: Int
let lastRestocked: Date?
}
let columns: [DataTableColumn<Product>] = [
.init("SKU", \.id),
.init("Product", \.name),
.init("Stock", \.stock) { $0 == 0 ? "Out of stock" : String($0) },
.init("Last Restocked", sortedBy: { lhs, rhs in
switch (lhs.lastRestocked, rhs.lastRestocked) {
case (nil, nil): return .orderedSame
case (nil, _): return .orderedDescending
case (_, nil): return .orderedAscending
case (let a?, let b?): return a.compare(b)
}
}) { $0.lastRestocked?.formatted() ?? "Never" }
]Best Practices
Define Columns Once
Create your column definitions once, typically as a property. Don't recreate them on every data update:
class EmployeeListVC: UIViewController {
// Define once
private let columns: [DataTableColumn<Employee>] = [
.init("Name", \.name),
.init("Role", \.role)
]
private var dataTable: SwiftDataTable!
func updateData(_ employees: [Employee]) {
// Reuse existing columns
dataTable.setData(employees, animatingDifferences: true)
}
}Use KeyPaths When Possible
KeyPaths are the simplest and preserve types automatically. Only use closures when you need formatting or computation:
// Prefer: simple keypath
.init("Name", \.name)
// Use closure only when needed
.init("Name") { "\($0.firstName) \($0.lastName)" }Format Numbers for Humans
Raw numbers are hard to read. Format them, but use typed sorting to keep the sort order correct:
// Good: formatted display, numeric sorting
.init("Revenue", \.revenue) { "$\($0.formatted())" }
.init("Growth", \.growth) { "\(String(format: "%.1f", $0 * 100))%" }
// Avoid: raw unformatted numbers
.init("Revenue", \.revenue) // Shows "1234567.89"Handle Optionals Explicitly
Don't let nil values silently become empty strings. Show meaningful placeholders:
// Clear placeholder for missing data
.init("Email") { $0.email ?? "Not provided" }
.init("Due Date") { $0.dueDate?.formatted() ?? "No deadline" }