How to build form validation in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Combine/Publisher Updated: May 12, 2026
TL;DR

Create an ObservableObject view model with @Published fields, chain Combine Publisher operators to derive per-field validity booleans, then merge them with Publishers.CombineLatest into a single isFormValid flag that gates your submit button.

class SignUpViewModel: ObservableObject {
    @Published var email    = ""
    @Published var password = ""
    @Published var isFormValid = false

    init() {
        Publishers.CombineLatest($email, $password)
            .map { email, pwd in
                email.contains("@") && email.contains(".")
                && pwd.count >= 8
            }
            .assign(to: &$isFormValid)
    }
}

Full implementation

The view model runs three independent Combine pipelines — one for email format, one for password strength, one for confirmation match — and a fourth CombineLatest3 pipeline that gates the submit button on all three. The SwiftUI view binds each field via $vm.property and shows inline error labels only after the user has started typing, so the form never opens in an angry red state.

import SwiftUI
import Combine

// MARK: - View Model

final class SignUpViewModel: ObservableObject {

    // Inputs
    @Published var email           = ""
    @Published var password        = ""
    @Published var confirmPassword = ""

    // Derived validation flags
    @Published var isEmailValid    = false
    @Published var isPasswordValid = false
    @Published var passwordsMatch  = false
    @Published var isFormValid     = false

    init() {
        // Email: must contain "@" with a "." somewhere after it
        $email
            .map { email in
                let parts = email.split(separator: "@")
                return parts.count == 2 && (parts.last?.contains(".") == true)
            }
            .assign(to: &$isEmailValid)

        // Password: 8+ chars, at least one digit, at least one uppercase letter
        $password
            .map { pwd in
                pwd.count >= 8
                    && pwd.contains(where: \.isNumber)
                    && pwd.contains(where: \.isUppercase)
            }
            .assign(to: &$isPasswordValid)

        // Confirmation: non-empty and matches password
        Publishers.CombineLatest($password, $confirmPassword)
            .map { pwd, confirm in !confirm.isEmpty && pwd == confirm }
            .assign(to: &$passwordsMatch)

        // Gate: all three must pass
        Publishers.CombineLatest3($isEmailValid, $isPasswordValid, $passwordsMatch)
            .map { $0 && $1 && $2 }
            .assign(to: &$isFormValid)
    }
}

// MARK: - View

struct SignUpView: View {
    @StateObject private var vm = SignUpViewModel()
    @State private var showSuccess = false

    var body: some View {
        NavigationStack {
            Form {
                Section("Account") {
                    TextField("Email", text: $vm.email)
                        .keyboardType(.emailAddress)
                        .autocorrectionDisabled()
                        .textInputAutocapitalization(.never)
                        .textContentType(.emailAddress)
                        .accessibilityLabel("Email address")

                    if !vm.email.isEmpty && !vm.isEmailValid {
                        Label("Enter a valid email address", systemImage: "xmark.circle.fill")
                            .font(.caption)
                            .foregroundStyle(.red)
                            .accessibilityLabel("Error: enter a valid email address")
                    }
                }

                Section("Password") {
                    SecureField("Password", text: $vm.password)
                        .textContentType(.newPassword)
                        .accessibilityLabel("Password, eight or more characters with a digit and uppercase letter")

                    if !vm.password.isEmpty && !vm.isPasswordValid {
                        Label("8+ chars, one digit, one uppercase", systemImage: "xmark.circle.fill")
                            .font(.caption)
                            .foregroundStyle(.red)
                    }

                    SecureField("Confirm password", text: $vm.confirmPassword)
                        .textContentType(.newPassword)
                        .accessibilityLabel("Confirm password")

                    if !vm.confirmPassword.isEmpty && !vm.passwordsMatch {
                        Label("Passwords do not match", systemImage: "xmark.circle.fill")
                            .font(.caption)
                            .foregroundStyle(.red)
                    }
                }

                Section {
                    Button {
                        showSuccess = true
                    } label: {
                        Label("Create account", systemImage: "person.crop.circle.badge.plus")
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.borderedProminent)
                    .disabled(!vm.isFormValid)
                    .accessibilityHint(
                        vm.isFormValid
                            ? "Double tap to submit"
                            : "Complete all fields correctly to enable"
                    )
                }
            }
            .navigationTitle("Sign Up")
            .alert("Account created!", isPresented: $showSuccess) {
                Button("OK", role: .cancel) {}
            }
        }
    }
}

#Preview {
    SignUpView()
}

How it works

  1. @Published as upstream Combine Publishers — Every @Published property automatically vends a Publisher through its $ prefix (e.g. $email). SwiftUI's TextField writes to the property on every keystroke, making Combine fire without any .onChange boilerplate in the view layer.
  2. .map transforms strings into validation booleans — The email pipeline splits on @ and checks for a dot in the domain segment. The password pipeline uses Swift's \.isNumber and \.isUppercase key-path predicates — no regex required, and both are Unicode-aware by default.
  3. .assign(to: &$flag) for memory-safe binding — The .assign(to:) overload that takes an inout Published.Publisher (iOS 14+) ties the pipeline's lifetime to the @Published property itself, preventing the retain cycle that the older .assign(to:on:) variant could create. No manual AnyCancellable storage needed for these simple pipelines.
  4. Publishers.CombineLatest3 as the submit gateCombineLatest3 re-emits a tuple whenever any of its three upstreams fires. Mapping that tuple with $0 && $1 && $2 means isFormValid stays continuously in sync as the user edits any field — no manual "re-validate everything" method call required.
  5. Deferred error display prevents a hostile UX — Each error Label only renders when the field is non-empty and the flag is false. A blank, untouched form shows no red text, while a partially typed field gives immediate feedback, matching the behaviour users expect from native iOS apps.

Variants

Debounced validation — stop the flicker while typing

Insert a .debounce operator before .map so the error label only appears after the user pauses for 300 ms, rather than flashing on every keypress.

// Replace the plain $email pipeline in init() with:
$email
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .map { email in
        let parts = email.split(separator: "@")
        return parts.count == 2 && (parts.last?.contains(".") == true)
    }
    .assign(to: &$isEmailValid)

// Apply the same pattern to $password and $confirmPassword.
// The debounce does NOT delay CombineLatest3 —
// it only delays when each individual flag flips,
// so the submit button still lights up promptly once
// the user finishes typing the last field.

Async remote validation — check username availability

Replace the synchronous .map with .removeDuplicates() followed by .debounce and then .flatMap wrapping your async network call in a Future. Because .assign(to:) is incompatible with .flatMap pipelines, store the resulting AnyCancellable in your cancellables set and use .receive(on: RunLoop.main) before the terminal .sink to avoid publishing from a background thread.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement form validation in SwiftUI for iOS 17+.
Use Combine/Publisher with @Published properties and CombineLatest.
Validate: email format, password strength (8+ chars, digit, uppercase),
and password confirmation match.
Show inline error Labels only after the field has been touched.
Make it accessible (VoiceOver labels, accessibilityHint on submit button).
Add a #Preview with realistic sample data.

In Soarias, paste this into the Build phase prompt panel — it maps directly to the Implementation step, and Soarias will scaffold the view model, wire all four Combine pipelines, and optionally generate a matching unit test target that exercises each validation rule in isolation.

Related

FAQ

Does this work on iOS 16?

The Combine pipelines and the .assign(to: &$property) syntax are supported from iOS 14 onward, so the view model compiles fine on iOS 16. However, the #Preview macro requires Xcode 15+ targeting iOS 17+. On iOS 16 swap it for a struct SignUpView_Previews: PreviewProvider block and replace NavigationStack with NavigationView.

Can I use the new @Observable macro instead of ObservableObject?

Not directly for Combine pipelines. The @Observable macro (iOS 17+) does not expose $property Combine Publishers, so .map and CombineLatest cannot be chained off its properties. Keep ObservableObject + @Published when you want Combine pipelines. If you prefer @Observable, replace the pipelines with .onChange modifiers in the view that call a plain Swift validation function — simpler, but the logic lives in the view layer rather than the model.

What's the UIKit equivalent?

In UIKit you'd add .editingChanged targets on each UITextField pointing to a shared validateForm() method that reads all fields and toggles submitButton.isEnabled. With Combine you can also subscribe to NotificationCenter.Publisher for UITextField.textDidChangeNotification and chain the same operators shown here — no SwiftUI required. The @Published + SwiftUI approach simply eliminates the notification registration boilerplate entirely.

Last reviewed: 2026-05-12 by the Soarias team.