GitHub

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\.nmae won'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 age is an Int, not a String
  • 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:

TypeSort BehaviorExample Order
.stringAlphabetical (lexicographic)"10", "2", "9"
.intNumeric2, 9, 10
.doubleNumeric1.5, 2.0, 10.99
.floatNumeric1.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 TypeConverts ToSort Behavior
String.stringAlphabetical
Int.intNumeric
Float.floatNumeric
Double.doubleNumeric
Optional<T>Unwrapped value or empty stringDepends 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 conformance

Real-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" }