Adding Sendable conformance types from other libraries

While migrating to Swift 6 I am facing warnings regarding Sendable conformance around types like UserDefaults, KeyPath. While I can use @unchecked Sendable to silence these warnings, but I lose the compiler Sendable checking.

I was thinking of adding @unchecked Sendable conformance to these types instead:

extension UserDefaults: @retroactive @unchecked Sendable {}

I am planning to distribute my code as binary XCFrameworks. Will there be any issue if any future version of OS adds Sendable conformance? What would happen if the type I have marked as Sendable is marked as non-Sendable in future version?

Sendable is a marker protocol, which means that it doesn't change any behavior at runtime. If Sendable conformance is added to a later SDK version, or made explicitly unavailable, this will only affect the build process in future versions, not existing binaries.

As for the types you want to conform, UserDefaults is documented to be thread-safe and might gain Sendable conformance in the future. This will cause a warning to appear in your code, suggesting to remove your retroactive conformance.

KeyPath is not thread-safe in certain conditions, while it is fine in other cases. Can you tell us what you're using key paths for?

1 Like

I’m pretty sure it won’t. See my posts on this forums thread (which, ironically, links back to a Swift Forums thread).

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

Retroactive conformances to protocols you don't own on types you don't own are, at best, a code smell, and at worst will introduce serious bugs into your code.

Since you own neither Sendable nor UserDefaults, you'll likely just be introducing hard-to-fix concurrency bugs into your code if you do this. If you then distribute your framework, this conformance will be visible to components that link to it, and they will also be at risk of concurrency bugs.

It would help to understand what you're trying to accomplish here. Do you just need to consult UserDefaults from a background task? There are ways to do that which don't require retroactive conformances.

3 Likes

I was under the same impression as well, hence wanted to conform. Seems like this is not the case.

While I can't share the exact code, I am migrating a declarative interface we have currently for UserDefaults with multiple processors that handle key functionality. The callers can pick and choose the processors to have desired data format and location. In this I am passing UserDefaults instance inside a @Sendable closure that is causing warnings:

//
//  UserDefaultStorage+Processor.swift
//
//  A minimal, self-contained sample of a processor pipeline over `UserDefaults`.
//  Demonstrates the protocol, the pipeline executor, and two concrete processors:
//  an app-group suite router and a transparent AES-GCM encryption layer.
//

import Foundation
import CryptoKit

/// The actor that owns all storage access. In the real codebase this is a
/// per-instance actor; here it is a minimal stand-in so the sample compiles.
actor StorageInstance {
    /// The app-group identifier this instance is configured with, if any.
    let appGroupID: String?

    init(appGroupID: String? = nil) {
        self.appGroupID = appGroupID
    }
}

// MARK: - UserDefaultStorage

/// A typed `UserDefaults`-backed storage location with an ordered pipeline of
/// processors that can intercept and transform its get / set / dictionary operations.
struct UserDefaultStorage<Value> {

    /// The suite scope of a storage location (e.g. standard vs. app-group suite).
    enum Scope: Sendable {
        case standard
        case suite(name: String)
    }

    let scope: Scope
    let processors: [any UserDefaultProcessor<Value>]

    init(scope: Scope = .standard, processors: [any UserDefaultProcessor<Value>] = []) {
        self.scope = scope
        self.processors = processors
    }
}

// MARK: - Pipeline execution

extension UserDefaultStorage {

    /// The base `UserDefaults` resolved from ``scope``.
    private func baseDefaults() -> UserDefaults {
        switch scope {
        case .standard:
            return .standard
        case .suite(let name):
            return UserDefaults(suiteName: name) ?? .standard
        }
    }

    /// Folds every processor over the base actions, then runs the requested operation.
    ///
    /// Each processor receives the actions accumulated so far and may replace them.
    /// `.skip` leaves the accumulated actions untouched.
    private func resolvedActions(
        instance: isolated StorageInstance
    ) throws -> (
        get: @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
        set: @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
        dictionary: @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any]
    ) {
        // Base actions: plain UserDefaults access.
        var get: @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any? = { _, ud, key in
            ud.object(forKey: key)
        }
        var set: @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void = { _, ud, key, value in
            if let value { ud.set(value, forKey: key) } else { ud.removeObject(forKey: key) }
        }
        var dictionary: @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any] = { _, ud in
            ud.dictionaryRepresentation()
        }

        for processor in processors {
            let result = try processor.process(
                get: get, set: set, dictionary: dictionary,
                scope: scope, instance: instance
            )
            switch result {
            case .skip:
                continue
            case .transformed(let newGet, let newSet, let newDictionary):
                get = newGet
                set = newSet
                dictionary = newDictionary
            }
        }
        return (get, set, dictionary)
    }

    /// Reads the value stored under `key`, running it through the full processor pipeline.
    func read(forKey key: String, instance: isolated StorageInstance) throws -> Any? {
        let actions = try resolvedActions(instance: instance)
        return try actions.get(instance, baseDefaults(), key)
    }

    /// Writes `value` under `key`, running it through the full processor pipeline.
    func write(_ value: Any?, forKey key: String, instance: isolated StorageInstance) throws {
        let actions = try resolvedActions(instance: instance)
        try actions.set(instance, baseDefaults(), key, value)
    }

    /// Returns the full dictionary representation, running it through the pipeline.
    func dictionaryRepresentation(instance: isolated StorageInstance) throws -> [String: Any] {
        let actions = try resolvedActions(instance: instance)
        return try actions.dictionary(instance, baseDefaults())
    }
}

// MARK: - UserDefaultProcessor

/// A type that can intercept and transform `UserDefaults` get, set, and
/// dictionary-representation operations performed by a ``UserDefaultStorage``.
///
/// Processors are applied in the order they appear in
/// ``UserDefaultStorage/processors``. Each call receives the three action closures
/// accumulated from all prior processors and returns either `.skip` — leaving every
/// action unchanged — or `.transformed` with replacement closures for all subsequent
/// processors and the final execution.
///
/// Each action closure receives the `isolated StorageInstance` as its first argument
/// so processors and closures have direct, isolation-safe access to instance state.
///
/// The associated type `Value` matches the `Value` of the ``UserDefaultStorage``
/// the processor is attached to.
protocol UserDefaultProcessor<Value>: Hashable, Sendable {
    associatedtype Value

    /// Processes all `UserDefaults` actions for an operation cycle.
    ///
    /// - Parameters:
    ///   - get: The current read closure.
    ///   - set: The current write closure.
    ///   - dictionary: The current dictionary-read closure.
    ///   - scope: The suite scope of the ``UserDefaultStorage`` issuing the operation.
    ///   - instance: The isolated instance in whose context the operation occurs.
    /// - Returns: `.skip` to leave all current actions unchanged, or `.transformed`
    ///   with replacement closures for all subsequent processors and the final execution.
    /// - Throws: Any error the processor encounters.
    func process(
        get: @escaping @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
        set: @escaping @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
        dictionary: @escaping @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any],
        scope: UserDefaultStorage<Value>.Scope,
        instance: isolated StorageInstance
    ) throws -> UserDefaultStorage<Value>.ProcessorResult
}

// MARK: - ProcessorResult

extension UserDefaultStorage {

    /// The return type of ``UserDefaultProcessor/process(get:set:dictionary:scope:instance:)``.
    ///
    /// - `.skip`: Leave the current actions unchanged.
    /// - `.transformed`: Replace all three action closures for all subsequent processors
    ///   and the final execution. Each closure receives the `isolated StorageInstance`
    ///   as its first argument. The value written during a set operation is passed
    ///   directly as the `Any?` parameter of the `set` closure.
    enum ProcessorResult: Sendable {
        /// Leave all current actions unchanged.
        case skip

        /// Replace all current actions with the provided closures.
        case transformed(
            get: @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
            set: @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
            dictionary: @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any]
        )
    }
}

// MARK: - AppGroupProcessor

/// Routes every operation to the instance's app-group `UserDefaults` suite when one
/// is configured, so values are visible to app extensions. If no app group is set,
/// the processor skips and leaves the standard suite in place.
struct AppGroupProcessor<Value>: UserDefaultProcessor {

    func process(
        get: @escaping @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
        set: @escaping @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
        dictionary: @escaping @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any],
        scope: UserDefaultStorage<Value>.Scope,
        instance: isolated StorageInstance
    ) throws -> UserDefaultStorage<Value>.ProcessorResult {
        // Only redirect when the instance actually has an app group configured.
        guard let appGroupID = instance.appGroupID,
              let suite = UserDefaults(suiteName: appGroupID) else {
            return .skip
        }

        // Replace the `UserDefaults` each downstream action receives with the suite.
        return .transformed(
            get: { instance, _, key in try get(instance, suite, key) },
            set: { instance, _, key, value in try set(instance, suite, key, value) },
            dictionary: { instance, _ in try dictionary(instance, suite) }
        )
    }
}

// MARK: - EncryptionProcessor

/// Transparently encrypts values on write and decrypts them on read using AES-GCM.
///
/// Stored ciphertext is a base64 string of the GCM combined representation
/// (nonce ‖ ciphertext ‖ tag). On read, values that fail to decrypt are returned
/// as-is, which lets plaintext written before encryption was enabled migrate lazily.
struct EncryptionProcessor<Value>: UserDefaultProcessor {

    /// The symmetric key as raw bytes, so the processor stays `Hashable`/`Sendable`.
    /// In production this is derived/loaded from the Keychain, never hard-coded.
    let keyData: Data

    private var key: SymmetricKey { SymmetricKey(data: keyData) }

    func process(
        get: @escaping @Sendable (isolated StorageInstance, UserDefaults, String) throws -> Any?,
        set: @escaping @Sendable (isolated StorageInstance, UserDefaults, String, Any?) throws -> Void,
        dictionary: @escaping @Sendable (isolated StorageInstance, UserDefaults) throws -> [String: Any],
        scope: UserDefaultStorage<Value>.Scope,
        instance: isolated StorageInstance
    ) throws -> UserDefaultStorage<Value>.ProcessorResult {
        let key = self.key

        return .transformed(
            get: { instance, ud, key2 in
                guard let stored = try get(instance, ud, key2) else { return nil }
                // Only attempt to decrypt base64 strings; pass everything else through.
                guard let base64 = stored as? String,
                      let decrypted = Self.decrypt(base64, using: key) else {
                    return stored
                }
                return decrypted
            },
            set: { instance, ud, key2, value in
                guard let value else {
                    try set(instance, ud, key2, nil)
                    return
                }
                // Encrypt string/data payloads; store the base64 ciphertext.
                if let encrypted = Self.encrypt(value, using: key) {
                    try set(instance, ud, key2, encrypted)
                } else {
                    try set(instance, ud, key2, value)
                }
            },
            dictionary: dictionary
        )
    }

    // MARK: Crypto helpers

    private static func encrypt(_ value: Any, using key: SymmetricKey) -> String? {
        let plaintext: Data
        if let string = value as? String {
            plaintext = Data(string.utf8)
        } else if let data = value as? Data {
            plaintext = data
        } else {
            return nil // non-encryptable scalar (Bool/Int/…): leave to the base layer
        }
        guard let sealed = try? AES.GCM.seal(plaintext, using: key),
              let combined = sealed.combined else { return nil }
        return combined.base64EncodedString()
    }

    private static func decrypt(_ base64: String, using key: SymmetricKey) -> String? {
        guard let combined = Data(base64Encoded: base64),
              let box = try? AES.GCM.SealedBox(combined: combined),
              let plaintext = try? AES.GCM.open(box, using: key) else { return nil }
        return String(decoding: plaintext, as: UTF8.self)
    }
}

// MARK: - Usage

/*
 let key = SymmetricKey(size: .bits256)
 let storage = UserDefaultStorage<String>(
     scope: .standard,
     processors: [
         AppGroupProcessor<String>(),                                    // route to app-group suite
         EncryptionProcessor<String>(keyData: key.withUnsafeBytes(Data.init)) // then encrypt
     ]
 )

 let instance = StorageInstance(appGroupID: "group.com.example.app")
 try await instance.run {
     try storage.write("secret-token", forKey: "auth", instance: $0)
     let token = try storage.read(forKey: "auth", instance: $0) as? String
     print(token ?? "nil") // "secret-token" — decrypted from the app-group suite
 }
 */

The first question that jumps out to me is: If you have an actor managing access, why doesn't the actor own the UserDefaults object?

1 Like

Because the UserDefaults object might itself change based on the processor. There is an AppGroupProcessor in the snippet which overrides the UserDefaults instance.

As in the stored instance of UserDefaults might be set to something different? That sounds like the exact sort of use case that actors are meant to help with.

The actor instance never stores the UserDefaults in my use-case (UserDefaults is never stored in my original code), the UserDefaults instance is created every time any data is accessed (since in my code base I store only 2 flags that is rarely used).

@grynspan @x-sheep Can you share why UserDefaults need to be stored in an actor to be accessed concurrently? The Apple doc mentions UserDefaults is thread safe, is the doc incorrect?

UserDefaults may be thread-safe, but it is an open class so it cannot be explicitly marked as Sendable. The way around this is not to add your own conformance, but either to use instances synchronously or to store them with nonisolated(unsafe) and not use any thread-unsafe subclasses.

It sounds like you're already using them synchronously though within a single actor isolation context, so frankly there should be no need for further changes?

1 Like