Swift and SwiftUI tutorials for Swift Developers

How to parse JSON in SwiftUI

In the world of mobile app development, data is king. Whether you are displaying the current weather, a list of popular movies, or a bank account balance, it is almost certain that this data arrives at your application in JSON (JavaScript Object Notation) format.

For an iOS developer, knowing how to transform that plain text into safe, usable Swift objects isn’t just a skill; it’s a necessary superpower. In the old days of Objective-C or the early years of Swift, this involved unsafe dictionaries ([String: Any]) and a lot of headaches.

Today, thanks to the Codable protocol and deep integration with SwiftUI, the process is elegant, safe, and powerful.

In this tutorial about how to parse JSON in SwiftUI, we will break down step-by-step how to parse JSON, covering everything from the basics to advanced cases, so your apps on iOS, macOS, and watchOS are robust and efficient.


Part 1: Understanding the Fundamentals

Before opening Xcode, we must understand the tools Swift provides us.

What is Codable?

Introduced in Swift 4, Codable is actually a typealias that combines two protocols:

  1. Decodable: The ability to transform external data (JSON) into a Swift object.
  2. Encodable: The ability to transform a Swift object into external data (JSON).

For this tutorial, we will focus primarily on the Decodable part, as “consuming” data is the most common task in frontend development.

The Workflow

The mental process you should always follow is:

  1. Analyze the JSON: See what structure it has.
  2. Create the Model (Struct): Design the Swift structure that mirrors that JSON.
  3. Configure the Decoder: Prepare the JSONDecoder.
  4. Decode: Convert bytes into objects.
  5. Integrate into SwiftUI: Display the data in the UI.

Part 2: The Basic Scenario (Flat JSON)

Imagine your backend returns user information. The JSON looks like this:

{
  "id": 101,
  "name": "Elon Code",
  "email": "elon@spacex.com",
  "is_verified": true
}

Step 1: Create the Model

In Swift, we use struct for data models because they are value types, fast, and thread-safe.

import Foundation

struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
    let isVerified: Bool
}

Wait! Did you notice the problem? In the JSON, the key is is_verified (snake_case), but in Swift, the convention is isVerified(camelCase). If you try to decode this directly, it will fail.

Step 2: Decoding Strategies (Snake Case vs Camel Case)

You have two options to solve the naming issue:

Option A: CodingKeys (The manual way) You explicitly map each property. It’s tedious but gives you total control.

struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
    let isVerified: Bool
    
    enum CodingKeys: String, CodingKey {
        case id, name, email
        case isVerified = "is_verified" // The magic happens here
    }
}

Option B: KeyDecodingStrategy (The automatic way – Recommended) Swift can do the conversion automatically if you configure the decoder correctly.

let jsonString = """
{
  "id": 101,
  "name": "Elon Code",
  "email": "elon@spacex.com",
  "is_verified": true
}
"""

func parseUser() {
    let jsonData = jsonString.data(using: .utf8)!
    
    let decoder = JSONDecoder()
    // This line automatically converts snake_case to camelCase
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    
    do {
        let user = try decoder.decode(User.self, from: jsonData)
        print("User: \(user.name), Verified: \(user.isVerified)")
    } catch {
        print("Error: \(error)")
    }
}

Part 3: Complex Data Types and Arrays

Rarely will you receive a single object. Usually, you receive a list (Array) or nested data.

Array of Objects

Suppose you receive this:

[
  { "id": 1, "name": "Ana" },
  { "id": 2, "name": "Beto" }
]

Decoding is intuitive. You simply tell the decoder to expect a [User].self (an array of users) instead of User.self.

let users = try decoder.decode([User].self, from: jsonData)

Nested JSON

Sometimes the JSON has “noise” or wrappers.

{
  "status": "ok",
  "response": {
    "users": [
      { "id": 1, "name": "Ana" }
    ]
  }
}

Here you cannot try to decode [User] directly. You must create a structure that represents the full hierarchy.

struct UserResponse: Codable {
    let status: String
    let response: UserList
}

struct UserList: Codable {
    let users: [User]
}

// Usage
let result = try decoder.decode(UserResponse.self, from: jsonData)
let myUsers = result.response.users

Part 4: The Headache of Dates

Dates are not a standard data type in JSON. They are generally sent as Strings (ISO 8601) or as Timestamps (numbers).

JSON Example:

{
  "event_name": "WWDC 2026",
  "date": "2026-06-05T10:00:00Z"
}

If you define your struct like this:

struct Event: Codable {
    let eventName: String
    let date: Date
}

It will fail by default because Swift expects a numeric format (TimeInterval) unless you tell it otherwise.

Solution: DateDecodingStrategy

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601 // The international standard

If the format is weird (e.g., “05-06-2026”), you need a custom formatter:

let formatter = DateFormatter()
formatter.dateFormat = "dd-MM-yyyy"
decoder.dateDecodingStrategy = .formatted(formatter)

Part 5: Real-World Implementation in SwiftUI (Async/Await)

Now that we know how to parse, let’s build a real application. We will create an app that downloads a list of users from a dummy API and displays it.

We will use the MVVM (Model-View-ViewModel) pattern, which is the gold standard in SwiftUI.

1. The Model (User.swift)

import Foundation

struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let username: String
    let email: String
    // We use optional (?) because sometimes the server doesn't send this data
    let website: String? 
}

2. The ViewModel (UsersViewModel.swift)

This component is responsible for logic, networking, and owning the UI state. We will use modern Swift concurrency (async/await).

import Foundation

@MainActor // Ensures UI updates happen on the main thread
class UsersViewModel: ObservableObject {
    
    @Published var users: [User] = []
    @Published var isLoading = false
    @Published var errorMessage: String? = nil
    
    func fetchUsers() async {
        self.isLoading = true
        self.errorMessage = nil
        
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else {
            self.errorMessage = "Invalid URL"
            self.isLoading = false
            return
        }
        
        do {
            // 1. URLSession with async/await
            let (data, response) = try await URLSession.shared.data(from: url)
            
            // 2. HTTP Code Verification
            guard let httpResponse = response as? HTTPURLResponse, 
                  httpResponse.statusCode == 200 else {
                self.errorMessage = "Server Error"
                self.isLoading = false
                return
            }
            
            // 3. Decoder Configuration
            let decoder = JSONDecoder()
            // jsonplaceholder uses camelCase by default, so we don't need a special strategy
            
            // 4. Decoding
            let decodedUsers = try decoder.decode([User].self, from: data)
            
            // 5. Update State
            self.users = decodedUsers
            self.isLoading = false
            
        } catch {
            // Detailed error handling
            self.errorMessage = "Error: \(error.localizedDescription)"
            self.isLoading = false
            print("Parsing Error: \(error)")
        }
    }
}

3. The View (ContentView.swift)

Here is where SwiftUI shines. We will connect the ViewModel to the view.

import SwiftUI

struct UsersListView: View {
    @StateObject private var viewModel = UsersViewModel()
    
    var body: some View {
        NavigationStack {
            ZStack {
                if viewModel.isLoading {
                    ProgressView("Loading users...")
                        .controlSize(.large)
                } else if let error = viewModel.errorMessage {
                    VStack {
                        Image(systemName: "exclamationmark.triangle")
                            .font(.largeTitle)
                            .foregroundColor(.red)
                        Text(error)
                            .padding()
                        Button("Retry") {
                            Task { await viewModel.fetchUsers() }
                        }
                    }
                } else {
                    List(viewModel.users) { user in
                        VStack(alignment: .leading) {
                            Text(user.name)
                                .font(.headline)
                            Text(user.email)
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                    .refreshable {
                        await viewModel.fetchUsers()
                    }
                }
            }
            .navigationTitle("JSON Users")
            // The .task modifier is the modern equivalent to onAppear for async
            .task {
                if viewModel.users.isEmpty {
                    await viewModel.fetchUsers()
                }
            }
        }
    }
}

Part 6: Advanced Error Handling (Debugging)

When parsing fails, Swift throws an error. If you only print error.localizedDescription, you will often get a vague message like “The data couldn’t be read because it isn’t in the correct format.”

To know exactly what failed, you must catch DecodingError types. This is vital for professional development.

Modify your catch block in the ViewModel like this:

} catch let decodingError as DecodingError {
    switch decodingError {
    case .typeMismatch(let type, let context):
        print("Type mismatch for \(type). Expected at: \(context.debugDescription)")
        print("Error path: \(context.codingPath)")
    case .valueNotFound(let type, let context):
        print("Value not found for \(type): \(context.debugDescription)")
    case .keyNotFound(let key, let context):
        print("Missing key '\(key.stringValue)': \(context.debugDescription)")
    case .dataCorrupted(let context):
        print("Corrupted JSON: \(context.debugDescription)")
    @unknown default:
        print("Unknown error")
    }
    self.errorMessage = "Data format error"
} catch {
    self.errorMessage = "Network error: \(error.localizedDescription)"
}

Practical Example: If the server sends "id": "101" (String) but your struct expects let id: Int, the .typeMismatch case will trigger and tell you exactly which line and property caused the error.


Part 7: Generics and Reusability (Pro Level)

Writing the fetchUsersfetchProductsfetchOrders function over and over again is bad practice (WET – Write Everything Twice). Let’s create a generic NetworkManager that can parse any JSON into any struct.

struct NetworkManager {
    
    enum NetworkError: Error {
        case badURL, requestFailed, invalidData
    }
    
    // T must conform to Decodable
    func fetch<T: Decodable>(url: URL) async throws -> T {
        let (data, response) = try await URLSession.shared.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse, 
              httpResponse.statusCode == 200 else {
            throw NetworkError.requestFailed
        }
        
        let decoder = JSONDecoder()
        // Standard configuration
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        
        return try decoder.decode(T.self, from: data)
    }
}

How to use it in your ViewModel:

func fetchUsers() async {
    let manager = NetworkManager()
    guard let url = URL(string: "...") else { return }
    
    do {
        // Swift infers that T is [User] because we assign it to a [User] variable
        let users: [User] = try await manager.fetch(url: url)
        self.users = users
    } catch {
        print(error)
    }
}

Part 8: Considerations for watchOS and macOS

The beauty of SwiftUI and Foundation is that the code we just wrote is 100% compatible across platforms.

  • macOS: The List will automatically render with the native Mac style.
  • watchOS: The same List will work, although you might want to reduce the amount of text shown in each cell (VStack) due to screen size.

You don’t need to import different libraries. URLSessionCodable, and JSONDecoder are universal in the Apple ecosystem.


Final Tips and Best Practices

  1. Use Optionals (?) Wisely: If you aren’t 100% sure a field will always come in the JSON, make it optional in your Struct (String?). If a mandatory field is missing and it’s not optional, the entire parsing will fail and the user will see nothing.
  2. QuickType.io: For massive JSONs, don’t write the structs by hand. Use tools like QuickType. Paste the JSON and it generates the Swift Codable code instantly.
  3. Process in Background, Update on Main: With async/await, Swift handles threads for you, but remember to use @MainActor in your ViewModels to ensure the UI updates on the main thread.
  4. Unit Testing: JSON parsing is the perfect candidate for Unit Tests. Save a local .json file in your test project and try to decode it to ensure your model doesn’t break if the API changes.

Conclusion

Parsing JSON in SwiftUI has evolved from a tedious task to a fluid and safe experience thanks to Codable. by mastering JSONDecoderKeyDecodingStrategy, and error handling, you have total control over the data entering your application.

Remember: Good data architecture is the foundation of a stable app. Don’t try to adapt your JSON to the view; adapt your view to a solid data model.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

@Binding vs @State in SwiftUI

Next Article

How to Pass View as Parameter in SwiftUI

Related Posts