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:
- 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.
- 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. - 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
- Add the SwiftLint rule but with severity
warninginitially — discover all sites. - Wave 1: BazaarVoiceClient (18 sites in one file → biggest win).
- Wave 2: any remaining force-unwraps on URL.
- Bump the rule to
error.
Verification
- Crashlytics: filter for
EXC_BAD_INSTRUCTIONwith a stack frame inBazaarVoiceClient; should drop to ~zero within one release. - Build pipeline: SwiftLint warning count reaches zero.
- Code review: PR template asks "any force-unwraps added? why?".
Costco iOS · Code Review Report · Generated 2026-05-11 · 88 machine-curated findings