Modular Form Validation in SwiftUI


Greetings, traveler!

When working with forms in SwiftUI, validation logic can quickly become tangled—especially as the number of fields grows. In this article, I’ll walk you through a demo implementation that focuses on clean, modular validation for text fields, using a flexible architecture that scales well.

Although the code also supports debounced validation and custom focus handling, we won’t dive into those here. If you’re curious, I’ve broken those topics down in separate posts:
👉 Custom Focus Handling
👉 Debounced Input in SwiftUI

Let’s focus on what matters in this demo: validation.

Centralized Validation Logic

All validation rules are collected in a single struct named Validator, making it easy to define and reuse logic:

struct Validator {
    static func email(_ email: String) -> ValidationResult {
        email.isValidEmail ? .success : .failure(message: "Invalid Email")
    }

    static func nonEmpty(_ string: String) -> ValidationResult {
        !string.isEmpty ? .success : .failure(message: "This field must not be empty")
    }
}

The result of each validation is encapsulated in a ValidationResult struct:

struct ValidationResult: Equatable {
    let isValid: Bool
    let failureMessage: String

    static let success = Self(isValid: true, failureMessage: "")
    static func failure(message: String) -> Self {
        .init(isValid: false, failureMessage: message)
    }
}

This approach keeps results structured and makes UI updates predictable and easy to manage.

Field Abstraction and Rules

The Field enum defines all available inputs. Each case provides its own validation rule:

enum Field: String, CaseIterable, Identifiable {
    case email
    case regular

    var id: String { rawValue }

    var validationRule: (String) -> ValidationResult {
        switch self {
        case .email: Validator.email
        case .regular: Validator.nonEmpty
        }
    }

    var placeholder: String {
        switch self {
        case .email: "Enter email"
        case .regular: "Enter some text"
        }
    }
}

This abstraction makes it easy to dynamically generate fields, attach rules, and present UI labels—all in one place.

Binding Form State to Field Definitions

Form state is stored using @State properties. Each field’s value and validation result is bound using helper methods that map the field enum to the correct property:

private func text(for field: Field) -> Binding<String> {
    switch field {
    case .email: $email
    case .regular: $someText
    }
}

Validation results are stored in a dictionary, initialized with all fields set to .success.

Reusable Validation Modifier

A key part of the architecture is the .validate() view modifier. It’s applied to each TextField, and handles:

  • When validation is triggered (onChange, onSubmit, onFocusLost)
  • Optional debounce interval
  • Updating results via a callback
extension View {
    func validate<Field: Equatable>(
        _ field: Field,
        with text: Binding<String>,
        rule: @escaping (String) -> ValidationResult,
        options: ValidationOptions,
        debounce: Duration?,
        focusedField: Binding<Field?>,
        onValidation: @escaping (ValidationResult) -> Void
    ) -> some View {
    
        modifier(
            TextFieldValidator(
                text: text,
                focusedField: focusedField,
                validationRule: rule,
                options: options,
                debounce: debounce,
                currentField: field,
                onValidation: onValidation
            )
        )
    }
}

This allows each field to define its own validation trigger and behavior, without duplicating code.

To make the validation logic truly modular and adaptable, this demo introduces a custom OptionSet called ValidationOptions. It allows each text field to specify when exactly validation should occur — whether on submit, on text change, on focus change, or any combination of these.

Here’s how it’s defined:

struct ValidationOptions: OptionSet {
    let rawValue: Int

    static let onSubmit = ValidationOptions(rawValue: 1 << 0)
    static let onChange = ValidationOptions(rawValue: 1 << 1)
    static let onEditingChanged = ValidationOptions(rawValue: 1 << 2)

    static let all: ValidationOptions = [.onSubmit, .onChange, .onEditingChanged]
}

Using this bitmask-based structure (much like UIView.AnimationOptions or SwiftUI.Alignment), it becomes easy to enable or disable validation triggers with a single line:

.validate(
    field,
    with: text(for: field),
    rule: field.validationRule,
    options: .all,
    ...
)

Or you can use only a subset:

options: [.onSubmit, .onEditingChanged]

Note

By the way, you can read more about OptionSet here.

Custom Modifier: TextFieldValidator

The core of this validation system is the TextFieldValidator view modifier. It encapsulates the logic needed to observe and respond to user input and focus changes in a modular way:

private struct TextFieldValidator<Field: Equatable>: ViewModifier {
    @Binding var text: String
    @Binding var focusedField: Field?
    let validationRule: (String) -> ValidationResult
    let options: ValidationOptions
    let debounce: Duration?
    let currentField: Field
    let onValidation: (ValidationResult) -> Void

    func body(content: Content) -> some View {
        content
            .modify { view in
                if options.contains(.onSubmit) {
                    view.onSubmit {
                        onValidation(validationRule(text))
                    }
                } else {
                    view
                }
            }
            .modify { view in
                if options.contains(.onChange) {
                    view.onChangeDebounced(of: text, for: debounce ?? .zero) { _, newValue in
                        onValidation(validationRule(newValue))
                    }
                } else {
                    view
                }
            }
            .modify { view in
                if options.contains(.onEditingChanged) {
                    view.onChange(of: focusedField) { oldValue, newValue in
                        if oldValue == currentField && newValue != currentField {
                            onValidation(validationRule(text))
                        }
                    }
                } else {
                    view
                }
            }
    }
}

Each supported validation trigger is added conditionally, based on the options passed to the modifier. This avoids hardcoding specific behavior into the view and allows the calling context to decide when validation should occur.

This level of configurability ensures the modifier remains reusable and easy to test or extend.

Cleaner Code with .modify

SwiftUI doesn’t natively support fluent chaining of conditional view modifications. To solve this, a small but powerful modify extension is introduced:

extension View {
    func modify(@ViewBuilder _ transform: (Self) -> some View) -> some View {
        transform(self)
    }
}

This helper enables you to write conditional .onChange, .onSubmit, or any other modifiers in a cleaner, readable style without deeply nesting logic or duplicating code.

For example:

content
    .modify { view in
        if condition {
            view.onChange(of: value) { ... }
        } else {
            view
        }
    }

This makes the TextFieldValidator not only more readable, but also more maintainable in the long run.

UI Feedback

Validation results are used to drive UI updates with simple logic:

RoundedRectangle(cornerRadius: 12)
    .stroke(validations[field, default: .success].isValid ? Color.secondary : Color.red, lineWidth: 1)

Text(validations[field]?.failureMessage ?? "")
    .foregroundColor(.red)
    .frame(maxWidth: .infinity, alignment: .leading)

Final Thoughts

This architecture separates validation logic from the UI and keeps everything clean and modular. Adding a new field or rule requires minimal effort—just extend the enum and the validator. You can also change triggers or debounce behavior per field without affecting the others.

If you’re working with SwiftUI forms and want better structure around validation, I hope this approach provides a useful blueprint.
If you’re interested in the debounce and focus-related mechanics, feel free to check out my other posts linked above.

Full code:

import SwiftUI

struct Validator {
    static func email(_ email: String) -> ValidationResult {
        email.isValidEmail ? .success : .failure(message: "Invalid Email")
    }
    
    static func nonEmpty(_ string: String) -> ValidationResult {
        !string.isEmpty ? .success : .failure(message: "This field must not be empty")
    }
}

enum Field: String, CaseIterable, Identifiable {
    case email
    case regular
    
    var id: String {
        rawValue
    }
    
    var validationRule: (String) -> ValidationResult {
        switch self {
        case .email: Validator.email
        case .regular: Validator.nonEmpty
        }
    }
    
    var placeholder: String {
        switch self {
        case .email: "Enter email"
        case .regular: "Enter some text"
        }
    }
}

struct ValidationResult: Equatable {
    let isValid: Bool
    let failureMessage: String
    
    static let success: Self = .init(isValid: true, failureMessage: "")
    static func failure(message: String) -> Self {
        .init(isValid: false, failureMessage: message)
    }
}

struct ValidationDemo: View {
    @State private var email = ""
    @State private var someText = ""
    @State private var focusedField: Field?
    @State private var validations: [Field: ValidationResult] = Dictionary(
        uniqueKeysWithValues: Field.allCases.map { ($0, .success) }
    )
    
    var body: some View {
        ScrollView {
            VStack {
                ForEach(Field.allCases) { field in
                    textField(field)
                }
            }
            .padding()
        }
    }
        
    private func textField(_ field: Field) -> some View {
        VStack {
            TextField(field.placeholder, text: text(for: field))
                .isFocused($focusedField, equals: field)
                .validate(
                    field,
                    with: text(for: field),
                    rule: field.validationRule,
                    options: .all,
                    debounce: .milliseconds(400),
                    focusedField: $focusedField
                ) { result in
                    guard validations[field] != result else { return }
                    withAnimation {
                        validations[field] = result
                    }
                }
                .padding()
                .background {
                    RoundedRectangle(cornerRadius: 12)
                        .fill(.clear)
                        .stroke(
                            validations[field, default: .success].isValid ? Color.secondary : Color.red,
                            lineWidth: 1
                        )
                }
            
            Text(validations[field]?.failureMessage ?? "")
                .foregroundColor(.red)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
    
    private func text(for field: Field) -> Binding<String> {
        switch field {
        case .email: $email
        case .regular: $someText
        }
    }
    
}

struct ValidationOptions: OptionSet {
    let rawValue: Int
    
    static let onSubmit = ValidationOptions(rawValue: 1 << 0)
    static let onChange = ValidationOptions(rawValue: 1 << 1)
    static let onEditingChanged = ValidationOptions(rawValue: 1 << 2)
    
    static let all: ValidationOptions = [.onSubmit, .onChange, .onEditingChanged]
}

extension View {
    func validate<Field: Equatable>(
        _ field: Field,
        with text: Binding<String>,
        rule: @escaping (String) -> ValidationResult,
        options: ValidationOptions,
        debounce: Duration?,
        focusedField: Binding<Field?>,
        onValidation: @escaping (ValidationResult) -> Void
    ) -> some View {
        
        modifier(
            TextFieldValidator(
                text: text,
                focusedField: focusedField,
                validationRule: rule,
                options: options,
                debounce: debounce,
                currentField: field,
                onValidation: onValidation
            )
        )
    }
}

private struct TextFieldValidator<Field: Equatable>: ViewModifier {
    @Binding var text: String
    @Binding var focusedField: Field?
    let validationRule: (String) -> ValidationResult
    let options: ValidationOptions
    let debounce: Duration?
    let currentField: Field
    let onValidation: (ValidationResult) -> Void
    
    func body(content: Content) -> some View {
        content
            .modify { view in
                if options.contains(.onSubmit) {
                    view.onSubmit {
                        onValidation(validationRule(text))
                    }
                } else {
                    view
                }
            }
            .modify { view in
                if options.contains(.onChange) {
                    view.onChangeDebounced(of: text, for: debounce ?? .zero) { _, newValue in
                        onValidation(validationRule(newValue))
                    }
                } else {
                    view
                }
            }
            .modify { view in
                if options.contains(.onEditingChanged) {
                    view.onChange(of: focusedField) { oldValue, newValue in
                        if oldValue == currentField && newValue != currentField {
                            onValidation(validationRule(text))
                        }
                    }
                } else {
                    view
                }
            }
    }
}

extension View {
    func modify(@ViewBuilder _ transform: (Self) -> some View) -> some View {
        transform(self)
    }
}

extension View {
    func isFocused<T: Hashable>(
        _ binding: Binding<T?>,
        equals value: T
    ) -> some View {
        
        modifier(
            FocusModifier(
                binding: binding,
                value: value
            )
        )
    }
}

private struct FocusModifier<T: Hashable>: ViewModifier {
    @Binding var binding: T?
    let value: T
    @FocusState private var focused: Bool

    func body(content: Content) -> some View {
        content
            .focused($focused)
            .onChange(of: binding) { _, newValue in
                focused = (newValue == value)
            }
            .onChange(of: focused) { _, newValue in
                if newValue {
                    binding = value
                } else if binding == value {
                    binding = nil
                }
            }
    }
}

extension String {
    var isValidEmail: Bool {
        guard !self.lowercased().hasPrefix("mailto:") else {
            return false
        }
        
        guard let emailDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
            return false
        }
        
        let matches = emailDetector.matches(
            in: self,
            options: NSRegularExpression.MatchingOptions.anchored,
            range: NSRange(location: 0, length: self.count)
        )
        
        guard matches.count == 1 else {
            return false
        }
        
        return matches[0].url?.scheme == "mailto"
    }
}