A few years ago, Apple added support for detecting potential issues at runtime. Runtime issues show up as purple issues in Xcode's Issues Navigator. They are easy to miss or ignore, but they are just as important as errors at compile time.

The only difference with compiler errors is that runtime issues aren't detected at compile time. As the name suggests, runtime issues are detected at runtime and aren't necessarily fatal. The consequences of a runtime issue can be severe and that is why you should treat a runtime issue as an error at compile time.

In this video, we take a look at the runtime issue breakpoint, a breakpoint that helps you stay on top of runtime issues.

Errors, Warnings, and Runtime Issues

Xcode's Issues Navigator supports three types of issues, errors, warnings, and runtime issues. An error is detected at compile time and prevents your app from running. A warning is a signal from the compiler that something may be wrong and deserves your attention, but it doesn't prevent your app from running by default. A runtime issue is similar to a warning in that it doesn't prevent your app from running.

Due to limitations of the compiler, runtime issues can only be detected at runtime. That may change as the compiler evolves and becomes more capable. That is one of the superpowers of compiled languages, detecting issues before they cause problems. The compiler you use every day becomes better and better over time, resulting in fewer bugs making it into production.

An Example

Open the starter project of this video in Xcode. The project isn't complicated. We have an app that fetches a list of posts from an API and displays it in a LazyVGrid. The business logic is handled by a view model, the PostsViewModel class. Check out Mastering MVVM With Swift and Mastering MVVM With SwiftUI if you want to learn more about the Model-View-ViewModel pattern.

PostsView.swift

var body: some View {
    VStack {
        switch viewModel.state {
        case .loading:
            ProgressView()
        case .posts(let cellViewModels):
            ScrollView {
                LazyVGrid(columns: layout, spacing: 20.0) {
                    ForEach(cellViewModels) { cellViewModel in
                        PostCell(viewModel: cellViewModel)
                    }
                }
                .padding()
            }
        case .error(let error):
            Spacer()
            Text(error)
            Spacer()
        }
    }
    .task { await viewModel.start() }
}

In the fetchPosts() method, the view model uses the URLSession API to fetch an array of posts. It decodes the response, maps the array of Post objects to an array of PostCellViewModel objects, and updates the view model's state property. If an error is thrown in the do clause, the view displays an error to the user through the state property.

PostsViewModel.swift

private func fetchPosts() async {
    do {
        try await Task.sleep(nanoseconds: 2_000_000_000)

        let (data, _) = try await URLSession.shared.data(from: url)

        let cellViewModels = try JSONDecoder()
            .decode([Post].self, from: data)
            .map(PostCellViewModel.init(post:))

        state = .posts(cellViewModels)
    } catch {
        print("Failed to Fetch Posts \(error)")
        state = .error("The app failed to fetch the posts from the server.")
    }
}

The state property is of type State, an enum. It defines three cases, loading, posts, and error. I like using this pattern as it clearly communicates the possible states of the PostsView. We use associated values to pass the view the data it needs to display.

PostsViewModel.swift

enum State {

    // MARK: - Cases

    case loading
    case posts([PostCellViewModel])
    case error(String)

}

Run the app in the simulator and open the Issues Navigator on the left. You should see a runtime issue pop up in the Issues Navigator the moment the app displays the list of posts.

Runtime issues can be found in Xcode's Issues Navigator.

Xcode shows a warning, a runtime issue, that the view model publishes changes from a background thread. What is happening? Revisit PostsViewModel.swift. In the fetchPosts() method, the view model fetches the array of posts from a remote API. That operation occurs asynchronously on a background thread to make sure the user interface remains responsive.

The problem is that the state property is updated on that same background thread. Because the state property defines what the PostsView displays, updating the value of the state property should take place on the main thread. Remember that updating the user interface should always happen on the main thread. We take a look at the solution to this problem later.

Adding a Runtime Issue Breakpoint

It is easy to miss or ignore runtime issues in the Issues Navigator, but Xcode can help you stay on top of the runtime issues in your project. Open the Breakpoints Navigator on the left, click the + button in the lower left, and choose Runtime Issue Breakpoint.

Adding a Runtime Issue Breakpoint

The breakpoint has two options, the number of times to ignore the breakpoint when a runtime issue is detected and, more interesting, the type of runtime issue to detect. For this example, we set Type to All, the default.

Adding a Runtime Issue Breakpoint

If you notice that the debugger doesn't suspend the process when a runtime issue is hit, then make sure the corresponding diagnostic is enabled. Click the small arrow below the Type field to open the Diagnostics tab.

Enabling Diagnostics

This opens the Posts scheme. The Diagnostics tab shows us which information the compiler and debugger collect. The Main Thread Checker and Thread Performance Checker are enabled by default. You can optionally check the Address Sanitizer or the Thread Sanitizer.

Debugging a Runtime Issue

With the runtime issue breakpoint set, run the app one more time. The debugger makes the runtime issue more explicit by suspending the process of the app. We can easily find the root cause by opening the Debug Navigator on the left.

Debugging a Runtime Issue

The stack trace takes us to the root of the problem. As I explained earlier, the state property of the view model is updated from a background thread, the same thread on which the list of posts is fetched.

It is trivial to resolve the issue thanks to Swift Concurrency. We apply the MainActor attribute to the declaration of the PostsViewModel class. This ensures the view model's state property is accessed from the main thread. Swift Concurrency takes care of the rest under the hood.

PostsViewModel.swift

import Foundation

@MainActor
final class PostsViewModel: ObservableObject {

	...

}

Run the app one more time. You should no longer run into a runtime issue.

What's Next?

Runtime issues don't stop you from running your app because they can't. The debugger detects a runtime issue at runtime hence the name. But that doesn't make runtime issues harmless. I strongly recommend treating runtime issues as compile time errors. A runtime issue can have severe consequences, so give them the attention they deserve.