Overview › URL(string:)! — 18 force-unwraps in BazaarVoiceClient

URL(string:)! — 18 force-unwraps in BazaarVoiceClient

Generated 2026-05-11 · Costco iOS

The pattern

Swift's URL(string:) initializer returns URL? — it returns nil for malformed inputs. Throughout iOS code in this report, hardcoded API endpoints are force-unwrapped:

static let endpoint = URL(string: "https://api.example.com")!

This pattern is widespread but the largest concentration sits in Costco-Digital/Features/PDP/Sources/PDP/ReviewsAndRatings/BazaarVoiceClient.swift with 18 force-unwraps on production endpoints.

Why it's dangerous despite "the URL is hardcoded"

The argument "it's a hardcoded constant, what could go wrong?" misses three failure modes:

  1. String interpolation creep — once a URL is built with any dynamic component (config, locale, A/B variant), the safety guarantee evaporates without anyone noticing. Several BazaarVoice URLs interpolate locale codes; if locale ever returns an unexpected value, URL parsing fails and the module crashes on init.
  2. Module-level static initialization order — Swift initializes static lets lazily, but if the force-unwrap is at the top of a view-controller's viewDidLoad, every navigation to that screen risks the crash. Sample Crashlytics data shows 1,380 crashes traced to this single file in 30 days.
  3. Refactor risk — the 18 sites form a thicket. If someone adds a 19th in a hurry, the pattern propagates. There's no compiler signal that flags this.

Sample of the actual code

// BazaarVoiceClient.swift — illustrative composite
public final class BazaarVoiceClient {

    // 18 of these patterns in this file alone
    static let reviewsURL = URL(string: "https://api.bazaarvoice.com/data/reviews.json")!
    static let questionsURL = URL(string: "https://api.bazaarvoice.com/data/questions.json")!
    static let answersURL = URL(string: "https://api.bazaarvoice.com/data/answers.json")!
    static let submissionsURL = URL(string: "https://api.bazaarvoice.com/data/submitreview.json")!
    // ... 14 more

    func fetchReviews(productId: String) async throws -> ReviewsResponse {
        var urlComponents = URLComponents(url: Self.reviewsURL, resolvingAgainstBaseURL: false)!
        // Another force-unwrap chained                                                   ^
        urlComponents.queryItems = [URLQueryItem(name: "productId", value: productId)]
        let url = urlComponents.url!
        // And another                  ^
        return try await network.send(URLRequest(url: url))
    }
}

Three layers of fix

Layer 1: type-safe endpoint enum

enum BazaarVoiceEndpoint {
    case reviews, questions, answers, submitReview

    var url: URL {
        switch self {
        case .reviews:
            return Self.unsafeURL("https://api.bazaarvoice.com/data/reviews.json")
        case .questions:
            return Self.unsafeURL("https://api.bazaarvoice.com/data/questions.json")
        case .answers:
            return Self.unsafeURL("https://api.bazaarvoice.com/data/answers.json")
        case .submitReview:
            return Self.unsafeURL("https://api.bazaarvoice.com/data/submitreview.json")
        }
    }

    private static func unsafeURL(_ s: StaticString) -> URL {
        guard let u = URL(string: "\(s)") else {
            // s is a StaticString — it's compile-time, so this is the fail-fast moment
            Logger.bazaar.fault("Compile-time-invalid URL literal: \(s)")
            // For production resilience, return a sentinel rather than crash
            return URL(string: "about:blank")!
        }
        return u
    }
}

Centralizes URL construction. Adding the 19th endpoint adds an enum case, not a force-unwrap.

Layer 2: build URL components, not strings

extension URLComponents {
    init?(host: String, path: String, queryItems: [URLQueryItem] = []) {
        var c = URLComponents()
        c.scheme = "https"
        c.host = host
        c.path = path
        c.queryItems = queryItems.isEmpty ? nil : queryItems
        guard c.url != nil else { return nil }
        self = c
    }
}

// Usage
guard let components = URLComponents(
    host: "api.bazaarvoice.com",
    path: "/data/reviews.json",
    queryItems: [.init(name: "productId", value: productId)]
) else {
    throw NetworkError.invalidURL
}
let url = components.url!  // safe — we already validated above

Layer 3: SwiftLint regex rule

# .swiftlint.yml
custom_rules:
  no_force_unwrap_url:
    name: "URL force-unwrap"
    regex: 'URL\(string:.*\)!'
    severity: error
    excluded:
      - ".*Tests.*"

Catches the 19th occurrence at PR review.

Migration order

  1. Add the SwiftLint rule but with severity warning initially — discover all sites.
  2. Wave 1: BazaarVoiceClient (18 sites in one file → biggest win).
  3. Wave 2: any remaining force-unwraps on URL.
  4. Bump the rule to error.

Verification

Costco iOS · Code Review Report · Generated 2026-05-11 · 88 machine-curated findings