Creating a Swift Download Dependency with AsyncThrowingStream
Learn how to build a clean, testable Swift dependency for downloading files using AsyncThrowingStream.
In this post, we’ll walk through how to create a modular, testable download client in Swift using AsyncThrowingStream
, @DependencyClient
, and a throttled progress mechanism.
Why AsyncThrowingStream?
AsyncThrowingStream
allows you to emit values over time, which makes it a great fit for use cases like downloading files and reporting progress or errors as they occur.
Step 1: Define the DownloadClient
We define a dependency client using the new @DependencyClient
macro:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Dependencies
import DependenciesMacros
import CasePaths
import Foundation
@DependencyClient
struct DownloadClient {
var download: @Sendable (_ url: URL) -> AsyncThrowingStream<Event, any Error> = { _ in .finished() }
@CasePathable
enum Event: Equatable {
case response(Data)
case updateProgress(Double)
}
}
Step 2: Add it to DependencyValues
This enables access via @Dependency(\.downloadClient)
:
1
2
3
4
5
6
extension DependencyValues {
var downloadClient: DownloadClient {
get { self[DownloadClient.self] }
set { self[DownloadClient.self] = newValue }
}
}
Step 3: Implement the Live Client
This is the real implementation using URLSession.shared.bytes
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
extension DownloadClient: DependencyKey {
static var liveValue: DownloadClient {
.production
}
static var production: DownloadClient {
.init { url in
.init { continuation in
Task {
do {
let (bytes, response) = try await URLSession.shared.bytes(from: url)
var data = Data()
var progress = 0
for try await byte in bytes {
data.append(byte)
let newProgress = Int(Double(data.count) / Double(response.expectedContentLength) * 100)
if newProgress != progress {
progress = newProgress
continuation.yield(.updateProgress(Double(progress) / 100))
}
}
continuation.yield(.response(data))
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
Step 4: Add Mocks for Preview and Test
1
2
3
4
5
6
7
static var previewValue: DownloadClient { .mock }
static var testValue: DownloadClient { .mock }
static var mock: DownloadClient {
.init { _ in .finished() }
}
}
Throttle Progress Updates
We reduce UI work by only emitting progress when the integer percent changes:
1
2
3
4
5
let newProgress = Int(Double(data.count) / Double(response.expectedContentLength) * 100)
if newProgress != progress {
progress = newProgress
continuation.yield(.updateProgress(Double(progress) / 100))
}
This avoids flooding the UI with updates like 0.231 → 0.232 → 0.233...
and instead yields at most 100 clean updates from 0.00 → 1.00
.
Example Usage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Dependency(\.downloadClient) var downloadClient
func startDownload(from url: URL) async {
do {
for try await event in downloadClient.download(url) {
switch event {
case let .updateProgress(value):
print("Progress: \(Int(value * 100))%")
case let .response(data):
print("Download completed, size: \(data.count) bytes")
}
}
} catch {
print("Download failed: \(error.localizedDescription)")
}
}
Full Source Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import Dependencies
import DependenciesMacros
import CasePaths
import Foundation
@DependencyClient
struct DownloadClient {
var download: @Sendable (_ url: URL) -> AsyncThrowingStream<Event, any Error> = { _ in .finished() }
@CasePathable
enum Event: Equatable {
case response(Data)
case updateProgress(Double)
}
}
extension DependencyValues {
var downloadClient: DownloadClient {
get { self[DownloadClient.self] }
set { self[DownloadClient.self] = newValue }
}
}
extension DownloadClient: DependencyKey {
static var liveValue: DownloadClient { .production }
static var production: DownloadClient {
.init { url in
.init { continuation in
Task {
do {
let (bytes, response) = try await URLSession.shared.bytes(from: url)
var data = Data()
var progress = 0
for try await byte in bytes {
data.append(byte)
let newProgress = Int(Double(data.count) / Double(response.expectedContentLength) * 100)
if newProgress != progress {
progress = newProgress
continuation.yield(.updateProgress(Double(progress) / 100))
}
}
continuation.yield(.response(data))
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
static var previewValue: DownloadClient { .mock }
static var testValue: DownloadClient { .mock }
static var mock: DownloadClient {
.init { _ in .finished() }
}
}
This approach makes your download logic modular, testable, and optimized for UI responsiveness. Perfect for SwiftUI + TCA projects.
☕ Support My Work
If you found this post helpful and want to support more content like this, you can buy me a coffee!
Your support helps me continue creating useful articles and tips for fellow developers. Thank you! 🙏