How to build form validation in SwiftUI
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
-
@Published as upstream Combine Publishers — Every
@Publishedproperty automatically vends aPublisherthrough its$prefix (e.g.$email). SwiftUI'sTextFieldwrites to the property on every keystroke, making Combine fire without any.onChangeboilerplate in the view layer. -
.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\.isNumberand\.isUppercasekey-path predicates — no regex required, and both are Unicode-aware by default. -
.assign(to: &$flag) for memory-safe binding — The
.assign(to:)overload that takes aninout Published.Publisher(iOS 14+) ties the pipeline's lifetime to the@Publishedproperty itself, preventing the retain cycle that the older.assign(to:on:)variant could create. No manualAnyCancellablestorage needed for these simple pipelines. -
Publishers.CombineLatest3 as the submit gate —
CombineLatest3re-emits a tuple whenever any of its three upstreams fires. Mapping that tuple with$0 && $1 && $2meansisFormValidstays continuously in sync as the user edits any field — no manual "re-validate everything" method call required. -
Deferred error display prevents a hostile UX — Each error
Labelonly renders when the field is non-empty and the flag isfalse. 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
- iOS 14+ required for the safe
.assign(to: &$property)overload. The memory-safe variant was introduced in iOS 14. If you copy this snippet into a project with a lower deployment target, the compiler silently resolves to the retain-cycle-prone.assign(to:on:)overload. Since this guide targets iOS 17+ you're fine — just watch for the symptom if you ever backport. - CombineLatest tops out at three streams.
Publishers.CombineLatest3is the last built-in overload. For four or more fields, chain twoCombineLatestcalls (CombineLatest(CombineLatest($a,$b), CombineLatest($c,$d))) or use the open-source CombineExt library'sCombineLatestMany. Reaching for a non-existentCombineLatest4won't compile. - Never use
@ObservedObjectto create the view model. Using@ObservedObject private var vm = SignUpViewModel()instead of@StateObjectcauses SwiftUI to destroy and re-instantiate the view model — and all its Combine subscriptions — on every parent re-render, wiping any text the user has already entered. - VoiceOver users need to understand a disabled button. A disabled button with no hint tells VoiceOver users nothing about what's wrong. Always add
.accessibilityHintexplaining the form must be completed, as shown in the full implementation above.
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.