Skip to content

Instantly share code, notes, and snippets.

@rozd
Created January 19, 2026 07:47
Show Gist options
  • Select an option

  • Save rozd/fa83b2c2884aab7b26289187ff6b2ef5 to your computer and use it in GitHub Desktop.

Select an option

Save rozd/fa83b2c2884aab7b26289187ff6b2ef5 to your computer and use it in GitHub Desktop.
swiftui-formkit.md
# 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