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
@Clamped: Enforcing Numeric Ranges@Validated: Form Input with Projected State@UserDefault: Type-Safe Persistence@Injected: Service Locator DI- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
@Clampedsilently 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@Validatedwith 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 defineprojectedValue. The compiler only exposes the outermost$projection, so$filmTitlewould giveValidationStatebut you would lose thePublished.Publisher. Keep@Validatedon 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
fatalErrorinresolve()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: EverywrappedValueaccess hitsUserDefaults.standard.object(forKey:), a dictionary lookup — fast but not free. Don’t read it in tight loops orbodycomputations.@CodableUserDefault: Encodes/decodes viaJSONEncoder/JSONDecoderon 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)
| Scenario | Recommendation |
|---|---|
| Enforcing invariants on a stored value (ranges, non-empty) | Strong fit — declares the constraint at the property |
| Form validation with per-field error state | Good fit — @Validated keeps state co-located with the property |
Type-safe UserDefaults keys | Good fit — eliminates stringly-typed API across the codebase |
SwiftUI @State/@Binding reactive storage | Do not reinvent — use the built-in wrappers |
| Complex dependency graphs with circular dependencies | Prefer constructor injection or a dedicated DI framework |
| Async/concurrent properties | Prefer actors or @MainActor over manual locking in wrappers |
| One-off behavior appearing in only one type | Prefer a plain didSet — the wrapper abstraction isn’t worth it |
Summary
- Property wrappers are a compile-time transformation: the compiler synthesizes
_propertyNameas the backing wrapper instance and routes get/set throughwrappedValue. projectedValue(the$prefix) exposes a secondary interface — use it for validation state, bindings, or metadata like theUserDefaultskey string.- Always initialize wrappers that take extra parameters using
_propertyName = WrapperType(wrappedValue:...)insideinit. - 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.