Crafting a Swift Timespan Type — With a Little Prompt Engineering Magic
I recently needed a simple Swift type that deals with durations—like laps, run paces, or countdowns—without the complexity of calendar dates. Sure, I could use Date
, Calendar
, or TimeInterval
, but they always feel a bit off for pure durations. So, I set out to build a Timespan
struct that:
- Stores durations down to milliseconds
- Handles arithmetic (like adding, subtracting, and multiplying) in a clean way
- Optionally rounds
.500 ms
or more up to the next whole second for easier display
I also wanted to ensure sub-second precision is preserved internally, so times like 3:30:58.500
remain intact. This post shares my final code, plus a small nod to how I leveraged a bit of prompt engineering to refine my thoughts and approach.
Why Build a Timespan
in Swift?
Avoiding Date/Calendar Overkill
Date
andCalendar
are good for calendar-based events, but durations like “2h 15m 30s” end up awkward with them.Better Than a Double
TimeInterval
is just aDouble
in seconds. It’s simple, but sub-second formatting or integer arithmetic can get unwieldy.Clean Arithmetic and Formatting
I wanted a direct interface to hours, minutes, seconds, and milliseconds, plus neat operators and easy string output.
Preserving Fractional Seconds
One of the challenges I faced was to ensure that if I multiply a pace by a distance and get 3:30:58.500, I don’t lose that .500
portion. By rounding to the nearest millisecond instead of truncating, I can keep those partial seconds around. That’s critical if you’re, say, summing multiple intervals or comparing slight differences in lap times.
Optional Rounding in the Display
While precision is great internally, sometimes a user (or I) just wants to see whole seconds—especially if .500 ms
or more is leftover. So, I added a toggle in the formatted
method:
formatted(includeMilliseconds: true)
→ shows partial seconds exactly (like3:30:58.500
).formatted(includeMilliseconds: false)
→ any.500 ms
or more gets bumped up, so that same time becomes3:30:59
.
This approach keeps the math accurate yet offers a clean display option.
The Timespan Code
Below is the entire Timespan
struct. It stores durations in milliseconds, leverages Swift 5.7’s Duration
, and supports optional rounding for display:
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import Foundation
struct Timespan {
private let duration: Duration
// MARK: - Computed Properties
var hours: Int {
duration.split.seconds / 3600
}
var minutes: Int {
(duration.split.seconds % 3600) / 60
}
var seconds: Int {
duration.split.seconds % 60
}
var milliseconds: Int {
duration.split.nanoseconds / 1_000_000
}
// MARK: - Initializers
init(milliseconds: Int) {
self.duration = .milliseconds(milliseconds)
}
init(seconds: Int) {
self.duration = .seconds(seconds)
}
init(hours: Int = 0, minutes: Int = 0, seconds: Int = 0, milliseconds: Int = 0) {
let totalMs = (hours * 3600 * 1000)
+ (minutes * 60 * 1000)
+ (seconds * 1000)
+ milliseconds
self.duration = .milliseconds(totalMs)
}
// MARK: - Derived Values
var totalMilliseconds: Int {
Int(duration.totalNanoseconds / 1_000_000)
}
var totalSeconds: Double {
Double(totalMilliseconds) / 1000.0
}
// MARK: - Formatting
func formatted(includeMilliseconds: Bool = false) -> String {
if includeMilliseconds {
return formatWithMilliseconds()
} else {
return formatAsWholeSecondsRounding()
}
}
private func formatWithMilliseconds() -> String {
if hours > 0 {
return String(format: "%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds)
} else {
return String(format: "%02d:%02d.%03d", minutes, seconds, milliseconds)
}
}
private func formatAsWholeSecondsRounding() -> String {
let leftoverMs = milliseconds
var totalS = duration.split.seconds
if leftoverMs >= 500 {
totalS += 1
}
let hh = totalS / 3600
let mm = (totalS % 3600) / 60
let ss = totalS % 60
if hh > 0 {
return String(format: "%02d:%02d:%02d", hh, mm, ss)
} else {
return String(format: "%02d:%02d", mm, ss)
}
}
// MARK: - Arithmetic
func multiplied(by factor: Double) -> Timespan {
let scaledMs = Double(totalMilliseconds) * factor
let roundedMs = scaledMs.rounded(.toNearestOrAwayFromZero)
return Timespan(milliseconds: Int(roundedMs))
}
func adding(_ other: Timespan) -> Timespan {
let sumMs = Double(totalMilliseconds) + Double(other.totalMilliseconds)
let roundedMs = sumMs.rounded(.toNearestOrAwayFromZero)
return Timespan(milliseconds: Int(roundedMs))
}
func subtracting(_ other: Timespan) -> Timespan {
let diff = max(0, Double(totalMilliseconds) - Double(other.totalMilliseconds))
let roundedMs = diff.rounded(.toNearestOrAwayFromZero)
return Timespan(milliseconds: Int(roundedMs))
}
func divided(by divisor: Double) -> Timespan {
guard divisor != 0 else {
fatalError("Division by zero is not allowed.")
}
let dividedMs = Double(totalMilliseconds) / divisor
let roundedMs = dividedMs.rounded(.toNearestOrAwayFromZero)
return Timespan(milliseconds: Int(roundedMs))
}
// MARK: - Operators
static func + (lhs: Timespan, rhs: Timespan) -> Timespan {
lhs.adding(rhs)
}
static func - (lhs: Timespan, rhs: Timespan) -> Timespan {
lhs.subtracting(rhs)
}
static func * (lhs: Timespan, rhs: Double) -> Timespan {
lhs.multiplied(by: rhs)
}
static func * (lhs: Double, rhs: Timespan) -> Timespan {
rhs.multiplied(by: lhs)
}
static func / (lhs: Timespan, rhs: Double) -> Timespan {
lhs.divided(by: rhs)
}
// MARK: - Comparison Operators
static func == (lhs: Timespan, rhs: Timespan) -> Bool {
lhs.totalMilliseconds == rhs.totalMilliseconds
}
static func != (lhs: Timespan, rhs: Timespan) -> Bool {
!(lhs == rhs)
}
static func < (lhs: Timespan, rhs: Timespan) -> Bool {
lhs.totalMilliseconds < rhs.totalMilliseconds
}
static func > (lhs: Timespan, rhs: Timespan) -> Bool {
lhs.totalMilliseconds > rhs.totalMilliseconds
}
static func <= (lhs: Timespan, rhs: Timespan) -> Bool {
!(lhs > rhs)
}
static func >= (lhs: Timespan, rhs: Timespan) -> Bool {
!(lhs < rhs)
}
}
extension Duration {
var split: (seconds: Int, nanoseconds: Int) {
let (secs, attos) = components
let leftoverNs = attos / 1_000_000_000
return (Int(secs), Int(leftoverNs))
}
var totalNanoseconds: Int64 {
let (secs, attos) = components
let leftoverNs = attos / 1_000_000_000
return secs * 1_000_000_000 + leftoverNs
}
}
Quick Demo: Marathon, Half Marathon, 10K
Here’s how I calculate total times for a few race distances at various paces, displaying them both “exactly” (with ms) and “rounded” (bumping .500 ms up):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func demoRunTimes() {
let marathonDistance = 42.195
let halfMarathonDistance = 21.0975
let tenKDistance = 10.0
// Marathon @ 5:00 per km
let marathonPace = Timespan(minutes: 5, seconds: 0)
let totalMarathonTime = marathonPace.multiplied(by: marathonDistance)
print("Marathon @ 5:00/km -> Exact: \(totalMarathonTime.formatted(includeMilliseconds: true))")
print("Marathon @ 5:00/km -> Rounded: \(totalMarathonTime.formatted())\n")
// Half Marathon @ 4:45 per km
let halfMarathonPace = Timespan(minutes: 4, seconds: 45)
let totalHalfMarathonTime = halfMarathonPace.multiplied(by: halfMarathonDistance)
print("Half Marathon @ 4:45/km -> Exact: \(totalHalfMarathonTime.formatted(includeMilliseconds: true))")
print("Half Marathon @ 4:45/km -> Rounded: \(totalHalfMarathonTime.formatted())\n")
// 10K @ 5:30 per km
let tenKPace = Timespan(minutes: 5, seconds: 30)
let totalTenKTime = tenKPace.multiplied(by: tenKDistance)
print("10K @ 5:30/km -> Exact: \(totalTenKTime.formatted(includeMilliseconds: true))")
print("10K @ 5:30/km -> Rounded: \(totalTenKTime.formatted())\n")
}
Sample output might look like:
1
2
3
4
5
6
7
8
Marathon @ 5:00/km -> Exact: 03:30:58.500
Marathon @ 5:00/km -> Rounded: 03:30:59
Half Marathon @ 4:45/km -> Exact: 01:40:15.250
Half Marathon @ 4:45/km -> Rounded: 01:40:15
10K @ 5:30/km -> Exact: 00:55:00.000
10K @ 5:30/km -> Rounded: 00:55:00
Final Thoughts and Prompt Engineering Tidbit
I used prompt engineering with ChatGPT along the way to brainstorm, validate ideas, and smooth out some rough edges in my code. It was surprisingly helpful for iterating quickly and catching little pitfalls—like accidentally truncating those all-important fractional milliseconds.
All told, Timespan
is a neat little tool for Swift projects needing:
- Sub-second precision internally
- Arithmetic on durations without fussing over raw seconds
- Optional rounding up if
.500 ms
or more
Feel free to adapt this approach or run the code as-is in your next Swift app. Enjoy!
Until the next post, happy coding, everyone!
Disclaimer: The code presented in this post was written with the assistance of ChatGPT. The initial draft of this blog post was generated based on the conversation with ChatGPT as well. While I have reviewed and refined the code and content, the use of AI tools may have influenced the final output. The title image of this post is also created with AI (DALL-E 3 through ChatGPT). Both the code and this blog post are part of my private experiment of using AI tools in different areas. Always review and validate any generated code before using it in production.