ForgeKVStore
Type-safe key-value storage for settings, flags, and small serialized data. One protocol, three backing stores. Add reactive observation via ReactiveKVStore.
import ForgeKVStore KVKey
Every value is accessed through a KVKey<Value> that pairs a string identifier with a type. The Value must conform to Codable & Sendable. There is no default value — get() returns nil when nothing has been stored.
public struct KVKey<Value: Codable & Sendable>: Sendable, Equatable {
public let key: String
public init(_ key: String)
} Defining Keys
Group keys by domain using enum namespaces. Use dot-separated prefixes to avoid collisions. Supply the value type as the generic parameter.
enum TaskKeys {
static let onboarding = KVKey<Bool>("taskflow.onboarding")
static let projectName = KVKey<String?>("taskflow.projectName")
static let taskCount = KVKey<Int>("taskflow.taskCount")
static let settings = KVKey<TaskSettings>("taskflow.settings")
}
enum CacheKeys {
static let lastPurgeDate = KVKey<Date?>("cache.lastPurgeDate")
} Supported Types
Any Codable type works: Bool, Int, Double, Float, String, Date, Data, URL, optionals, arrays, dictionaries with Codable elements, and custom Codable structs or enums. The store automatically selects the optimal encoding path — native for primitives, JSON for custom types.
KVStoring Protocol
All stores conform to KVStoring. Depend on the protocol, not the concrete type. Note: get() returns an optional, and set(), remove(), removeAll() all throw.
public protocol KVStoring: Sendable {
func get<T: Codable & Sendable>(_ key: KVKey<T>) -> T?
func set<T: Codable & Sendable>(_ key: KVKey<T>, value: T) throws
func remove<T: Codable & Sendable>(_ key: KVKey<T>) throws
func contains<T: Codable & Sendable>(_ key: KVKey<T>) -> Bool
func removeAll() throws
} The protocol does not include stream(). For reactive observation, wrap any store in ReactiveKVStore.
API Reference
get(_:)
Returns the stored value, or nil if nothing has been set. Use the nil-coalescing operator (??) to provide a fallback.
let count = store.get(TaskKeys.taskCount) ?? 0 // Int
let name = store.get(TaskKeys.projectName) // String? — nil if not set set(_:value:)
Stores a value. Overwrites any existing value for the key. Throws on encoding or write failure.
try store.set(TaskKeys.taskCount, value: 42)
try store.set(TaskKeys.projectName, value: "TaskFlow v2") remove(_:)
Removes the stored value. Subsequent reads return nil.
try store.remove(TaskKeys.projectName)
store.get(TaskKeys.projectName) // nil contains(_:)
Returns true if a value was explicitly set for the key.
store.contains(TaskKeys.taskCount) // false (not yet set)
try store.set(TaskKeys.taskCount, value: 1)
store.contains(TaskKeys.taskCount) // true removeAll()
Removes all values from the store.
try store.removeAll() UserDefaultsKVStore
Production store backed by UserDefaults. Conforms to KVStoring. Supports suite-based sharing for app groups (widgets, extensions).
// Standard UserDefaults
let store = UserDefaultsKVStore()
// App group suite (for widgets, extensions)
let sharedStore = UserDefaultsKVStore(suiteName: "group.com.taskflow.shared")
// Pre-existing UserDefaults instance
let custom = UserDefaultsKVStore(defaults: myDefaults) Encoding is automatic: native types (Bool, Int, Double, Float, String, Date, Data) are stored natively in UserDefaults. Custom Codable types are JSON-encoded.
DiskKVStore
File-based key-value store. A stateless struct — each method performs independent file I/O. Each key maps to a JSON file: {directory}/{namespace}/{sanitized-key}.json. Use this for values too large for UserDefaults or when you need specific file protection.
let store = DiskKVStore(
directory: .applicationSupport,
namespace: "taskflow-settings",
fileProtection: .complete // Encrypted when device locked
) When to Use DiskKVStore vs UserDefaultsKVStore
| Criteria | UserDefaultsKVStore | DiskKVStore |
|---|---|---|
| Small values (< 1KB) | Preferred | Overkill |
| Large values (> 100KB) | Performance issues | Preferred |
| File protection control | Limited | Per-store configurable |
| Cross-process sharing | Via app group suite | Not supported |
Directory Options
.documents— User-visible, backed up by iCloud..applicationSupport— App-managed, backed up. Best for persistent settings..caches— Not backed up, may be purged by the system on low disk.
File Protection
Set via the fileProtection parameter at init. Applied to the directory and every file written.
// Default — accessible after first unlock (good for background tasks)
DiskKVStore(directory: .applicationSupport, namespace: "taskflow-data")
// Complete protection — only accessible when device is unlocked
DiskKVStore(
directory: .applicationSupport,
namespace: "taskflow-sensitive",
fileProtection: .complete
) InMemoryKVStore
Dictionary-backed store for tests and SwiftUI previews. No persistence — values exist only for the lifetime of the instance. Thread-safe via LockedState. Conforms to the same KVStoring protocol.
// In tests
let store = InMemoryKVStore()
let viewModel = TaskFlowViewModel(store: store)
// Assert default state
#expect(store.get(TaskKeys.taskCount) == nil)
// Mutate and verify
try store.set(TaskKeys.taskCount, value: 5)
#expect(store.get(TaskKeys.taskCount) == 5) ReactiveKVStore
ReactiveKVStore wraps any KVStoring implementation and adds per-key observation via AsyncStream. This is the only way to get stream() — the KVStoring protocol itself does not include it.
Writes must go through the ReactiveKVStore wrapper for observation to work. The underlying store is accessed directly — no caching layer.
// Wrap any KVStoring in ReactiveKVStore
let store = UserDefaultsKVStore()
let reactive = ReactiveKVStore(store)
// ReactiveKVStore conforms to KVStoring — use it the same way
try reactive.set(TaskKeys.onboarding, value: true)
let done = reactive.get(TaskKeys.onboarding) // Optional(true) stream(_:)
Returns an AsyncStream<T?> that emits the current value immediately, then yields on every change. Duplicate values are skipped. The value type must conform to Equatable.
// Observe a key
for await value in reactive.stream(TaskKeys.taskCount) {
print("Task count: \(value ?? 0)")
}
// In a SwiftUI ViewModel
func observeOnboarding() {
Task {
for await isDone in reactive.stream(TaskKeys.onboarding) {
await MainActor.run {
self.showOnboarding = !(isDone ?? false)
}
}
}
} KVStoreError
Errors thrown by KVStoring operations. All mutating methods (set, remove, removeAll) throw on failure.
public enum KVStoreError: LocalizedError, Sendable {
case encodingFailed(key: String, underlying: String)
case writeFailed(key: String, underlying: String)
case removeFailed(key: String, underlying: String)
case removeAllFailed(underlying: String)
} do {
try store.set(TaskKeys.settings, value: settings)
} catch let error as KVStoreError {
switch error {
case .encodingFailed(let key, let underlying):
print("Encoding failed for '\(key)': \(underlying)")
case .writeFailed(let key, let underlying):
print("Write failed for '\(key)': \(underlying)")
default:
print(error.localizedDescription)
}
}