Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 003 / 100

Swift basics: concepts, trade-offs and production lessons

The language essentials.

Expona Admin7 min read

If you've worked with dynamic languages like JavaScript or Python, Swift might feel like a step back at first—until you realize how much its design catches bugs before they reach production. On Day 3 of our 100-day series, we're diving into Swift basics: concepts, trade-offs and production lessons, the foundational language features that shape how we write iOS apps at scale.

Why Swift's basics matter in production

Swift was designed from the ground up to be safe by default. That means the language itself nudges you toward writing code that's harder to break. Over months and years of maintaining iOS codebases, this philosophy pays dividends. You'll spend less time debugging null pointer exceptions or type mismatches, and more time building features. But this comes with trade-offs—verbosity in some places, strictness in others. Understanding the fundamentals means understanding those trade-offs and when to lean into them rather than fight them.

let for constants, var for variables

This is the first choice you make on almost every line of Swift code: should this value change? Use let for constants—values that cannot be reassigned after initialization. Use var for variables—values that can change.

swift
let maxRetries = 3
var attemptCount = 0

attemptCount = 1  // OK
// maxRetries = 5  // Compiler error: cannot assign to constant

In production, we've found that defaulting to let reduces cognitive load. When you see let, you know the value is stable. When you see var, you immediately know to watch for mutations. This is especially powerful in concurrent environments—shared mutable state is a source of race conditions, so using let where possible is a defensive practice.

The trade-off: you cannot use let if your value needs to change. Some developers coming from JavaScript find this restrictive at first. But in practice, it's freeing. It forces you to think about whether a value truly needs to be mutable, which often leads to cleaner designs using parameters and return values instead.

Real-world scenario: building a retry mechanism. You might initialize var attemptCount = 0, but consider whether you could instead make attemptCount a parameter and return a new value from each iteration. The second approach is more testable and leaves fewer opportunities for state drift.

Type inference with strong typing

Swift is strongly typed—every value has a type, and type mismatches are caught at compile time, not runtime. But Swift is also smart: it can infer types from context, so you don't always need to write them explicitly.

swift
let count = 5                    // Type inferred as Int
let pi = 3.14                    // Type inferred as Double
let name = "Alice"               // Type inferred as String
let isActive: Bool = true        // Explicit type annotation

// Type mismatch caught at compile time
// let mixed = count + pi  // Error: cannot add Int and Double

The trade-off here is between clarity and brevity. Inferring types keeps code lean, but explicit annotations make intent clearer, especially in function signatures and class properties. In production codebases, we've found a middle ground: use type inference for simple cases, but always annotate in public APIs and complex expressions.

One production lesson: Swift's type system is your teammate. Lean into it. When you're building a network layer, encode response types strictly. When you're managing state, use enums and custom types instead of generic String or Int. The upfront cost of defining types is repaid every time the compiler catches a bug that would have slipped through in a dynamic language.

swift
// Bad: generic types lose information
func handleResponse(_ data: String, _ status: Int) {
    if status == 200 {
        print(data)
    }
}

// Better: types encode intent
enum HTTPStatus {
    case success(String)
    case notFound
    case serverError
}

func handleResponse(_ result: HTTPStatus) {
    switch result {
    case .success(let data):
        print(data)
    case .notFound:
        print("Not found")
    case .serverError:
        print("Server error")
    }
}

String interpolation with \(value)

String interpolation lets you embed expressions directly inside string literals using the \() syntax. It's one of Swift's most practical features, and it keeps logging, debugging, and user-facing messages readable.

swift
let user = "Alice"
let score = 42

let message = "\(user) scored \(score) points!"
print(message)  // "Alice scored 42 points!"

// Expressions work too
let total = 10
let remaining = 3
print("Progress: \(remaining) of \(total) remaining")

// Custom types can implement description
struct Product {
    let name: String
    let price: Double
}

let product = Product(name: "Widget", price: 9.99)
print("Buying: \(product.name) for $\(product.price)")

In production, string interpolation is vastly preferable to concatenation. It's faster (fewer allocations), more readable, and less error-prone. However, be mindful of performance when interpolating large strings in loops or when building SQL queries—consider using a mutable string buffer or dedicated builders instead.

One production gotcha: if you're building log messages or debug output that might contain user-generated data, remember that string interpolation calls description on custom types. Make sure your types provide safe, non-sensitive output.

Functions with argument labels

Swift functions are more expressive than their counterparts in C-style languages because of argument labels. You can give parameters a label that's used at the call site, which makes function calls read like natural language.

swift
// Basic function with labels
func greet(_ name: String, with greeting: String = "Hello") -> String {
    "\(greeting), \(name)!"
}

print(greet("Alice", with: "Hi"))  // "Hi, Alice!"
print(greet("Bob"))                // "Hello, Bob!"

// Underscore suppresses the label (first parameter typically)
func add(_ a: Int, to b: Int) -> Int {
    a + b
}

print(add(5, to: 10))  // 15

// Real-world: building a network request function
func fetchUser(
    id: Int,
    from endpoint: String = "/api/users",
    with timeout: TimeInterval = 30
) -> String {
    "\(endpoint)/\(id) (timeout: \(timeout)s)"
}

print(fetchUser(id: 123))
print(fetchUser(id: 456, from: "/api/v2/users", with: 60))

Argument labels make code at the call site self-documenting. When you see fetchUser(id: 123, from: endpoint, with: timeout), you immediately understand what each parameter does. This is a trade-off against brevity, but it's one we almost always take in production.

The underscore (_) before a parameter name suppresses its label. Use this for the first parameter if it's obvious from context, but use explicit labels for subsequent parameters. Default values let you build flexible APIs that remain easy to call in the common case.

Production lesson: argument labels become especially valuable when refactoring. If you have 50 call sites and you want to swap the order of parameters or add a new one, the labels help prevent silent bugs. A caller using fetchUser(id: 123, from: "/v2") will break obviously if you change the parameter order, whereas a positional language would silently pass arguments to the wrong parameters.

Common mistakes

  • Mutating unnecessarily: Starting with var everywhere out of habit. Start with let and only switch to var if the compiler complains. This catches design issues early.
  • Ignoring type mismatches: Force-casting or converting types implicitly instead of handling errors. If Int and Double don't mix naturally, there's usually a reason—respect the type system.
  • Overcomplicating string building: Using string concatenation with + in loops instead of interpolation. Worse, building SQL or URLs without proper escaping. Use string interpolation for readability, and dedicated builders for complex cases.
  • Omitting argument labels: Using underscores for all parameters to match other languages. This loses readability. Use labels liberally, especially in public APIs.
  • Forgetting default parameter values: Creating overloaded functions when a single function with defaults is cleaner. Swift prefers extensibility through defaults over multiple function variants.

Key takeaways

  • let by default, var only when mutation is necessary. This reduces bugs and clarifies intent across your codebase.
  • Swift's strong type system is a feature, not a limitation. Use types to encode domain concepts and let the compiler catch mistakes before they reach users.
  • Type inference keeps common code clean, but explicit annotations make APIs and complex logic self-documenting.
  • String interpolation with \() is not just syntactic sugar—it's faster and safer than concatenation.
  • Argument labels make code at the call site read naturally. Use them to build expressive, maintainable APIs.
  • These basics combine to form a philosophy: Swift prioritizes safety and clarity over brevity, especially in production environments.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles