dankinsoid/swift-configs
SwiftConfigs provides a unified, type-safe API for small key-value storage systems where keys can be manually enumerated. The library abstracts storage implementation details behind a clean interface, making it easy to switch between different backends without changing applicatio
Features
- Unified API for Small Key-Value Stores: Works with UserDefaults, Keychain, environment variables, in-memory storage, and other enumerable key-value systems
- Configuration Categories: High-level abstraction that allows changing storage backends without modifying code that uses the values
- Type Safety: Full support for any
Codablevalues out of the box with compile-time type checking - Flexible Key Configuration: Individual keys can use specific stores instead of abstract categories, allowing usage before system bootstrap
- Easy Storage Migration: Seamlessly migrate between different storage backends or individual key migrations
- Test and Preview Support: Automatically uses in-memory storage for SwiftUI previews and can be easily configured for testing
- Per-Key Customization: Each configuration key can have its own store, transformer, or migration logic
- Property Wrapper APIs for simpler usage
- Real-time Updates with cancellable change subscriptions
- Secure Storage Options including Keychain and Secure Enclave support
Getting Started
1. Import SwiftConfigs
import SwiftConfigs2. Define Configuration Keys
public extension Configs.Keys {
var apiToken: RWConfigKey<String?> {
ConfigKey("api-token", in: .secure)
}
var userID: ROConfigKey<UUID> {
ConfigKey("USER_ID", in: .syncedSecure, default: UUID(), cacheDefaultValue: true)
}
var serverURL: ROConfigKey<String> {
ConfigKey("SERVER_URL", in: .environment, default: "https://api.example.com")
}
}3. Create a Configs Instance
let configs = Configs()4. Use Your Configuration
// Read values
let userID = configs.userID
let token = configs.apiToken
let serverURL = configs.serverURL
// Write values (for RWConfigKey only)
configs.apiToken = "new-token"Configuration Categories
SwiftConfigs organizes configuration data using categories, allowing you to store different types of settings in appropriate backends:
ConfigSystem.bootstrap([
.default: .userDefaults, // General app settings
.secure: .keychain, // Sensitive data (tokens, passwords)
.critical: .secureEnclave(), // Maximum security with biometrics
.syncedSecure: .keychain(iCloudSync: true), // Synced secure data
.environment: .environment, // Environment variables
.memory: .inMemory, // Temporary/testing data
.remote: .userDefaults, // Remote configuration cache
.manifest: .infoPlist // App Info.plist values
])Built-in Categories
.default- General application settings.synced- Data synced across devices.secure- Sensitive data requiring encryption.critical- Maximum security with hardware protection.syncedSecure- Secure data synced across devices.environment- Environment variables.memory- In-memory storage.remote- Remote configuration cache.manifest- App manifest values, e.g. Info.plist
Bootstrapping
Call ConfigSystem.bootstrap(:errorHandler:) (or defaultBootstrap(:errorHandler:)) at app start, before any config is read. Calling it again is safe: each call replaces the stores and the error handler, so the last call wins. Change observers registered earlier keep observing the stores they were registered with.
Error Handling
Subscript and property access never throw. A read failure (a store error such as a locked Keychain, or a value that fails to decode) is passed to the errorHandler given to bootstrap, and the key's default is returned. The default handler calls fatalError in debug builds.
To handle read failures at the call site, use throwingGet. It throws the underlying error instead of reporting it; a missing value still yields the default:
do {
let token = try configs.throwingGet(\.apiToken) // also: throwingGet(.apiToken), $wrapper.throwingGet()
} catch {
// e.g. the device is locked and the Keychain item is not accessible yet
}Custom keys built with ConfigKey(:get:set:remove:exists:onChange:) read through get in throwingGet; pass throwingGet: to ConfigKey(:get:throwingGet:set:remove:exists:onChange:) to surface their errors.
Available Stores
UserDefaults
.userDefaults // Standard UserDefaults
.userDefaults(suiteName: "group") // App group UserDefaultsKeychain (iOS/macOS)
.keychain // Basic keychain storage
.keychain(iCloudSync: true) // iCloud Keychain sync
.secureEnclave() // Secure Enclave with user presence
.biometricSecureEnclave() // Secure Enclave with biometrics
.passcodeSecureEnclave() // Secure Enclave with device passcodeiCloud Key-Value Store
.ubiquitous // Default iCloud key-value store
.ubiquitous(store: customUbiquitousStore) // Custom iCloud store instanceOther Stores
.environment // Environment variables (read-only)
.infoPlist // App bundle Info.plist (read-only)
.infoPlist(for: bundle) // Custom bundle Info.plist
.inMemory // In-memory storage
.inMemory(["key": "value"]) // In-memory with initial values
.multiple(store1, store2) // Multiplex multiple stores (fallback chain)Property Wrapper API
Use property wrappers for inline configuration management:
struct AppSettings {
// Using key path reference to predefined keys
@ROConfig(\.userID)
var userID: UUID
// Using category-based initialization (recommended)
@RWConfig("api-token", in: .secure)
var apiToken: String?
@RWConfig("user-preferences", in: .default)
var preferences = UserPreferences()
// Using store-based initialization (for specific store targeting)
@RWConfig("debug-mode", store: .inMemory)
var debugMode = false
}
let settings = AppSettings()
print(settings.userID) // Read value
settings.apiToken = "new-token" // Write value
settings.preferences.theme = .darkSwiftUI Property Wrappers
For SwiftUI views, use ROConfigState and RWConfigState property wrappers that automatically trigger view updates when configuration changes:
struct SettingsView: View {
// Read-only configuration with automatic view updates
@ROConfigState(\.userID)
var userID: UUID
// Read-write configuration with automatic view updates
@RWConfigState("theme", in: .default)
var theme = Theme.light
@RWConfigState("counter", in: .default)
var counter = 0
var body: some View {
VStack {
Text("User: \(userID)")
Picker("Theme", selection: $theme) {
Text("Light").tag(Theme.light)
Text("Dark").tag(Theme.dark)
}
Text("Count: \(counter)")
Button("Increment") {
counter += 1
}
}
}
}Namespaces
SwiftConfigs supports namespace-based organization of configuration keys, providing compile-time structure and type safety for logically related keys.
Basic Namespaces
Group related keys in namespace extensions of Configs.Keys:
public extension Configs.Keys {
var security: Security { Security() }
struct Security: ConfigNamespaceKeys {}
}
extension Configs.Keys.Security {
public var apiToken: RWConfigKey<String?> {
ConfigKey("api-token", in: .secure)
}
public var encryptionEnabled: ROConfigKey<Bool> {
ConfigKey("encryption-enabled", in: .secure, default: true)
}
}
// Usage - clean, organized access
let configs = Configs()
let apiToken = configs.security.apiToken
configs.security.encryptionEnabled = false
// Property wrapper usage
@RWConfig(\.security.apiToken) var token: String?
@ROConfigState(\.security.encryptionEnabled) var isEncryptionEnabled: BoolNested Namespaces
Create deeper hierarchies by nesting namespace types:
public extension Configs.Keys {
var features: Features { Features() }
struct Features: ConfigNamespaceKeys {
public var auth: Auth { Auth() }
public struct Auth: ConfigNamespaceKeys {}
}
}
extension Configs.Keys.Features.Auth {
public var biometricEnabled: RWConfigKey<Bool> {
ConfigKey("biometric-enabled", in: .default, default: false)
}
}
// Usage - deep namespace navigation
let biometricEnabled = configs.features.auth.biometricEnabled
configs.features.auth.biometricEnabled = trueKey Prefixing (Optional)
Namespaces are primarily for code organization. But if needed, you can add a keyPrefix to automatically prefix all keys in that namespace:
public extension Configs.Keys {
var environment: Environment { Environment() }
struct Environment: ConfigNamespaceKeys {
public var keyPrefix: String { "env/" } // Optional key prefixing
public var apiUrl: ROConfigKey<String> {
ConfigKey(qualify("api-url"), in: .environment, default: "localhost") // "env/api-url"
}
}
}Async/Await Support
let configs = Configs()
// Fetch latest values
try await configs.fetch()
// Fetch and get specific value
let token = try await configs.fetch(configs.apiToken)
// Fetch only if needed
let value = try await configs.fetchIfNeeded(configs.someKey)Listening for Changes
Callback-based Listening
let configs = Configs()
// Listen to all configuration changes
let cancellation = configs.onChange { configs in
print("Configurations updated")
}
// Listen to specific key changes
let keyCancellation = configs.onChange(\.apiToken) { newToken in
print("API token changed: \(newToken)")
}
// Cancel when done
cancellation.cancel()
keyCancellation.cancel()Async Sequence-based Listening
let configs = Configs()
// Listen to all configuration changes using async sequences
for await updatedConfigs in configs.changes() {
print("Configurations updated")
}
// Listen to specific key changes using async sequences
for await newToken in configs.changes(for: \.apiToken) {
print("API token changed: \(newToken)")
}
// Use in async context with cancellation
let task = Task {
for await newToken in configs.changes(for: \.apiToken) {
print("API token changed: \(newToken)")
// Break on specific condition
if newToken == "expected-token" {
break
}
}
}
// Cancel the task when needed
task.cancel()Combine Publisher Support
When Combine is available, configuration changes can also be used as Publishers:
import Combine
let configs = Configs()
var cancellables = Set<AnyCancellable>()
// Listen to configuration changes using Combine
configs.changes()
.sink { updatedConfigs in
print("Configurations updated")
}
.store(in: &cancellables)
// Listen to specific key changes using Combine
configs.changes(for: \.apiToken)
.sink { newToken in
print("API token changed: \(newToken)")
}
.store(in: &cancellables)
// Chain with other Combine operators
configs.changes(for: \.apiToken)
.compactMap { $0 }
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { debouncedToken in
print("Debounced API token: \(debouncedToken)")
}
.store(in: &cancellables)Value Transformers
SwiftConfigs automatically handles common types:
public extension Configs.Keys {
// String-convertible types
var count: ROConfigKey<Int> {
ConfigKey("count", in: .default, default: 0)
}
var rate: ROConfigKey<Double> {
ConfigKey("rate", in: .default, default: 1.0)
}
// Enum types
var theme: ROConfigKey<Theme> {
ConfigKey("theme", in: .default, default: .light)
}
// Codable types (stored as JSON)
var settings: ROConfigKey<AppSettings> {
ConfigKey("settings", in: .default, default: AppSettings())
}
// Optional types
var optionalValue: ROConfigKey<String?> {
ConfigKey("optional", in: .default)
}
// Using specific stores when needed
var tempSetting: RWConfigKey<String> {
ConfigKey("temp", store: .inMemory, default: "temp-value")
}
var secureToken: RWConfigKey<String?> {
ConfigKey("secure-token", store: .keychain)
}
}Configuration Migration
Handle configuration schema changes gracefully:
public extension Configs.Keys {
// Migrate from old boolean to new enum
var notificationStyle: ROConfigKey<NotificationStyle> {
ConfigKey("notification-style", in: .default, default: .none)
}
private var oldNotificationsEnabled: ROConfigKey<Bool> {
ConfigKey("notifications-enabled", in: .default, default: false)
}
// Custom migration using multiplex stores can be done at bootstrap level:
// ConfigSystem.bootstrap([
// .default: .multiple(.userDefaults, .inMemory) // Check multiple sources
// ])
}Custom Configuration Stores
Create custom storage backends by implementing the ConfigStore protocol:
import Foundation
struct MyCustomStore: ConfigStore {
var isWritable: Bool { true }
func fetch(completion: @escaping (Error?) -> Void) {
// Fetch latest values from your backend
completion(nil)
}
func onChange(_ listener: @escaping () -> Void) -> Cancellation {
// Set up change notifications
return Cancellation { /* cleanup */ }
}
func onChangeOfKey(_ key: String, _ listener: @escaping (String?) -> Void) -> Cancellation {
// Set up key-specific change notifications
return Cancellation { /* cleanup */ }
}
func get(_ key: String) throws -> String? {
// Retrieve value for key
return myDatabase.getValue(key)
}
func set(_ value: String?, for key: String) throws {
// Store value for key
if let value = value {
myDatabase.setValue(value, forKey: key)
} else {
myDatabase.removeValue(forKey: key)
}
}
func exists(_ key: String) throws -> Bool {
return myDatabase.hasValue(forKey: key)
}
func removeAll() throws {
myDatabase.clearAll()
}
func keys() -> Set<String>? {
return Set(myDatabase.allKeys())
}
}
// Use your custom store
ConfigSystem.bootstrap([
.default: MyCustomStore(),
.secure: .keychain
])Available Implementations
There is a ready-to-use ConfigStore implementation:
Firebase Remote Config
- Repository: swift-firebase-tools
- Features: Remote configuration management, A/B testing, real-time updates
- Use case: Server-controlled feature flags and configuration values
// Add to Package.swift
.package(url: "https://github.com/dankinsoid/swift-firebase-tools.git", from: "0.3.0")
// Usage
import FirebaseConfigs
ConfigSystem.bootstrap([
.default: .userDefaults,
.remote: .firebaseRemoteConfig
])Community Contributions
Want to add your own ConfigStore implementation? Consider contributing to the ecosystem by:
- Creating a separate package with your store
- Following the
ConfigStoreprotocol - Adding comprehensive tests and documentation
- Submitting your package for inclusion in this list
Installation
Swift Package Manager
Add SwiftConfigs to your Package.swift:
// swift-tools-version:5.7
import PackageDescription
let package = Package(
name: "YourProject",
dependencies: [
.package(url: "https://github.com/dankinsoid/swift-configs.git", from: "1.0.0")
],
targets: [
.target(name: "YourProject", dependencies: ["SwiftConfigs"])
]
)Or add it through Xcode:
- Go to File → Add Package Dependencies
- Enter:
https://github.com/dankinsoid/swift-configs.git - Choose the version and add to your target
Best Practices
- Define keys as computed properties in
Configs.Keysextensions for organization and discoverability - Use namespaces for organization - group related keys into
ConfigNamespaceKeystypes for compile-time structure - Use appropriate categories for different security and persistence needs
- Provide sensible defaults for all configuration keys
- Use read-only keys (
ROConfigKey) when values shouldn't be modified at runtime - Bootstrap the system early in your app lifecycle before accessing any configuration
- Prefer category-based initialization (
init(_:in:default:)) over store-based for most use cases - Use store-based initialization (
init(_:store:default:)) only when you need specific store targeting or before system bootstrap - Use prefixing sparingly - only add
keyPrefixwhen you need it; most namespaces work fine with the default empty prefix - Handle migration using multiplex stores or custom migration logic
- Use property wrappers for clean SwiftUI and declarative code integration
- Leverage async/await for remote configuration fetching
- Use change observation for reactive configuration updates
Security Considerations
- Use
.securecategory for sensitive data (API tokens, passwords) - uses Keychain encryption - Use
.criticalfor maximum security with hardware-backed Secure Enclave protection - Use
.syncedSecurecarefully - only for data that should be shared across devices via iCloud Keychain - Never log configuration values that might contain sensitive data
- Environment variables are read-only and visible to the entire process and system
- Keychain accessibility levels control when encrypted data can be accessed (device locked/unlocked)
- Biometric authentication adds an extra layer of security for critical configuration data
- iCloud sync (
.ubiquitous) has a 1MB total storage limit and is eventually consistent
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
SwiftConfigs is available under the MIT license. See the LICENSE file for more info.
Package Metadata
Repository: dankinsoid/swift-configs
Default branch: main
README: README.md