Writing Swifty Code: Idioms Every Developer Should Know

Introduction: What Makes Code "Swifty"?

Writing Swifty code means leveraging Swift's powerful type system, functional programming paradigms, and modern language features to create clean, modular, and readable code. It's not just about following syntax rules—it's about thinking in Swift and using the language's idioms to express your intent clearly. Whether you're building iOS development applications or server-side Swift projects, understanding these idioms will transform how you write code.

In this post, we'll explore the essential idioms that separate novice Swift developers from experienced ones. We'll examine value types, error handling patterns, optional handling, and modern concurrency with async/await, complete with before-and-after refactoring examples.

Idiom 1: Embrace Value Types as Your Default Choice

One of Swift's most distinctive features is its emphasis on value types through structs and enums. Many developers coming from object-oriented languages default to classes, but in Swift, structs should be your first choice unless you have a specific reason for reference semantics.

Before (Class-based, non-Swifty):

class User {
    var id: Int
    var name: String
    var email: String
    
    init(id: Int, name: String, email: String) {
        self.id = id
        self.name = name
        self.email = email
    }
}

var user1 = User(id: 1, name: "Alice", email: "alice@example.com")
var user2 = user1
user2.name = "Alice Smith"
print(user1.name) // Output: "Alice Smith" - unexpected mutation!

After (Struct-based, Swifty):

struct User {
    let id: Int
    let name: String
    let email: String
}

var user1 = User(id: 1, name: "Alice", email: "alice@example.com")
var user2 = user1
// Can't mutate because properties are immutable
// user2.name = "Alice Smith" // Compilation error

// Create a new instance with modified property using memberwise initialization
user2 = User(id: user1.id, name: "Alice Smith", email: user1.email)

The Swifty approach demonstrates why value types matter. Structs automatically provide memberwise initialization, value semantics prevent unexpected mutations, and they're more memory-efficient for most use cases. In iOS development and beyond, structs should be your default—reach for classes only when you need reference semantics or require inheritance.

Idiom 2: Master Swift Error Handling with Typed Throws

Swift error handling is elegant and powerful. Instead of relying on error codes or optional return values, use Swift error handling to create clear, type-safe error communication.

Before (Non-Swifty, using optionals and unclear error states):

func fetchUser(id: Int) -> User? {
    guard id > 0 else { return nil }
    
    let urlString = "https://api.example.com/users/\(id)"
    // What went wrong? Invalid URL? Network error? Parsing error?
    // The optional doesn't tell us.
    return nil
}

if let user = fetchUser(id: -1) {
    print(user)
} else {
    print("Something failed") // Too vague!

After (Swift error handling with typed throws):

enum NetworkError: Error {
    case invalidID
    case invalidURL
    case requestFailed(statusCode: Int)
    case decodingFailed(String)
}

func fetchUser(id: Int) throws -> User {
    guard id > 0 else { throw NetworkError.invalidID }
    
    guard let url = URL(string: "https://api.example.com/users/\(id)") else {
        throw NetworkError.invalidURL
    }
    
    do {
        let (data, response) = try URLSession.shared.data(from: url)
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.requestFailed(statusCode: 0)
        }
        return try JSONDecoder().decode(User.self, from: data)
    } catch is DecodingError {
        throw NetworkError.decodingFailed("Invalid JSON structure")
    }
}

do {
    let user = try fetchUser(id: 1)
    print(user)
} catch NetworkError.invalidID {
    print("Please provide a valid user ID")
} catch NetworkError.decodingFailed(let reason) {
    print("Failed to parse user data: \(reason)")
} catch {
    print("An unexpected error occurred")

This Swifty approach provides clear information about what went wrong, enables precise error handling, and makes debugging easier. Your callers know exactly what errors to expect.

Idiom 3: Handle Swift Optionals Idiomatically

Swift optionals are a breakthrough in safety, but handling them poorly leads to pyramids of unwrapping. Swifty code uses guard statements, optional chaining, and the nil-coalescing operator to keep code flat and readable.

Before (Nested unwrapping, pyramid of doom):

func getUserDisplayName(user: User?) -> String {
    if let user = user {
        if let email = user.email {
            if !email.isEmpty {
                return email
            } else {
                return "No email"
            }
        } else {
            return "No email"
        }
    } else {
        return "No user"
    }
}

After (Flat, Swifty optional handling):

func getUserDisplayName(user: User?) -> String {
    guard let user = user, !user.email.isEmpty else {
        return "No user or email"
    }
    return user.email
}

// Or, even more concise with optional chaining and nil-coalescing:
func getUserDisplayName(user: User?) -> String {
    user?.email?.isEmpty == false ? user!.email : "No email"
}

// Or the most elegant approach:
func getUserDisplayName(user: User?) -> String {
    user?.email ?? "No email"
}

The Swifty pattern uses guard let at the top of functions to establish preconditions, optional chaining with ?. to safely access properties, and the nil-coalescing operator ?? to provide defaults. This keeps your code linear and easy to follow.

Idiom 4: Embrace Modern Concurrency with Async/Await

Async/await is the modern way to handle asynchronous operations in Swift. It replaces callback pyramids with sequential, readable code that looks synchronous but executes asynchronously.

Before (Callback-based, callback hell):

func fetchUserAndPosts(userId: Int, completion: @escaping (Result<(User, [Post]), Error>) -> Void) {
    fetchUser(id: userId) { userResult in
        switch userResult {
        case .success(let user):
            fetchPosts(for: userId) { postsResult in
                switch postsResult {
                case .success(let posts):
                    completion(.success((user, posts)))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

After (Async/await, clean and linear):

func fetchUserAndPosts(userId: Int) async throws -> (User, [Post]) {
    async let user = fetchUser(id: userId)
    async let posts = fetchPosts(for: userId)
    
    return try await (user, posts)
}

// At the call site:
Task {
    do {
        let (user, posts) = try await fetchUserAndPosts(userId: 1)
        updateUI(with: user, and: posts)
    } catch {
        displayError(error)
    }
}

The async/await idiom is now the standard for iOS development and Swift concurrency. It's more readable, easier to reason about, and automatically handles thread safety with Swift actors. Modern Swifty code uses async/await exclusively, avoiding completion handlers and delegate patterns for simple async operations.

Idiom 5: Use Pattern Matching and Guard Effectively

Swift's pattern matching capabilities are powerful. Swifty code uses guard, if case, and switch statements to express logic clearly without nested conditions.

enum AuthResult {
    case success(token: String)
    case failure(reason: String)
    case requiresMFA
}

func handleAuthResult(_ result: AuthResult) {
    guard case .success(let token) = result else {
        // Handle non-success cases clearly
        if case .requiresMFA = result {
            promptForMFA()
        } else if case .failure(let reason) = result {
            showError(reason)
        }
        return
    }
    
    // Only success path continues
    storeToken(token)
    navigateToHome()
}

Bonus Idiom: Use Extensions for Organization and Protocol Conformance

Swifty code organizes related functionality through extensions. This keeps your types clean and focused while making code more discoverable.

// Core struct
struct User {
    let id: Int
    let name: String
    let email: String
}

// Extension for Codable conformance
extension User: Codable {}

// Extension for displaying users
extension User {
    var displayName: String {
        name.isEmpty ? email : name
    }
}

// Extension for API-related functionality
extension User {
    static func fetch(id: Int) async throws -> User {
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }
}

Conclusion: Writing Swifty Code is a Mindset

Writing Swifty code isn't about memorizing rules—it's about thinking in Swift and leveraging the language's unique strengths. Prefer value types through structs, use typed error handling instead of optionals for error states, keep optional unwrapping flat and readable, embrace async/await for concurrency, and organize code through extensions.

These idioms work together to create code that's not just correct, but expressive and maintainable. As you apply these patterns in your iOS development work and beyond, you'll find your Swift code becoming more elegant, more robust, and more truly Swifty.