Contents

maniramezan/swiftcommons

Shared Swift utilities and helpers for reusable app code.

Requirements

  • Swift 6.2 toolchain
  • macOS 15+, iOS 18+, Mac Catalyst 18+ (the package's declared minimums; they apply whether or

not the SwiftData trait is enabled)

Installation

Add SwiftCommons as a Swift Package Manager dependency:

dependencies: [
    .package(url: "https://github.com/maniramezan/SwiftCommons.git", from: "0.12.0")
]

SwiftCommons ships three products:

  • SwiftCommons — the main library. Add this to any target that uses the APIs below.
  • SwiftCommonsTestSupport — test-only helpers (a TestCommons-backed fake clock, LoadingState assertions,

in-memory SwiftData context, sync test fixtures). Add this to your test targets only.

  • SwiftCommonsSpeech — on-device speech-to-text, text-to-speech, and read-aloud transcript

alignment (see Speech). A separate product so apps that don't use speech don't link Speech or AVFAudio. Live recognition needs iOS 26 / macOS 26.

.target(name: "MyApp", dependencies: [
    .product(name: "SwiftCommons", package: "SwiftCommons")
]),
.testTarget(name: "MyAppTests", dependencies: [
    "MyApp",
    .product(name: "SwiftCommons", package: "SwiftCommons"),
    .product(name: "SwiftCommonsTestSupport", package: "SwiftCommons"),
]),

Package traits

| Trait | Default | Enables | |---|---|---| | SwiftData | on | ModelContainer.make(for:inMemory:), SyncEngine, SyncResourceAdapter, SyncMetadata, and the SwiftData test helpers (makeInMemoryModelContext, makeInMemorySyncContainer) | | CSV | off | CSV.parseRows / CSV.serializeRows |

With no traits: argument you get the defaults, so the SwiftData APIs are included. A package that doesn't use SwiftData (for example, a networking library) can leave it out and keep everything else, including the sync DTOs, SyncableModel, SyncState, and SyncEvent:

.package(url: "https://github.com/maniramezan/SwiftCommons.git", from: "0.12.0", traits: [])

Listing traits replaces the defaults instead of adding to them. To add CSV and keep SwiftData, include .defaults:

.package(
    url: "https://github.com/maniramezan/SwiftCommons.git", from: "0.12.0",
    traits: [.defaults, "CSV"])

Features

  • Extensions — safe/defaulted Array subscripts, Optional.ifNil(_:), String blank/trim

helpers, the StringParsable protocol (T(parsing:) with a lenient Bool parser) and String(flag:) boolean-flag helpers, Bundle app-version accessors, UUID byte/deterministic helpers, Duration.timeInterval, URL string-literal support, and locale-aware NumberFormatter/FixedWidthInteger helpers.

  • Decoding diagnostics — DecodingError.debugSummary (e.g.

keyNotFound 'id' at items[2].user) for logging decode failures without payload values.

  • Dates & calendars — Calendar date math (start of week/month, inclusive date ranges, month

arithmetic), a thread-safe DateFormatter cache, and Date.relativeDescription(...) for human-readable relative times ("1 hour ago").

  • Localization — Language/Country enums and Locale identifier helpers.
  • Logging — OSLog Logger conveniences for categories, privacy, and context.
  • Library logging — LibraryLogger, a level-gated OSLog front end whose verbosity a library's

users control (setLevel(_:)), with per-category handles and private URL/error logging.

  • Formatting — DurationFormatter for compact m:ss / h:mm:ss durations.
  • Errors — AnySendableError, a Hashable snapshot of any error (type, domain, code,

descriptions) for storing or comparing errors as values.

  • State — a generic LoadingState machine (idle/loading/loaded/failed) with a

user-safe LoadingError, including a LoadingState.load { ... } helper for wrapping async work. LoadingErrorKind and LoadingErrorClassifiable distinguish offline, timeout, and other failures without displaying internal error details; apps can map LoadingError.kind to localized copy.

  • App versions — AppVersion(string:) parses and compares numeric marketing versions,

normalizing missing components to zero and ignoring prerelease/build suffixes.

  • Month grids — CalendarArithmetic.gridDates(in:) returns day starts padded to full weeks,

respecting the calendar's first weekday, time zone, leap months, and era segments.

  • Configuration — ConfigValue, a lenient bool/string/int/double coercion type, with

loaders for ProcessInfo.environment and decoded property lists, plus a ConfigValueType tag and ConfigValue(string:valueType:) for decoding a textual value whose type is known separately.

  • Async fan-out — AsyncBroadcaster, one source broadcast to many AsyncStreams, with

optional replay of the latest value.

  • Concurrency — AsyncLock (FIFO mutex), AsyncSemaphore (counting semaphore), Debouncer,

and withRetry(...) — all built on the injectable DelayClock abstraction so consumers can substitute a fake clock in tests.

  • Retry backoff — RetryBackoff (.constant, .exponential with cap and jitter) and a

shouldRetry predicate for withRetry(attempts:backoff:clock:shouldRetry:operation:).

  • Timeouts — withTimeout(_:clock:operation:) races async work against an injectable clock,

cancels the losing task, and throws TimeoutError when time expires. Operations must cooperate with cancellation for prompt completion.

  • CSV — lightweight CSV parsing/serialization (behind the CSV package trait).
  • Call coalescing — SingleFlight<Key, Value>, which shares one in-flight async call among

concurrent callers for the same key.

  • Persistence — ModelContainer.make(for:inMemory:), a thin SwiftData bootstrap helper for

apps, previews, and tests.

  • Sync — a generic SyncEngine that drives offline, cross-device sync for SwiftData-backed

resources through one contract-owning loop; resources plug in as SyncResourceAdapter values.

Usage

Collections & optionals

import SwiftCommons

let value = [1, 2, 3][safe: 10]          // nil — no crash
let fallback = ["a", "b"][5, default: "n/a"]  // "n/a"
let slice = [1, 2, 3][safe: 1..<10]      // [2, 3]

let name: String? = nil
let resolved = name.ifNil("Anonymous")   // "Anonymous"

Strings, Bundle, and UUID

"  hi  ".trimmed        // "hi"
"   ".nilIfBlank        // nil

Bool(parsing: "yes")   // true — also accepts 1/0, on/off, true/false
Int(parsing: "42")     // 42 — T(parsing:) works for any StringParsable
String(flag: true)     // "1"

Bundle.main.versionAndBuildNumber  // "2.3.1 (142)"

UUID().bytes             // [UInt8], canonical byte order

Numbers & currency

1234.digits                                          // [4, 3, 2, 1]
NumberFormatter.formatDecimal(1234.5, fractionDigits: 2...2) // "1,234.50" in en_US
NumberFormatter.formatPercent(0.25)                    // "25%" in en_US
NumberFormatter.formatYear(2024)                      // "2024" (digits follow the locale)
NumberFormatter.formatCurrency(9.99, currencyCode: "USD") // "$9.99"

Dates & calendars

let formatter = DateFormatter.formatter(.MMMMddyyyy) // cached per thread
let dateAndTime = DateFormatter.formatter(dateStyle: .medium, timeStyle: .short)
let monthLabel = DateFormatter.formatter(template: "yMMMM", calendar: .current)
// Cached formatters are shared on the calling thread: treat them as read-only
// and use them synchronously without passing them between tasks or threads.
let dateString = formatter.string(from: Date())

let calendar = Calendar(identifier: .gregorian)
let monthStart = try calendar.startOfMonth(for: Date())
let days = try calendar.numberOfDays(in: 2, year: 2024) // 29

Date().addingTimeInterval(-3600).relativeDescription() // "1 hour ago"

Localization

let id = Locale.identifier(language: .french, country: .canada) // "fr-CA"
let preset = Locale.Identifiers.englishUS                        // "en-US"
let persian = Locale(identifier: "fa_IR").withNumberingSystemIdentifier(.arabExtended)

Logging

let logger = Logger.swiftCommonsLogger(for: MyType.self)
logger.infoPublic("Started")
logger.error("Failed to load", error: error, context: "id=\(id)")

Signposts

SignpostRecorder wraps OSSignposter for measuring work that has to fit inside a frame — scrolling, animation, rendering. Recording is free when no profiler is attached.

let signposts = SignpostRecorder(subsystem: "MyApp", category: "Rendering")

let grid = signposts.measure("resolveGrid") { expensiveGridResolution() }

// Start and end driven by separate callbacks
let state = signposts.begin("scrollSettle")
signposts.end("scrollSettle", state)

Record with Instruments' os_signpost instrument, or xcrun xctrace record --template 'os_signpost' --attach <pid>.

State & configuration

var state: LoadingState<[Item]> = await .load { try await api.fetchItems() }
if let items = state.value { /* ... */ }
if let error = state.error { showError(error.message, retryable: error.isRetryable) }

let config = ConfigValue.environment()
let isDebug = config["DEBUG_MODE"]?.boolValue ?? false

// Decode a textual value when its type is known separately
ConfigValue(string: "yes", valueType: .bool)  // .bool(true)
ConfigValue(string: "7.5", valueType: .int)   // nil

Concurrency

let data = try await withRetry(attempts: 3, delay: .seconds(1)) {
    try await fetchData()
}

let debouncer = Debouncer(delay: .milliseconds(300))
func searchFieldDidChange(_ query: String) async {
    await debouncer.run { await performSearch(query) }
}

let lock = AsyncLock() // serialize work that spans awaits
let result = try await lock.withLock { try await refreshToken() }

let semaphore = AsyncSemaphore(value: 3) // cap concurrent requests
let response = try await semaphore.withPermit { try await urlSession.data(from: url) }

Timeouts, versions, and month grids

let items = try await withTimeout(.seconds(10)) {
    try await fetchItems()
}

let installedVersion = AppVersion(string: "2.4")
let recommendedVersion = AppVersion(string: "2.5.0-preview")

let arithmetic = CalendarArithmetic(calendar: Calendar(identifier: .gregorian))
let dates = arithmetic.gridDates(in: MonthIdentifier(month: 2, year: 2028))

AppVersion compares numeric app versions; it does not implement semantic-version prerelease precedence. gridDates(in:) returns nil for an invalid or mismatched month and includes padding dates outside the requested month. Use arithmetic.month(containing:) to identify that padding.

withTimeout waits for both child tasks to finish cleanup. An operation that ignores cancellation can keep it suspended beyond the timeout. Parent cancellation throws CancellationError.

LoadingError(from:) recognizes errors conforming to LoadingErrorClassifiable, native offline and timeout URL errors, and TimeoutError. Other errors retain the generic safe message. For localized presentation, map LoadingError.kind to app-owned strings; explicit messages remain available through LoadingError(message:isRetryable:requiresSignIn:kind:).

Speech

SwiftCommonsSpeech wraps Apple's on-device SpeechAnalyzer and AVSpeechSynthesizer behind small protocols (SpeechRecognizing, SpeechSynthesizing) so features can be tested with fakes.

import SwiftCommonsSpeech

// 1. Permissions (Info.plist needs NSMicrophoneUsageDescription and
//    NSSpeechRecognitionUsageDescription) and the locale's model.
guard await SpeechRecognitionPermissions.requestAll() else { return }
let locale = Locale(identifier: "en_US")
if await SpeechModelAssets.status(for: locale) != .installed {
    try await SpeechModelAssets.install(for: locale) { progress in print(progress) }
}

// 2. Live transcript; on iOS, activate a .playAndRecord AVAudioSession first.
let recognizer = SpeechRecognitionSession()
let events = try await recognizer.start(
    SpeechRecognitionConfiguration(locale: locale, autoStopAfterSilence: .seconds(1.5)))
var transcript = SpeechTranscript()
for try await event in events {
    if case .transcript(let latest) = event { transcript = latest }
}

// 3. Speak a reply with the best installed voice.
let speech = SpeechSynthesisSession()
for await _ in speech.speak(SpeechSynthesisRequest(text: "Nice work!", languageCode: "en-US")) {}

// 4. Grade a read-aloud attempt word by word.
let alignment = TranscriptAlignment.align(
    reference: "The quick brown fox", heard: transcript.words.map(\.transcriptToken))
alignment.tokens   // .match / .lowConfidence / .substitution / .omission / .insertion
alignment.score    // 0...1

Alignment compares what the recognizer understood: it catches missed, extra, and misheard words, but a recognizer often repairs mildly mispronounced words, so it isn't phoneme-level pronunciation scoring. Leave contextualStrings empty when grading, or the recognizer is biased toward the expected sentence.

Sync (SwiftData)

SyncEngine drives offline sync for SwiftData rows that conform to SyncableModel. Each resource plugs in with one SyncResourceAdapter (a struct of closures); the engine owns the ack guard, pending guard, pagination, full-snapshot reconciliation, and full-resync recovery.

@Model final class Item: SyncableModel {
    var key: String
    var title: String
    var serverId: Int?
    var updatedAt: Date
    var isTombstoned: Bool      // never name this `isDeleted` on a @Model
    var syncState: SyncState
    var localUpdatedAt: Date
    // init ...
}

// SyncMetadata stores each resource's cursor — it must be in your schema.
let container = try ModelContainer.make(for: Item.self, SyncMetadata.self)
let engine = SyncEngine(modelContainer: container, events: { analytics.track($0) })

let items = SyncResourceAdapter<Item, ItemUpsert, ItemDelete, ItemChange>(
    resourceName: "items",
    fetchPending: { try $0.fetch(FetchDescriptor<Item>(predicate: #Predicate { $0.syncState != .synced })) },
    businessKey: { $0.key },
    makeUpserts: { $0.filter { !$0.isTombstoned }.map(ItemUpsert.init) },
    makeDeletes: { $0.filter(\.isTombstoned).map(ItemDelete.init) },
    call: { try await api.sync("items", $0) },
    findExisting: { change, context in /* fetch by change.key */ },
    changeKey: { $0.key },
    isChangeDeleted: { $0.isDeleted },
    upsertFromChange: { change, existing, context in /* apply or insert */ },
    fetchActive: { try $0.fetch(FetchDescriptor<Item>(predicate: #Predicate { !$0.isTombstoned })) },
    purgeSynced: { try $0.delete(model: Item.self, where: #Predicate { $0.syncState == .synced }) }
)

try await engine.syncAll([AnySyncResource(items)])

SyncMetadata uses @Attribute(.unique), which CloudKit-backed containers don't support — keep it in a local (non-CloudKit) store. See the Syncing SwiftData Resources DocC article for the full contract.

Persistence (SwiftData)

let container = try ModelContainer.make(for: Item.self, Tag.self)
let previewContainer = try ModelContainer.make(for: Item.self, inMemory: true)

Testing your own code

Add the SwiftCommonsTestSupport product to your test target to get:

import SwiftCommons
import SwiftCommonsTestSupport
import Testing

let clock = ManualClock()
let debouncer = Debouncer(delay: .seconds(1), clock: clock)
await debouncer.run { /* action under test */ }
try #require(await clock.waitForSleepCount(1))
await clock.advance(by: .seconds(1)) // resumes the pending action deterministically

let context = try makeInMemoryModelContext(for: Item.self)

let state = await LoadingState.load { try await fetchItems() }
expectLoaded(state) // records a test failure if `state` isn't `.loaded`

// Sync: a container that already includes SyncMetadata, plus DTO builders.
let container = try makeInMemorySyncContainer(for: Item.self)
let response = SyncResponseDTO<ItemChange>.fixture(
    applied: [.fixture(key: "run", id: 101, status: "created")], cursor: "c1")

Agent skills

The repo ships Agent Skills that teach coding agents (Claude Code, Codex, Cursor, …) to use SwiftCommons correctly in your app. Install them into a consuming project:

npx skills add maniramezan/SwiftCommons                         # all three
npx skills add maniramezan/SwiftCommons --skill swiftcommons-sync
  • swiftcommons — "use this instead of hand-rolling it" API map, logging conventions, pitfalls.
  • swiftcommons-sync — adding a SyncEngine resource end to end.
  • swiftcommons-testing — SwiftCommonsTestSupport patterns (fake clock, fixtures).

Documentation

Full API documentation is published with DocC to GitHub Pages.

Generate documentation locally:

swift package generate-documentation \
  --target SwiftCommons \
  --output-path /tmp/docc

Package Metadata

Repository: maniramezan/swiftcommons

Default branch: main

README: README.md