Swift Assertions: assert, precondition, and fatalError

Swift assertions help you catch invalid assumptions early, fail fast when a program reaches an impossible state, and document the rules your code expects. They are a key part of testing and debugging because they make bugs obvious instead of letting incorrect data flow deeper into your app.

Quick answer: Use assert for debug-only checks, precondition for conditions that must always be true, and fatalError when execution should never continue. In release builds, assert is usually removed, while precondition and fatalError can still stop the program.

Difficulty: Beginner

You'll understand this better if you know: basic Swift syntax, how functions return values, and the difference between valid and invalid program states.

1. What Are Assertions in Swift?

Assertions are runtime checks that verify assumptions about your code. If an assertion fails, Swift stops execution and reports the failure so you can fix the bug immediately.

These tools are not for normal user-facing validation. If bad input is expected from the outside world, handle it with optional handling, error throwing, or other control flow instead of crashing.

2. Why Assertions Matter

Assertions make bugs easier to find because they fail at the point where the problem starts. That is often much better than allowing a bad value to travel through multiple functions and produce confusing behavior later.

They also make code self-documenting. When you write an assertion, you are stating the assumptions your function or type depends on. Other developers can read those assumptions directly in the code.

Use them when:

Do not use them for expected runtime conditions like network failures, missing files, or user-entered invalid data.

3. Basic Syntax or Core Idea

Swift provides three closely related functions. They all take a Boolean condition or a message, but they serve different purposes.

Simple assert

Use assert to check a condition that should be true while debugging.

let age = 21
assert(age >= 0, "Age cannot be negative.")

This says that age should never be negative. If it is, your program logic is wrong.

Simple precondition

Use precondition when the condition must be true before execution continues.

let count = 5
precondition(count > 0, "Count must be greater than zero.")

This check is stronger than assert because it is meant to protect important runtime assumptions.

Simple fatalError

Use fatalError when code should never reach a certain path.

func unsupportedMode() -> Never {
    fatalError("This mode is not supported.")
}

fatalError does not return, so Swift treats the function as terminating immediately.

4. Step-by-Step Examples

Example 1: Checking a value during development

Imagine a helper that calculates a discount. You can assert that the percentage is within a valid range while you are building and testing the feature.

func discountedPrice(price: Double, discount: Double) -> Double {
    assert(discount >= 0 && discount <= 1, "Discount must be between 0 and 1.")
    return price * (1 - discount)
}

If the check fails, you get a clear signal that the calling code is wrong.

Example 2: Protecting a required precondition

Use precondition when the function cannot continue safely unless its input meets a strict requirement.

func average(values: [Double]) -> Double {
    precondition(!values.isEmpty, "Cannot compute an average of an empty array.")

    let sum = values.reduce(0, +)
    return sum / Double(values.count)
}

The precondition makes the function contract explicit and prevents a divide-by-zero problem.

Example 3: Reaching an impossible branch

fatalError is appropriate when every valid path has already been handled and anything else indicates a programming bug.

enum Theme {
    case light
    case dark
}

func themeName(theme: Theme) -> String {
    switch theme {
    case .light:
        return "Light"
    case .dark:
        return "Dark"
    }
}

In this complete switch, no default branch is needed. If a default branch were required for a larger enum, fatalError could be used to flag an impossible state.

Example 4: Comparing debug and release behavior

This example shows why assert is often used for development checks that you do not want to keep in optimized release builds.

func makeUsername(name: String) -> String {
    assert(!name.isEmpty, "Name should not be empty.")
    return name.trimmingCharacters(in: .whitespacesAndNewlines)
}

In a debug build, the assertion helps catch bad test data. In a release build, the check may be removed, so you should not rely on it for user input validation.

5. Practical Use Cases

These checks are most useful inside your own codebase, where a failed assertion tells you that a programmer made a mistake.

6. Common Mistakes

Mistake 1: Using assert for user input validation

It is tempting to use assert whenever a value looks wrong, but user input can be invalid in normal operation. A crash is the wrong response for expected input errors.

Problem: This code crashes in debug builds if the email is invalid, but it does not actually handle the situation in a user-friendly way.

func sendWelcomeEmail(email: String) {
    assert(email.contains("@"), "Invalid email address.")
    // Send the email here.
}

Fix: Validate the input and return early or throw an error when the value is not acceptable.

func sendWelcomeEmail(email: String) {
    guard email.contains("@") else {
        return
    }
    // Send the email here.
}

The corrected version handles invalid input instead of treating it like a programmer bug.

Mistake 2: Using precondition for something that should be optional

precondition is strong, so it should only protect invariants that must hold for the function to work. If a missing value is normal, do not crash.

Problem: This code aborts when a nickname is missing, even though a fallback name would be fine.

func displayName(nickname: String?) -> String {
    precondition(nickname != nil, "Nickname is required.")
    return nickname!
}

Fix: Use a safe fallback when the value is optional and absence is acceptable.

func displayName(nickname: String?) -> String {
    return nickname ?? "Guest"
}

The corrected version avoids a crash because the absence of a nickname is no longer treated as an impossible state.

Mistake 3: Forgetting that assertions are not a substitute for safe control flow

Assertions help during development, but they do not replace proper branching logic. If a value can be missing in real usage, unwrap it safely first.

Problem: This code force-unwraps an optional after an assertion, which can still crash in release builds if the check is removed.

func firstCharacter(text: String?) -> Character {
    assert(text != nil, "Text must exist.")
    return text!.first!
}

Fix: Use optional binding and handle the missing case explicitly.

func firstCharacter(text: String?) -> Character? {
    guard let text = text, let first = text.first else {
        return nil
    }
    return first
}

The corrected version is safe in every build because it does not rely on the assertion for correctness.

7. Best Practices

Practice 1: Use assert for internal checks, not public validation

Use assert when a failure means your own code is wrong. That keeps the check lightweight and focused on development-time debugging.

func setProgress(value: Double) {
    assert(value >= 0 && value <= 1, "Progress must stay between 0 and 1.")
}

This is a good fit because progress values come from your own logic, not from untrusted input.

Practice 2: Use clear messages that explain the assumption

Good assertion messages reduce debugging time. State what was expected and, when helpful, why it matters.

precondition(capacity > 0, "Capacity must be positive before allocating the buffer.")

A clear message tells you what broke instead of forcing you to inspect the code path manually.

Practice 3: Prefer safe control flow for recoverable cases

If the program can continue in a sensible way, use guard, optional binding, or error handling. Reserve assertions for impossible or programmer-error states.

func parsePort(text: String) -> Int? {
    guard let port = Int(text), port > 0 else {
        return nil
    }
    return port
}

This approach keeps invalid external data from turning into a crash.

8. Limitations and Edge Cases

Note: If you are checking assumptions in a library API, document whether callers must satisfy the condition before calling the function. That helps them understand whether they should expect a crash or a recoverable error.

9. Practical Mini Project

Here is a small validation helper for a bank transfer workflow. It uses assertions to protect internal state and precondition for required invariants.

struct Transfer {
    let sourceBalance: Double
    let amount: Double

    func execute() -> Double {
        precondition(amount > 0, "Transfer amount must be positive.")
        assert(sourceBalance >= amount, "Source balance should already have been validated.")
        return sourceBalance - amount
    }
}

let transfer = Transfer(sourceBalance: 250, amount: 40)
let remaining = transfer.execute()
print(remaining)

This example shows a realistic split: precondition protects required input, while assert documents an internal assumption that should already have been checked elsewhere.

10. Key Points

11. Practice Exercise

Expected output: A function that accepts valid values and reports invalid ones during debugging.

Hint: Combine a range check with a clear assertion message. Do not use the assertion as the only way to handle bad data if the caller may supply invalid input.

Solution:

func clampedPercentage(_ value: Double) -> Double {
    assert(value >= 0 && value <= 1, "Percentage must be between 0 and 1.")
    return value
}

12. Final Summary

Swift assertions are a fast way to catch incorrect assumptions during development. They help you fail early, keep bugs close to their source, and make your code’s expectations easier to understand.

Use assert for debug-time checks, precondition for conditions that must always be true, and fatalError when execution should never continue. The key skill is choosing the right level of strictness: recover from expected problems, and crash only when the code has reached an invalid state.

As you build larger Swift programs, combine assertions with safe optionals, error handling, and careful API design. That gives you code that is easier to debug without becoming fragile in production.