Building Custom Property Wrappers: Validation, Clamping, and Dependency Injection


SwiftUI’s @State, @Binding, and @AppStorage are all property wrappers — a compile-time transformation that wraps your stored property with custom get/set behavior, projected values, and lifecycle hooks. Once you understand the pattern, you can build wrappers that eliminate repetitive validation code, enforce invariants at declaration time, and plug a lightweight dependency injection system into any Swift type.

This post builds four production-quality wrappers: @Clamped for numeric ranges, @Validated for form input, @UserDefault for type-safe persistence, and @Injected for dependency resolution. We’ll also cover wrappedValue vs. projectedValue, the _propertyName backing storage, and thread safety implications. SwiftUI state management and generics are assumed knowledge.

Contents

The Problem

Form models accumulate validation logic fast. Here’s a typical Pixar film submission form — before property wrappers:

class PixarFilmFormViewModel: ObservableObject {
    @Published var title: String = "" {
        didSet {
            isTitleValid = !title.trimmingCharacters(in: .whitespaces).isEmpty
        }
    }

    @Published var isTitleValid: Bool = false

    @Published var rating: Int = 50 {
        didSet {
            // Clamp to valid range every time it changes
            if rating < 1 { rating = 1 }
            if rating > 100 { rating = 100 }
        }
    }

    @Published var synopsis: String = "" {
        didSet {
            isSynopsisValid = synopsis.count >= 20 && synopsis.count <= 500
        }
    }

    @Published var isSynopsisValid: Bool = false

    var hasSeenOnboarding: Bool {
        get { UserDefaults.standard.bool(forKey: "hasSeenOnboarding") }
        set { UserDefaults.standard.set(newValue, forKey: "hasSeenOnboarding") }
    }
}

Each property carries its own clamping or validation didSet, plus a shadow boolean to track validity. Add five more fields and the view model doubles in size with duplicated logic. Property wrappers let you declare the behavior once and apply it as an annotation.

@Clamped: Enforcing Numeric Ranges

@propertyWrapper types require one thing: a wrappedValue property. The compiler replaces every access to the annotated property with access to wrappedValue on the hidden backing storage instance.

@propertyWrapper
struct Clamped<Value: Comparable> {
    private var value: Value
    let range: ClosedRange<Value>

    init(wrappedValue: Value, range: ClosedRange<Value>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Value {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

The usage reads exactly as you’d hope:

struct PixarFilmRating {
    @Clamped(range: 1...100) var audienceScore: Int = 50
    @Clamped(range: 0.0...10.0) var criticScore: Double = 7.5
    @Clamped(range: 1...300) var runtimeMinutes: Int = 90
}

var toyStoryRating = PixarFilmRating()
toyStoryRating.audienceScore = 150  // Silently clamped to 100
toyStoryRating.audienceScore = -5   // Silently clamped to 1
print(toyStoryRating.audienceScore) // 1

The compiler synthesizes a backing property _audienceScore: Clamped<Int> and rewrites every read/write of audienceScore into _audienceScore.wrappedValue. You never see this in normal code, but you can access the wrapper instance directly via _audienceScore if you need to inspect range.

Warning: @Clamped silently discards out-of-range values. For user-facing forms, silent clamping is usually correct (a slider can’t exceed its range). For server-side business logic, silent clamping can hide bugs — prefer @Validated with an explicit error state instead.

@Validated: Form Input with Projected State

The projectedValue property — accessed via the $ prefix — lets a wrapper expose a second interface on the same property. @State uses this to expose a Binding; we’ll use it to expose a validation state struct.

struct ValidationState {
    let isValid: Bool
    let errorMessage: String?
}

@propertyWrapper
struct Validated<Value> {
    private var value: Value
    // Returns nil if valid, an error message if not
    private let validator: (Value) -> String?

    init(wrappedValue: Value, validator: @escaping (Value) -> String?) {
        self.value = wrappedValue
        self.validator = validator
    }

    var wrappedValue: Value {
        get { value }
        set { value = newValue }
    }

    // $filmTitle gives back a ValidationState
    var projectedValue: ValidationState {
        let errorMessage = validator(value)
        return ValidationState(isValid: errorMessage == nil, errorMessage: errorMessage)
    }
}

Now the form model becomes a set of declarations, not a tangle of didSet observers:

struct PixarFilmForm {
    @Validated(validator: { title in
        let trimmed = title.trimmingCharacters(in: .whitespaces)
        if trimmed.isEmpty { return "Title cannot be blank" }
        if trimmed.count > 120 { return "Title must be 120 characters or fewer" }
        return nil
    })
    var filmTitle: String = ""

    @Validated(validator: { synopsis in
        if synopsis.count < 20 { return "Synopsis must be at least 20 characters" }
        if synopsis.count > 500 { return "Synopsis must be 500 characters or fewer" }
        return nil
    })
    var synopsis: String = ""

    @Clamped(range: 1...100) var audienceScore: Int = 50

    var canSubmit: Bool {
        $filmTitle.isValid && $synopsis.isValid
    }
}

Access validation state through the $ prefix — the projectedValue in action:

var form = PixarFilmForm()
form.filmTitle = ""
print(form.$filmTitle.isValid)       // false
print(form.$filmTitle.errorMessage!) // "Title cannot be blank"

form.filmTitle = "Toy Story"
print(form.$filmTitle.isValid)       // true

In a SwiftUI view, pair @Validated with a separate @State or @Observable model. Do not stack @Validated directly on @Published — both define projectedValue, and stacking two wrappers that both expose $ is unsupported by the compiler:

@Observable
class FilmFormViewModel {
    var form = PixarFilmForm()
}

struct FilmSubmissionView: View {
    @State private var viewModel = FilmFormViewModel()

    var body: some View {
        Form {
            Section("Film Title") {
                TextField("e.g. Toy Story", text: $viewModel.form.filmTitle)
                if let error = viewModel.form.$filmTitle.errorMessage {
                    Text(error).foregroundStyle(.red).font(.caption)
                }
            }
            Button("Submit") { /* ... */ }
                .disabled(!viewModel.form.canSubmit)
        }
    }
}

Warning: Stacking multiple property wrappers on one property (@Validated @Published var filmTitle) looks tempting but does not work when both wrappers define projectedValue. The compiler only exposes the outermost $ projection, so $filmTitle would give ValidationState but you would lose the Published.Publisher. Keep @Validated on plain stored properties and handle reactivity at the model level.

@UserDefault: Type-Safe Persistence

UserDefaults has a stringly-typed, crash-prone API by default. A property wrapper gives you a fully type-safe interface:

@propertyWrapper
struct UserDefault<Value> {
    let key: String
    let defaultValue: Value
    private let store: UserDefaults

    init(key: String, defaultValue: Value, store: UserDefaults = .standard) {
        self.key = key
        self.defaultValue = defaultValue
        self.store = store
    }

    var wrappedValue: Value {
        get { store.object(forKey: key) as? Value ?? defaultValue }
        set { store.set(newValue, forKey: key) }
    }

    // Projected value exposes the key for use in KVO or reactive observation
    var projectedValue: String { key }
}

Usage in a settings model:

struct AppSettings {
    @UserDefault(key: "hasSeenPixarOnboarding", defaultValue: false)
    var hasSeenOnboarding: Bool

    @UserDefault(key: "preferredFilmSortOrder", defaultValue: "releaseYear")
    var sortOrder: String

    @UserDefault(key: "maxSearchResults", defaultValue: 20)
    var maxSearchResults: Int
}

var settings = AppSettings()
settings.hasSeenOnboarding = true
print(settings.$hasSeenOnboarding) // "hasSeenPixarOnboarding" — the key string

For Codable types, extend the wrapper to encode/decode through Data:

@propertyWrapper
struct CodableUserDefault<Value: Codable> {
    let key: String
    let defaultValue: Value
    private let store: UserDefaults

    init(key: String, defaultValue: Value, store: UserDefaults = .standard) {
        self.key = key
        self.defaultValue = defaultValue
        self.store = store
    }

    var wrappedValue: Value {
        get {
            guard let data = store.data(forKey: key),
                  let decoded = try? JSONDecoder().decode(Value.self, from: data)
            else { return defaultValue }
            return decoded
        }
        set {
            guard let encoded = try? JSONEncoder().encode(newValue) else { return }
            store.set(encoded, forKey: key)
        }
    }
}

struct FavoriteFilm: Codable {
    let title: String
    let year: Int
}

struct UserPreferences {
    @CodableUserDefault(
        key: "favoritePixarFilm",
        defaultValue: FavoriteFilm(title: "Coco", year: 2017)
    )
    var favoriteFilm: FavoriteFilm
}

Apple Docs: UserDefaults — Foundation

@Injected: Service Locator DI

A service locator pattern can be wrapped cleanly with a property wrapper. Register services once at app startup; resolve them at declaration time anywhere in the codebase.

// The service locator
final class ServiceLocator: @unchecked Sendable {
    static let shared = ServiceLocator()
    private var services: [ObjectIdentifier: Any] = [:]
    private let lock = NSLock()

    func register<T>(_ service: T) {
        lock.withLock {
            services[ObjectIdentifier(T.self)] = service
        }
    }

    func resolve<T>() -> T {
        lock.withLock {
            guard let service = services[ObjectIdentifier(T.self)] as? T else {
                fatalError(
                    "No service registered for \(T.self). " +
                    "Did you forget to register it at startup?"
                )
            }
            return service
        }
    }
}

@propertyWrapper
struct Injected<Service> {
    var wrappedValue: Service {
        ServiceLocator.shared.resolve()
    }
}

Register at the app entry point and inject anywhere:

@main
struct PixarCatalogApp: App {
    init() {
        ServiceLocator.shared.register(FilmRepository() as FilmRepositoryProtocol)
        ServiceLocator.shared.register(AnalyticsService() as AnalyticsServiceProtocol)
    }

    var body: some Scene { WindowGroup { ContentView() } }
}

// Any view model — no constructor injection required
final class FilmListViewModel: ObservableObject {
    @Injected private var repository: FilmRepositoryProtocol
    @Injected private var analytics: AnalyticsServiceProtocol

    func loadFilms() async {
        await repository.fetchAll()
        analytics.track(event: "film_list_loaded")
    }
}

Warning: The fatalError in resolve() is intentional — a missing registration is a programmer error, not a runtime condition to handle gracefully. Crash fast in debug; ensure your app startup tests cover all registrations before shipping.

Advanced Usage

Accessing Backing Storage in init

The compiler synthesizes _propertyName as the backing instance of your wrapper. Inside a type’s init, you must assign to _propertyName directly when the wrapper’s init takes parameters beyond wrappedValue, because the synthesized memberwise initializer doesn’t cover them:

struct PixarFilmFormViewModel {
    @Clamped(range: 1...100) var audienceScore: Int

    // The memberwise init won't work here — use the backing store directly
    init(initialScore: Int) {
        _audienceScore = Clamped(wrappedValue: initialScore, range: 1...100)
    }
}

Forgetting this is the most common property wrapper initialization error.

Thread Safety for @Clamped

The @Clamped wrapper above is not thread-safe. Concurrent reads and writes to value from multiple threads produce data races. If the wrapped property is accessed from background queues, add isolation:

@propertyWrapper
struct ClampedSendable<Value: Comparable & Sendable>: @unchecked Sendable {
    private var value: Value
    private let range: ClosedRange<Value>
    private let lock = NSLock()

    init(wrappedValue: Value, range: ClosedRange<Value>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Value {
        get { lock.withLock { value } }
        set {
            lock.withLock {
                value = min(max(newValue, range.lowerBound), range.upperBound)
            }
        }
    }
}

For types exclusively used on the main actor (view models, @MainActor classes), the plain version is fine — Swift’s actor isolation is your thread safety guarantee.

Nested Property Wrappers

Swift allows stacking up to one property wrapper that has a projectedValue and one that doesn’t. Stacking two wrappers with projected values causes a compiler error. Design your wrappers with this limit in mind — the @Validated @Published combination works because @Published’s $ projection provides the Binding, and @Validated’s would conflict. Choose one.

Performance Considerations

Property wrappers are zero-cost abstractions when the compiler inlines them. The wrappedValue getter/setter on a simple wrapper like @Clamped compiles down to the same machine code as writing value = max(min(newValue, upper), lower) directly.

The exceptions:

  • @UserDefault: Every wrappedValue access hits UserDefaults.standard.object(forKey:), a dictionary lookup — fast but not free. Don’t read it in tight loops or body computations.
  • @CodableUserDefault: Encodes/decodes via JSONEncoder/JSONDecoder on every write/read. Cache the decoded value in memory if you access it frequently.
  • @Injected: Performs a dictionary lookup and lock acquisition on every access. Store the resolved value in a local variable if you call it repeatedly inside a function.

When to Use (and When Not To)

ScenarioRecommendation
Enforcing invariants on a stored value (ranges, non-empty)Strong fit — declares the constraint at the property
Form validation with per-field error stateGood fit — @Validated keeps state co-located with the property
Type-safe UserDefaults keysGood fit — eliminates stringly-typed API across the codebase
SwiftUI @State/@Binding reactive storageDo not reinvent — use the built-in wrappers
Complex dependency graphs with circular dependenciesPrefer constructor injection or a dedicated DI framework
Async/concurrent propertiesPrefer actors or @MainActor over manual locking in wrappers
One-off behavior appearing in only one typePrefer a plain didSet — the wrapper abstraction isn’t worth it

Summary

  • Property wrappers are a compile-time transformation: the compiler synthesizes _propertyName as the backing wrapper instance and routes get/set through wrappedValue.
  • projectedValue (the $ prefix) exposes a secondary interface — use it for validation state, bindings, or metadata like the UserDefaults key string.
  • Always initialize wrappers that take extra parameters using _propertyName = WrapperType(wrappedValue:...) inside init.
  • Thread safety is your responsibility — for UI-only code, actor isolation is sufficient; for shared state, add a lock.
  • Property wrappers are zero-cost when inlined, but wrappers with I/O side effects (UserDefaults, JSONDecoder) have real costs at every access.

Custom property wrappers pair naturally with dependency injection patterns — once you have @Injected in place, the next step is managing the service lifecycle and scoping resolvers to feature modules.