iOS
Swift
Parse
Async
Programming

ios swift parse methods with async results

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In Swift, asynchronous parsing code becomes hard to maintain when networking, validation, decoding, and UI updates all happen in one large method. The cleaner pattern is to treat async fetching and parsing as a small pipeline: load data, validate the response, decode the payload, and then hand the result to the caller.

Separate Fetching from Decoding

A common improvement is splitting transport logic from parsing logic. That makes the code easier to test and easier to reuse.

swift
1import Foundation
2
3struct User: Decodable {
4    let id: Int
5    let name: String
6    let email: String
7}
8
9enum APIError: Error {
10    case invalidResponse
11    case badStatusCode(Int)
12    case emptyData
13}
14
15func fetchData(from url: URL, session: URLSession = .shared) async throws -> Data {
16    let (data, response) = try await session.data(from: url)
17
18    guard let http = response as? HTTPURLResponse else {
19        throw APIError.invalidResponse
20    }
21
22    guard (200...299).contains(http.statusCode) else {
23        throw APIError.badStatusCode(http.statusCode)
24    }
25
26    guard !data.isEmpty else {
27        throw APIError.emptyData
28    }
29
30    return data
31}
32
33func decodeUser(from data: Data) throws -> User {
34    let decoder = JSONDecoder()
35    return try decoder.decode(User.self, from: data)
36}

Now each piece has a single responsibility.

Build an Async Function That Returns Parsed Results

Once transport and decoding are separated, the top-level function becomes simple:

swift
1func fetchUser(from url: URL) async throws -> User {
2    let data = try await fetchData(from: url)
3    return try decodeUser(from: data)
4}

This is much easier to read than nested callback code because the data flow is linear.

Parse Several Results Concurrently

If a screen needs several independent pieces of data, Swift concurrency lets you fetch and parse them in parallel.

swift
1import Foundation
2
3struct Dashboard: Decodable {
4    let activeUsers: Int
5    let openTickets: Int
6}
7
8func fetchDashboard(from url: URL) async throws -> Dashboard {
9    let data = try await fetchData(from: url)
10    return try JSONDecoder().decode(Dashboard.self, from: data)
11}
12
13func loadScreen(userURL: URL, dashboardURL: URL) async throws -> (User, Dashboard) {
14    async let user = fetchUser(from: userURL)
15    async let dashboard = fetchDashboard(from: dashboardURL)
16    return try await (user, dashboard)
17}

This keeps related async work concise without forcing serial waits.

Bridge Older Completion APIs to Async

Some SDKs or older app code still return results through completion handlers. Instead of spreading callback wrappers everywhere, convert them once at the edge of the system.

swift
1import Foundation
2
3final class LegacyClient {
4    func loadProfile(completion: @escaping (Data?, Error?) -> Void) {
5        let json = """
6        {"id":1,"name":"Taylor","email":"[email protected]"}
7        """.data(using: .utf8)
8        completion(json, nil)
9    }
10}
11
12func loadProfileAsync(client: LegacyClient) async throws -> User {
13    let data = try await withCheckedThrowingContinuation { continuation in
14        client.loadProfile { data, error in
15            if let error {
16                continuation.resume(throwing: error)
17                return
18            }
19            guard let data else {
20                continuation.resume(throwing: APIError.emptyData)
21                return
22            }
23            continuation.resume(returning: data)
24        }
25    }
26
27    return try decodeUser(from: data)
28}

That lets the rest of the app stay async without callback nesting.

Keep UI State on the Main Actor

Fetching and parsing should happen off the main path of UI mutation. When the result is ready, update observable state on the main actor.

swift
1import Foundation
2
3@MainActor
4final class ProfileViewModel: ObservableObject {
5    @Published private(set) var user: User?
6    @Published private(set) var errorMessage: String?
7
8    func load(url: URL) {
9        Task {
10            do {
11                user = try await fetchUser(from: url)
12                errorMessage = nil
13            } catch {
14                errorMessage = String(describing: error)
15            }
16        }
17    }
18}

This prevents background parsing code from mutating UI-facing state incorrectly.

Common Pitfalls

One common mistake is blaming all failures on networking. Transport errors and decoding errors should stay distinct because the recovery action is different.

Another pitfall is decoding directly inside view controllers. That makes reuse harder and testing much worse.

Teams also often ignore cancellation. If the user leaves the screen, stale tasks can still complete and overwrite newer state.

Finally, avoid one global decoder for every endpoint unless the entire API uses identical key and date conventions. Different payloads often need different decoder settings.

Summary

  • Structure async parse code as fetch, validate, decode, then return.
  • Keep transport and decoding logic in separate functions.
  • Use Swift concurrency to return parsed models directly instead of nesting callbacks.
  • Bridge legacy completion APIs once with continuations.
  • Update UI-facing state on the main actor after background work completes.

Course illustration
Course illustration

All Rights Reserved.