Created
January 19, 2026 07:47
-
-
Save rozd/fa83b2c2884aab7b26289187ff6b2ef5 to your computer and use it in GitHub Desktop.
swiftui-formkit.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Lightweight Form Validation in SwiftUI: A Property Wrapper Approach That Actually Works | |
| Form validation in SwiftUI has always been a pain point. Most solutions either require you to wrap every input in custom controls, pollute your views with validation logic, or force you to use heavyweight third-party libraries. What if there was a simpler way? | |
| In this article, I'll walk you through a lightweight form validation approach that leverages Swift's property wrappers and SwiftUI's view modifiers to create a clean, type-safe, and extensible validation system. No custom controls required. | |
| ## The Problem with Existing Approaches | |
| Before diving into the solution, let's acknowledge why form validation in SwiftUI is challenging: | |
| 1. **SwiftUI's native controls don't support validation states** — `TextField`, `Picker`, and other controls have no built-in concept of "invalid" | |
| 2. **Validation logic tends to leak into views** — making them bloated and hard to test | |
| 3. **Most solutions require custom input controls** — forcing you to abandon SwiftUI's native components | |
| 4. **Type safety is often sacrificed** — many validation libraries only work with strings | |
| 5. **Remote validation (server-side) is an afterthought** — most approaches don't handle async validation well | |
| The approach I'm about to show solves all of these problems with surprisingly little code. | |
| ## The Core Idea: A `@Validate` Property Wrapper | |
| The foundation of this approach is a property wrapper called `@Validate` that wraps any `Equatable` value and tracks its validation state: | |
| ```swift | |
| @propertyWrapper | |
| struct Validate<T: Equatable> { | |
| enum State { | |
| case idle | |
| case editing | |
| case valid | |
| case invalid(messages: [String]) | |
| } | |
| private var value: T | |
| private let rules: [any ValidationRule<T>] | |
| let name: String? | |
| private(set) var state: State = .idle | |
| var wrappedValue: T { | |
| get { value } | |
| set { | |
| if newValue != value { | |
| state = .editing | |
| } | |
| value = newValue | |
| if case .invalid(_) = state { | |
| validate() | |
| } | |
| } | |
| } | |
| var projectedValue: State { | |
| state | |
| } | |
| init( | |
| wrappedValue: T, | |
| name: String? = nil, | |
| _ rules: any ValidationRule<T>... | |
| ) { | |
| self.value = wrappedValue | |
| self.rules = rules | |
| self.name = name | |
| } | |
| @discardableResult | |
| mutating func validate() -> Bool { | |
| var errors: [String] = [] | |
| for rule in rules { | |
| if let message = rule.validate(value: value) { | |
| errors.append(message) | |
| } | |
| } | |
| if errors.isEmpty { | |
| state = .valid | |
| return true | |
| } else { | |
| state = .invalid(messages: errors) | |
| return false | |
| } | |
| } | |
| } | |
| ``` | |
| Notice something elegant here: **the validation rules operate on the actual type `T`, not on strings**. This means you can validate `Price`, `Date`, `Email`, or any custom type directly. | |
| ## Validation Rules: Simple and Type-Safe | |
| Validation rules follow a simple protocol: | |
| ```swift | |
| protocol ValidationRule<Value> { | |
| associatedtype Value | |
| func validate(value: Value) -> String? | |
| } | |
| ``` | |
| If validation passes, return `nil`. If it fails, return an error message. That's it. | |
| Here's an example for string validation: | |
| ```swift | |
| struct NotEmptyStringRule: ValidationRule { | |
| let message: String | |
| func validate(value: String) -> String? { | |
| if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| return message | |
| } | |
| return nil | |
| } | |
| } | |
| extension ValidationRule where Self == NotEmptyStringRule { | |
| static func isNotEmpty(message: String) -> NotEmptyStringRule { | |
| NotEmptyStringRule(message: message) | |
| } | |
| } | |
| ``` | |
| The static extension provides a nice DSL for declaring rules inline: | |
| ```swift | |
| @Validate(name: "email", .isNotEmpty(message: "Email is required"), .email()) | |
| var email: String = "" | |
| ``` | |
| ## Validating Custom Types, Not Just Strings | |
| Here's where this approach really shines. Let's say you have a `Price` value object: | |
| ```swift | |
| struct Price: Equatable { | |
| let amount: Decimal | |
| let currency: Currency | |
| } | |
| ``` | |
| You can create a validation rule specifically for `Price?`: | |
| ```swift | |
| struct RequiredPriceRule: ValidationRule { | |
| let message: String | |
| func validate(value: Price?) -> String? { | |
| guard value != nil else { | |
| return message | |
| } | |
| return nil | |
| } | |
| } | |
| extension ValidationRule where Self == RequiredPriceRule { | |
| static func required(message: String) -> RequiredPriceRule { | |
| RequiredPriceRule(message: message) | |
| } | |
| } | |
| ``` | |
| Now you can use it in your form: | |
| ```swift | |
| @Validate(name: "price", .required(message: "Price is required")) | |
| var price: Price? | |
| ``` | |
| The same pattern works for any custom type — `ValidityPeriod`, `PhoneNumber`, `Address`, you name it. **Your validation logic is type-safe and operates on domain objects, not string representations**. | |
| ## The Form: A Simple Struct | |
| Forms are plain Swift structs that conform to `ValidatableForm`: | |
| ```swift | |
| protocol ValidatableForm { | |
| var validates: [ValidateAccessor<Self>] { get } | |
| } | |
| ``` | |
| Here's a real-world example: | |
| ```swift | |
| struct UpdatePlanForm: ValidatableForm, SubmittableForm { | |
| @Validate(name: "name", .isNotEmpty(message: "Name cannot be empty")) | |
| var name: LocalizedString = .empty | |
| @Validate(name: "duration", .required(message: "Duration is required")) | |
| var duration: ValidityPeriod? = nil | |
| @Validate(name: "workouts", .required(message: "Workouts is required")) | |
| var workouts: Plan.WorkoutAllowance? = nil | |
| @Validate(name: "price", .required(message: "Price is required")) | |
| var price: Price? | |
| var description: LocalizedString = .empty // No validation needed | |
| var validates: [ValidateAccessor<Self>] { | |
| [ | |
| ValidateAccessor(\._name), | |
| ValidateAccessor(\._duration), | |
| ValidateAccessor(\._price), | |
| ValidateAccessor(\._workouts), | |
| ] | |
| } | |
| func submit() async throws -> Void { | |
| // Submit to your backend | |
| try await api.updatePlan(...) | |
| } | |
| } | |
| ``` | |
| The `validates` array lists all fields that should be validated when the form is submitted. The `ValidateAccessor` uses key paths to the underlying property wrapper storage (note the underscore prefix). | |
| ## The Controller: Managing Form State | |
| The `FormController` is an `@Observable` class that wraps your form and manages its lifecycle: | |
| ```swift | |
| @Observable | |
| final class FormController<T> { | |
| enum State { | |
| case initial | |
| case loading | |
| case success | |
| case failure(Error) | |
| } | |
| var form: T | |
| private(set) var state: State = .initial | |
| init(form: T) { | |
| self.form = form | |
| } | |
| } | |
| ``` | |
| For forms that are both validatable and submittable, the controller provides key functionality: | |
| ```swift | |
| extension FormController where T: SubmittableForm, T: ValidatableForm { | |
| var isLoading: Bool { | |
| if case .loading = state { return true } | |
| return false | |
| } | |
| func submit() async throws -> T.Output { | |
| self.validate() | |
| if !form.isValid { | |
| throw ValidationError.invalid(errors: form.validationErrors) | |
| } | |
| state = .loading | |
| do { | |
| let output = try await form.submit() | |
| state = .success | |
| return output | |
| } catch { | |
| state = .failure(error) | |
| // Handle remote validation errors | |
| guard case .invalid(let errors) = (error as? ValidationError) else { | |
| throw error | |
| } | |
| // Map server errors back to individual fields | |
| for accessor in form.validates { | |
| if let property = accessor.name(form), | |
| let messages = errors[property] { | |
| accessor.markAsInvalid(&form, messages) | |
| } | |
| } | |
| throw error | |
| } | |
| } | |
| } | |
| ``` | |
| This is where remote validation happens. If your server returns validation errors, they're automatically mapped back to individual fields using the `name` property you specified in `@Validate`. | |
| ## The View: Native Controls with a Validation Modifier | |
| Here's the magic: **you use completely standard SwiftUI controls**. No custom `ValidatedTextField` or `FormField` wrappers needed: | |
| ```swift | |
| struct UpdatePlanView: View { | |
| @State private var controller = FormController(form: UpdatePlanForm()) | |
| var body: some View { | |
| NavigationStack { | |
| Form { | |
| Section { | |
| TextField("Name", text: $controller.form.name) | |
| .disabled(controller.isLoading) | |
| .validator(state: controller.form.$name) | |
| ValidityPeriodPicker( | |
| "Duration", | |
| period: $controller.form.duration, | |
| units: [.day, .weekOfMonth, .month] | |
| ) | |
| .disabled(controller.isLoading) | |
| .validator(state: controller.form.$duration) | |
| Picker("Workouts", selection: $controller.form.workouts) { | |
| ForEach(workoutsOptions) { option in | |
| Text(option.displayName).tag(option) | |
| } | |
| } | |
| .disabled(controller.isLoading) | |
| .validator(state: controller.form.$workouts) | |
| PriceField("Price", price: $controller.form.price) | |
| .disabled(controller.isLoading) | |
| .validator(state: controller.form.$price) | |
| } header: { | |
| Text("Plan Details") | |
| } | |
| } | |
| .navigationTitle("Update Plan") | |
| .formToolbar( | |
| controller: controller, | |
| preventsAccidentalDismiss: true, | |
| onSubmit: { | |
| Task { try? await controller.submit() } | |
| } | |
| ) | |
| } | |
| } | |
| } | |
| ``` | |
| The `.validator(state:)` modifier takes the projected value (`$name`, `$duration`, etc.) which gives access to the validation state: | |
| ```swift | |
| struct ValidatorViewModifier<T: Equatable>: ViewModifier { | |
| let state: Validate<T>.State | |
| func body(content: Content) -> some View { | |
| VStack(alignment: .leading, spacing: 4) { | |
| content | |
| if case let .invalid(messages) = state { | |
| ForEach(messages, id: \.self) { message in | |
| Text(message) | |
| .foregroundStyle(.red) | |
| .font(.caption) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| extension View { | |
| func validator<T: Equatable>(state: Validate<T>.State) -> some View { | |
| modifier(ValidatorViewModifier<T>(state: state)) | |
| } | |
| } | |
| ``` | |
| ## Automatic Error Reset on Input | |
| One subtle but important UX detail: **errors automatically clear when the user starts typing**. This happens in the property wrapper's setter: | |
| ```swift | |
| var wrappedValue: T { | |
| get { value } | |
| set { | |
| if newValue != value { | |
| state = .editing // Clears any previous error state | |
| } | |
| value = newValue | |
| if case .invalid(_) = state { | |
| validate() // Re-validate if was invalid | |
| } | |
| } | |
| } | |
| ``` | |
| When the value changes, the state transitions to `.editing`. If the field was previously invalid and the user makes a change, it re-validates immediately — giving instant feedback as they correct the error. | |
| ## Form Toolbar: Loading States and Accidental Dismiss Prevention | |
| The `formToolbar` modifier handles common form UX patterns: | |
| ```swift | |
| struct FormToolbarViewModifier<T: ValidatableForm & SubmittableForm>: ViewModifier { | |
| @Environment(\.dismiss) private var dismiss | |
| @State private var showsDiscardWarning: Bool = false | |
| let controller: FormController<T> | |
| let preventsAccidentalDismiss: Bool | |
| let onSubmit: (() -> Void) | |
| func body(content: Content) -> some View { | |
| content | |
| .toolbar { | |
| ToolbarItem(placement: .cancellationAction) { | |
| Button("Cancel") { | |
| if preventsAccidentalDismiss && controller.isDirty { | |
| showsDiscardWarning = true | |
| } else { | |
| dismiss() | |
| } | |
| } | |
| } | |
| ToolbarItem(placement: .confirmationAction) { | |
| Button("Submit", action: onSubmit) | |
| .bold() | |
| .disabled(!controller.isDirty || controller.isLoading) | |
| } | |
| } | |
| .interactiveDismissDisabled( | |
| preventsAccidentalDismiss && controller.isDirty | |
| ) | |
| .confirmationDialog("Discard Changes?", isPresented: $showsDiscardWarning) { | |
| Button("Discard Changes", role: .destructive) { dismiss() } | |
| Button("Keep Editing", role: .cancel) { } | |
| } | |
| } | |
| } | |
| ``` | |
| Key behaviors: | |
| - **Submit button is disabled** when form is pristine or loading | |
| - **Swipe-to-dismiss is blocked** when there are unsaved changes | |
| - **Confirmation dialog appears** when trying to cancel with unsaved changes | |
| - **All fields can be disabled** during submission via `controller.isLoading` | |
| ## Summary: What Makes This Approach Work | |
| Let's recap the key features: | |
| | Feature | How It's Achieved | | |
| |---------|-------------------| | |
| | **Lightweight** | ~300 lines of code total, no external dependencies | | |
| | **No custom controls** | Standard SwiftUI controls + a view modifier | | |
| | **Local validation** | Each field carries its own rules via `@Validate` | | |
| | **Type-safe validation** | Rules operate on actual types, not strings | | |
| | **Remote validation** | Server errors mapped back to fields by name | | |
| | **Error reset on input** | Property wrapper setter handles state transitions | | |
| | **Loading state** | `controller.isLoading` disables controls during submit | | |
| | **UI indication** | `.validator(state:)` modifier shows error messages | | |
| ## The Complete Architecture | |
| ``` | |
| ┌─────────────────────────────────────────────────────────┐ | |
| │ SwiftUI View │ | |
| │ ┌─────────────────────────────────────────────────┐ │ | |
| │ │ TextField / Picker / Custom Control │ │ | |
| │ │ .disabled(controller.isLoading) │ │ | |
| │ │ .validator(state: controller.form.$field) │ │ | |
| │ └─────────────────────────────────────────────────┘ │ | |
| └───────────────────────────┬─────────────────────────────┘ | |
| │ | |
| ▼ | |
| ┌─────────────────────────────────────────────────────────┐ | |
| │ FormController<T> │ | |
| │ - state: .initial | .loading | .success | .failure │ | |
| │ - isDirty, isLoading │ | |
| │ - validate(), submit() │ | |
| └───────────────────────────┬─────────────────────────────┘ | |
| │ | |
| ▼ | |
| ┌─────────────────────────────────────────────────────────┐ | |
| │ Form Struct (ValidatableForm) │ | |
| │ ┌─────────────────────────────────────────────────┐ │ | |
| │ │ @Validate(name: "field", .rule(...)) │ │ | |
| │ │ var field: SomeType │ │ | |
| │ │ - state: .idle | .editing | .valid | .invalid│ │ | |
| │ │ - validate() -> Bool │ │ | |
| │ └─────────────────────────────────────────────────┘ │ | |
| └───────────────────────────┬─────────────────────────────┘ | |
| │ | |
| ▼ | |
| ┌─────────────────────────────────────────────────────────┐ | |
| │ ValidationRule<T> │ | |
| │ func validate(value: T) -> String? │ | |
| │ - NotEmptyStringRule │ | |
| │ - RequiredPriceRule │ | |
| │ - EmailValidator │ | |
| │ - Your custom rules... │ | |
| └─────────────────────────────────────────────────────────┘ | |
| ``` | |
| ## Extending the System | |
| Adding new validation rules is trivial. Want to validate that a number is within a range? | |
| ```swift | |
| struct RangeRule<T: Comparable>: ValidationRule { | |
| let range: ClosedRange<T> | |
| let message: String | |
| func validate(value: T) -> String? { | |
| range.contains(value) ? nil : message | |
| } | |
| } | |
| extension ValidationRule { | |
| static func inRange<T: Comparable>( | |
| _ range: ClosedRange<T>, | |
| message: String | |
| ) -> RangeRule<T> where Self == RangeRule<T> { | |
| RangeRule(range: range, message: message) | |
| } | |
| } | |
| // Usage: | |
| @Validate(name: "age", .inRange(18...120, message: "Age must be 18-120")) | |
| var age: Int = 0 | |
| ``` | |
| ## Conclusion | |
| Form validation doesn't have to be complicated. By leveraging Swift's property wrappers, protocol-oriented design, and SwiftUI's view modifiers, we've created a system that: | |
| - Works with any SwiftUI control out of the box | |
| - Keeps validation logic separate from UI code | |
| - Handles both local and remote validation elegantly | |
| - Provides proper loading and dirty state management | |
| - Is fully type-safe and extensible | |
| The entire implementation is under 300 lines of code with zero external dependencies. Sometimes the best solution is the simplest one. | |
| --- | |
| *The code examples in this article are from a production iOS app. Feel free to adapt this pattern for your own projects.* |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment