> ## Content Index
> Fetch the complete content index at: https://williamboles.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Resisting the Whispers of a Form‑Engine
- URL: https://williamboles.com/resisting-the-whispers-of-a-form-engine/
- Published: 2025-12-02T09:28:25.000Z
- Updated: 2026-10-04T20:44:18.000Z
- Description: Every iOS dev hears that whisper sooner or later: "These fields look the same…you should build a form-engine." Those who follow that whisper almost always end up with an over-abstracted mess. Here, we look at building a simple validation flow that keeps us on the right path.
- Author: William Boles
- Tags: SwiftUI, Separation of Concerns, Abstraction

When building the registration section of our apps, many of us will have heard that seductive whisper:

"These fields are so alike. You should generalise them. Let's build a form-engine"

It makes sense. The fields are alike. Why shouldn't you build a form-engine? You'd be praised by the rest of the team. You'd be easing the burden of any future developers. You'd be adding value 😇.

So you go away and build that form-engine that lets you assemble form pages in minutes. No sooner have you pushed this *masterpiece* in declarative programming to `main` than you're pulled onto another feature. Your colleague picks up registration and, moments after opening their Xcode, you hear their cry of frustration: trying to customise the password field, they discover your form-engine has buried the actual `SecureField` inside a view shared by five screens and surrounded by a mountain of `if` statements. An isolated, simple change has become a risky, regression-prone slog.

As you peek round from behind the coffee machine, you hear that whisper again, but this time it isn't seductive. This time, you hear the malice in it as it mocks you for falling into the form-engine trap 😈.

![A photo of two people whispering](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2025/11/photo-of-two-people-whispering-min.jpg)

The instinct behind a form-engine is good: capture a common pattern in a reusable component. Where form-engines fall short is in being too ambitious. They go too far and end up creating a [quasi](https://dictionary.cambridge.org/dictionary/english/quasi?ref=williamboles.com)\-[Domain-Specific Language](https://en.wikipedia.org/wiki/Domain-specific%5Flanguage?ref=williamboles.com) (`DSL`) that doesn't complement SwiftUI — it tries to *replace* it. Creating your own language is hard. Creating one accidentally is harder. Maintaining it? Hardest of all. Especially when the creator doesn't control the ideas that form the bedrock of this `DSL` masquerading as a form-engine.

One day, a designer comes along - unaware that this form-engine exists - and asks for a small behaviour change on one screen: a password field should shake on error. The form-engine creator now must either add more conditionals to the abstraction or split it. Conditional wins at first, and they find a way to trickle that shake behaviour down to the input field. Then the designer changes the border colour. Then the font. Then adds a description label. And so on.

Before the form-engine creator knows it, that trickle has become a flood. The complexity has skyrocketed, and now screens are having to opt out of functionality that, without the form-engine, they wouldn't even know about. All those conditionals need tests, and each new conditional only makes testing harder - ending up with more risk, more work, and a codebase that's harder to teach to new developers.

You might be thinking:

"The form-engine creator should just create a new view type to decouple that generalisation"

But that's where they started before the abstraction spiral began. The issue isn't abstraction - it's the ambition to build on ideas that the developer doesn't control. What looks like "the same field" to the developer is "five different concepts" to the designer.

> This misalignment is a form of "Coincidental Duplication" - see [Coincidental Duplication Costs Really Add Up](https://williamboles.com/coincidental-duplication-costs-can-really-add-up/) for more details on what it is and how to combat it.

This post will explore how we can get up to the edge of a form-engine and reap the benefits that abstraction can give us without falling into that deep water. Each component we build will handle one concern. Each screen will then be free to use them as needed without becoming overly coupled to any other screen. We'll focus on a single example - a view that handles text entry and validation - but the pattern shown will work for other types of field too.

> This post will gradually build up to a working example. But I get it, that whisper is demanding, so if you are unable to ignore it, then head on over to the [completed example](https://github.com/wibosco/Validation-Example?ref=williamboles.com) and take a look at `ValidationInputField`, `Validator` and `ValidationViewModel` to see how things end up.

## Looking at What We Need to Build

Before jumping into building each component, let's take a moment to look at the overall architecture we're going to put in place and how each component fits into it.

![Diagram showing the architecture of a SwiftUI validation system. An entry point presents the RegistrationView, which owns ValidationViewModel and renders ValidationInputField. ValidationViewModel publishes ValidationState, which ValidationInputField reads at runtime. ValidationViewModel also owns a Debouncer and a Validator, with the Debouncer triggering the Validator after a delay. ValidationState is the only data type; all other components are behavioural.](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2026/06/swiftui_validating_input_class_diagram.jpg)

- `RegistrationView` is the screen we are going to add our input field to (we will only cover this briefly to see how `ValidationInputField` can be used).
- `ValidationInputField` is our input field view. It will allow us to share common functionality across screens without tying itself to any one of them.
- `ValidationViewModel` orchestrates validation, debouncing, and state exposure to the UI. There are a number of ways we could connect the outcome of validating to the view. In the example project for this post, [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel?ref=williamboles.com) is the architecture chosen, with the `ViewModel` acting as that connector. But as you will see, `MVVM` isn't intrinsic to this solution and you should be able to swap in whatever *controller* you want here.
- `Debouncer` helps smooth out the validation feedback by waiting for the user to stop typing before triggering validation.
- `Validator` performs validation on the user's input.
- `ValidationState` represents the outcome of the validation in a declarative manner that the view can consume.

> The diagram simplifies one thing: `Validator` is shown as a single type, but in the example you'll see it's actually a protocol with two concrete types - `PasswordValidator` and `EmailAddressValidator`. In practice, it could be any number of validators.

> Don't worry if that doesn't all make sense yet; we will look into each component in greater depth below.

Now that we know where we are going, let's start by implementing password validation as a concrete solution, then we'll take that solution and abstract it to validate any input.

> This project is built using Swift 6.2 with the [Default Actor Isolation](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0466-control-default-actor-isolation.md?ref=williamboles.com) build setting set to `MainActor`. If you need to support an earlier version of Swift or don't want to set `Default Actor Isolation` to `MainActor`, then add `@MainActor` where needed.

## Defining What Makes a Valid Password

Before we can validate anything, we need a validator. That validator will answer just one question:

"Is this input acceptable?"

At its most simple, the answer is either `true` or `false`.

Let's look at the steps involved in validating a password. First, we need to define the rules for what a valid password is:

1. Must have at least 8 characters.
2. Must have at most 24 characters.
3. Must have at least one lowercase character.
4. Must have at least one uppercase character.
5. Must have at least one number.

With this definition, let's implement a validator that enforces those rules:

```
struct PasswordValidator {

    // 1
    func validate(_ value: String) -> Bool {

        // 2

        // Must have at least 8 characters
        guard value.count >= 8 else {
            return false
        }

        // Must have at most 24 characters
        guard value.count <= 24 else {
            return false
        }

        // Must have at least one lowercase character
        guard value.contains(where: { $0.isLowercase }) else {
            return false
        }

        // Must have at least one uppercase character
        guard value.contains(where: { $0.isUppercase }) else {
            return false
        }

        // Must have at least one number
        guard value.contains(where: { $0.isNumber }) else {
            return false
        }

        return true
    }
}
```

Here's what we did:

1. `PasswordValidator` has one method `validate(_:) -> Bool` that takes a `String` argument and returns `true` if that argument constitutes a valid password or `false` for an invalid password.
2. `value` is checked against each rule. If a check fails, `false` is immediately returned. If all checks pass, `true` is returned.

> In this post, all validation happens locally. If you need validation that involves asynchronous work, you could make this into an asynchronous method, e.g. `func validate(_ value: String) async -> Bool`.

`PasswordValidator` in its present form is a perfectly good validator, but notice how we lose details during the validation process - we know on which rule validation fails, but that information is lost by returning the same value for each validation failure. Let's tweak `PasswordValidator` to preserve that information by having `PasswordValidator` throw an error rather than return a boolean. Before `PasswordValidator` can be changed, we first need to define what errors can be thrown:

```
enum PasswordValidationError: Error {
    case tooShort
    case tooLong
    case missingLowercase
    case missingUppercase
    case missingNumber
}
```

`PasswordValidationError` is an enum that conforms to `Error`. Each case corresponds to one of the validation rules defined above. Let's update `PasswordValidator` to throw a `PasswordValidationError` error when the input is invalid:

```
struct PasswordValidator {

    // 1
    func validate(_ value: String) throws {

        // Must have at least 8 characters
        guard value.count >= 8 else {
            throw .tooShort
        }

        // Must have at most 24 characters
        guard value.count <= 24 else {
            throw .tooLong
        }

        // Must have at least one lowercase character
        guard value.contains(where: { $0.isLowercase }) else {
            throw .missingLowercase
        }

        // Must have at least one uppercase character
        guard value.contains(where: { $0.isUppercase }) else {
            throw .missingUppercase
        }

        // Must have at least one number
        guard value.contains(where: { $0.isNumber }) else {
            throw .missingNumber
        }
    }
}
```

Here's what we did:

1. Instead of using a `Bool` as the return type, we instead either throw an error for invalid input or return nothing at all for valid input. By throwing a unique error for each failure, any type making use of this validator will be able to tailor their response to what exactly went wrong.

`PasswordValidator` now throws specific errors for each rule it enforces. Which is good, but we can take it a step further. At present even though `PasswordValidator` **only** throws `PasswordValidationError`, the compiler requires us to handle unrelated error types as well because as far as the compiler is concerned, that plain `throws` that we added to `validate(_:)` effectively means "this method can throw `any Error`" so we need to handle `any Error` in our `do-catch` blocks. As `validate(_:)` will always throw `PasswordValidationError`, this error handling for `any Error` is effectively dead/unreachable code. We can give the compiler more information about the errors `validate(_:)` can throw, and so avoid that dead code by using: [typed throws](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0413-typed-throws.md?ref=williamboles.com).

Typed throws allows us to declare exactly which error type a method throws. By eliminating error erasure, our validation chain can be fully type-safe. To take advantage of this, let's update `PasswordValidator` to specify that it only throws `PasswordValidationError` errors:

```
struct PasswordValidator {

    // 1
    func validate(_ value: String) throws(PasswordValidationError) {
        // Omitted method body as it is unchanged
    }
}
```

Here's what we did:

1. As `validate(_:) throws` already only ever throws `PasswordValidationError`, we only need to change the signature to use typed throws.

Notice what `PasswordValidator` *doesn't* know: it doesn't know about views, debouncing, or user feedback. It knows password rules - that's it. This single responsibility makes it reusable across any context that needs password validation: registration, settings, password reset, wherever. This is the first piece of our separation of concerns: validators validate, nothing more.

With password validation implemented, let's figure out how to share the outcome of that validation in a way that fits the needs of a declarative UI.

## Transforming Outcomes

SwiftUI views excel when they react to state rather than handle control flow. Currently, `PasswordValidator` throws errors, which would force any consuming view into imperative error handling with `do-catch` blocks. Mixing imperative code in declarative spaces creates awkward code. Instead, we can introduce an enum for representing validation outcomes as **observable state** that a SwiftUI view can render declaratively. This enum will act as a clean boundary between the two styles:

```
// 1
enum ValidationState: Equatable {
    case valid
    case invalid(String)
}
```

Here's what we did:

1. `ValidationState` has two cases: `.valid` and `.invalid`, representing the successful and unsuccessful validation outcomes. `.invalid` has an [associated value](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/enumerations/?ref=williamboles.com#Associated-Values) of type `String` that will be used to hold a user-friendly message for why their input failed validation.

Now that we can represent validation outcomes declaratively, let's look at how to transform the output of `PasswordValidator` into something our custom input field can consume.

## Connecting the Validator and Input Field

We now have two pieces that don't quite fit together: `PasswordValidator` throws errors, but we've said that our custom input field needs `ValidationState` to render. We need something to bridge this gap.

That's the role of `PasswordViewModel`. It acts as the coordinator between these components, handling **three** key responsibilities:

1. Publishing - enabling `PasswordInputField` to react to state changes.
2. Triggering validation - when the user's input changes
3. Bridging the gap between our validator and the UI - catching and transforming validation errors into `ValidationState`

SwiftUI views automatically re-render when the state they observe changes. To enable this reactive behaviour, we need to mark `PasswordViewModel` with `@Observable`:

```
@Observable
final class PasswordViewModel {
}
```

`@Observable` automatically makes all properties observable, allowing SwiftUI to track changes and update the UI accordingly.

> If your app needs to support pre-iOS 17, you can substitute in [Combine](https://developer.apple.com/documentation/combine?ref=williamboles.com) to get the reactive behaviour provided by `@Observable`.

With the reactive foundation in place, we now need to decide what state to hold:

1. `value` \- the current input.
2. `validationState` \- the validation outcome.

Let's start with `value`:

```
@Observable
final class PasswordViewModel {
   // 1
   var value: String

   // 2
   init(initialValue: String) {
      self.value = initialValue
   }
}
```

Here's what we did:

1. Created a `value` property that will be bound to its associated view.
2. Implemented the initialiser to accept the initial value of `value`.

Before adding `validationState`, we need to decide its initial value. The current choices of `valid` and `invalid` don't feel right, as neither represents the nature of validation when no validation has occurred on `value`. We are missing a third `ValidationState` case - `.unchanged` to represent this unvalidated state. Let's add that:

```
enum ValidationState: Equatable {
    case unchanged
    case valid
    case invalid(String)
}
```

With `.unchanged`, we can now add a property to `PasswordViewModel` that can be used to drive any view changes based on the outcome of validation:

```
@Observable
final class PasswordViewModel {
    // Omitted unchanged properties

    // 1
    private(set) var validationState: ValidationState = .unchanged

    // Omitted unchanged methods
}
```

Here's what we did:

1. Added a `validationState` property to share the outcome of validation. Unlike `value`, we don't want anything external to `PasswordViewModel` to be able to set this property, so we mark it as `private(set)` to allow read but not write access.

Before we can trigger validation, we need to get from the `PasswordValidationError` that `validator` outputs to a `String` that the `.invalid` case of `ValidationState` expects. There are many ways to do this; here, we are going to extend `PasswordValidationError` to conform to `LocalizedError` and use `errorDescription` to do the conversion:

```
// 1
extension PasswordValidationError: LocalizedError {

    // 2
    var errorDescription: String? {
        switch self {
        case .tooShort:
            return "Password must be at least 8 characters long."
        case .tooLong:
            return "Password must be at most 24 characters long."
        case .missingLowercase:
            return "Password must contain at least one lowercase letter."
        case .missingUppercase:
            return "Password must contain at least one uppercase letter."
        case .missingNumber:
            return "Password must contain at least one number."
        }
    }
}
```

Here's what we did:

1. Created an extension that conforms `PasswordValidationError` to `LocalizedError`.
2. Implemented the property `errorDescription` of `LocalizedError` to provide a user-friendly description for each error.

> `LocalizedError` defines several other properties; we don't need to make use of them here, so I've not implemented them.

Now that we have `validationState` and a way to convert `PasswordValidationError` to `String`, we have everything we need to validate:

```
@Observable
final class PasswordViewModel {
    // Omitted unchanged property

    // 1
    private let validator: PasswordValidator

    init(initialValue: String,
         validator: PasswordValidator) {
        // Omitted unchanged code

        self.validator = validator
    }

    // 2
    func validate(_ currentValue: String) {
        do {
            try validator.validate(currentValue)

            // 3
            validationState = .valid
        } catch {
          // 4
          let errorMessage = error.errorDescription ?? "Unknown error"

          validationState = .invalid(errorMessage)
        }
    }
}
```

Here's what we did:

1. Added a private `validator` property to hold the `Validator` instance that will be used during validation.
2. Implemented `validate(_:)` to trigger validation. As `validate(_:)` on `Validator` can throw, we wrap that call in a `do-catch` block.
3. When validation succeeds, we set `validationState` to `.valid`.
4. As `PasswordValidationError` now conforms to `LocalizedError`, we call `errorDescription` to get the user-friendly message we defined. As this is an optional property, we need to provide a default error message in case `nil` is returned. We set `validationState` to `.invalid`.

`PasswordViewModel` can validate, but we aren't triggering that validation. There are **two** options:

1. Property side-effect - trigger validation automatically whenever `value` changes.
2. Explicit method call - call `validate(_:)` from `onChange(of:initial:_:)` in the view.

We are going with the property side-effect approach here. This approach keeps decision-making out of the view layer, and while property side-effects are often discouraged because they hide behaviour, this case is different: we want `validationState` to **always** be in sync with `value`. These aren't separate concerns - they're two facets of the same state. By triggering validation in `didSet`, we maintain this invariant automatically rather than relying on the view to remember to call `validate(_:)`. This keeps coordination logic where it belongs - in the view model, not scattered across views.

```
@Observable
final class PasswordViewModel {
    var value: String {
        // 1
        didSet {
            validate(value)
        }
    }

    // 2
    private func validate(_ currentValue: String) {
        // Omitted unchanged code
    }
}
```

Here's what we did:

1. Updated `value` so that any change will trigger validation to occur.
2. Updated `validate(_:)` to now be `private`.

While this is all that is required for our view model to trigger validation and convert the outcome of that validation into something `PasswordInputField` could consume, there is a subtle behavioural quirk: reverting `value` back to its initial state won't reset `validationState` back to `.unchanged`. This means that the same `value` might sometimes be treated as an error and sometimes not. Let's correct this:

```
@Observable
final class PasswordViewModel{
    var value: String {
        didSet {
            // 1
            guard value != initialValue else {
                validationState = .unchanged

                return
            }

            // Omitted unchanged code
        }
    }

    // Omitted other properties

    // 2
    private let initialValue: String

    // 3
    init(initialValue: String,
         validator: PasswordValidator) {
        // Omitted unchanged code

        self.initialValue = initialValue
    }

    // Omitted other methods
}
```

Here's what we did:

1. To avoid validating the *initial value* of `value`, we check that the current value is different from `initialValue` \- we don't want to treat the initial value of `value` as an error even if it actually is.
2. Added an `initialValue` property to allow the current value of `value` to be compared against its initial value.
3. Updated the initialiser to set the `initialValue` property.

`PasswordViewModel` is now complete - it coordinates validation without knowing about text fields, and it manages state without knowing validation rules. Each piece stays focused. Let's build the next piece: `PasswordInputField`.

## Creating a Password Input Field

Before we can begin writing a validating input field, we first need to know what our form field should look like:

![A mock-up of an input field showing three different scenarios: Scenario 1: No Input, Scenario 2: Valid Input, Scenario 3: Invalid Input](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2025/11/swiftui-securefield-validation-ui-mockup.jpg)

Across the **three** different scenarios shown in the mock-up, the input view consists of:

1. Title Label - describes what information the user needs to enter.
2. Input Field - allows the user to enter the requested information.
3. Error Label - describes how the entered information is invalid.

Now we know *what* to build - we need to think about how we should design its API.

When creating reusable views, one goal is to give that view a clean, focused initialiser. SwiftUI's own [SecureField](https://developer.apple.com/documentation/swiftui/securefield?ref=williamboles.com) demonstrates this beautifully: its initialiser accepts only the essential state needed to render the field, with styling and behaviour added through view modifiers.

Let's follow that pattern.

Looking at the mock-up, the title and input field are always present, so we want to pass the state used to set these via the initialiser, but the error label is conditional - we'll add that state via a modifier later. For now, the initialiser needs:

```
struct PasswordInputField: View {
    private let placeholder: String
    private let title: String
    @Binding private var value: String

    // 1
    init(title: String,
         placeholder: String,
         value: Binding) {
        self.placeholder = placeholder
        self.title = title
        self._value = value
    }

    // 2
    private var titleView: some View {
        Text(title)
            .font(.subheadline)
            .fontWeight(.medium)
            .foregroundColor(.primary)
    }

    // 3
    private var inputView: some View {
        SecureField(placeholder,
                    text: $value)
        .padding(12)
        .background(Color(.systemGray6))
        .cornerRadius(8)
    }

    // 4
    var body: some View {
        VStack(alignment: .leading,
               spacing: 0) {
            titleView
                .padding(.bottom, 6)

            inputView
                .padding(.bottom, 12)
        }
    }
}
```

Here's what we did:

1. `PasswordInputField`'s initialiser takes three arguments: `title` (text for the title label), `placeholder` (text shown when empty), and `value` (stores user input). The first two are simple `String` values that `PasswordInputField` only reads. But `value` is different - the input field needs to both read the current value *and* write changes back. That's what `Binding` provides: a two-way connection that lets changes flow from the text field back to the view model, ensuring the user's input isn't lost.
2. Implemented the title label as the `titleView` property.
3. Implemented the input field as the `inputView` property.
4. Arranged the subviews in a vertical stack within `body` to match the mock-up.

Let's add our `PasswordInputField` to `RegistrationView` to capture the user's password:

```
struct RegistrationView: View {
    @Bindable var viewModel = PasswordViewModel(initialValue: "",
                                                validator: PasswordValidator())

    var body: some View {
        passwordFieldView
    }

    private var passwordFieldView: some View {
        PasswordInputField(title: "Enter your password",
                          placeholder: "Enter your password",
                          value: $viewModel.value)
        .textContentType(.newPassword)
    }
}
```

While `PasswordInputField` looks good, it's purely presentational - no validation yet. Let's layer validation in by connecting it to our view model's state.

SwiftUI uses environment values for configuration that should cascade down the view hierarchy - think `.font()`, `.foregroundColor()`, or `.disabled()`. Validation state fits this pattern perfectly: it's set at the parent level and consumed by nested input fields. Let's add `validationState` to the environment:

```
// 1
extension EnvironmentValues {
    @Entry var validationState: ValidationState = .unchanged
}

// 2
extension View {
    func validationState(_ value: ValidationState) -> some View {
        environment(\.validationState, value)
    }
}
```

Here's what we did:

1. Extended [EnvironmentValues](https://developer.apple.com/documentation/SwiftUI/EnvironmentValues?ref=williamboles.com) with a `validationState` property, making it available throughout the view hierarchy like built-in environment values.
2. Created a convenience modifier that lets us write `.validationState(value)` instead of the more verbose `.environment(\.validationState, value)`.

With these changes, we can now pass validation state into the field:

```
struct RegistrationView: View {
    // Omitted unchanged code

    private var passwordFieldView: some View {
        PasswordInputField(title: "Enter your password",
                           placeholder: "Enter your password",
                           value: $viewModel.value)
        .textContentType(.newPassword)

        // 1
        .validationState(viewModel.validationState)
    }
}
```

Here's what we did:

1. Passed the `validationState` from the view model into the view.

Before updating `PasswordInputField` to respond to `validationState` changes, let's add a convenience property to `ValidationState` to make writing that code easier:

```
enum ValidationState: Equatable {
    // Omitted cases

    // 1
    var isInvalid: Bool {
        guard case .invalid(_) = self else {
            return false
        }

        return true
    }
}
```

Here's what we did:

1. Added an `isInvalid` helper to check if the current state is `.invalid`.

Armed with this convenience property, let's make `PasswordInputField` respond to validation changes:

```
struct PasswordInputField: View {
    // 1
    @Environment(\.validationState) private var validationState

    // Omitted unchanged methods and properties

    // 2
    @ViewBuilder
    private var validatedView: some View {
        if case let .invalid(errorMessage) = validationState {
            HStack(alignment: .top, spacing: 4) {
                Image(systemName: "exclamationmark.circle.fill")
                    .font(.caption)
                Text(errorMessage)
                    .font(.caption)
            }
            .foregroundColor(.red)
        }
    }

    @ViewBuilder
    private var inputView: some View {
        // Omitted unchanged code

        // 3
        .overlay(
            RoundedRectangle(cornerRadius: 8)
                .stroke(validationState.isInvalid ? Color.red : Color.clear, lineWidth: 2)
        )
    }

    var body: some View {
        VStack(alignment: .leading,
               spacing: 0) {
            titleView
                .padding(.bottom, 6)

            inputView
                .padding(.bottom, 12)

            // 4
            validatedView
        }
    }
}
```

Here's what we did:

1. Added a `validationState` property to read from SwiftUI's environment.
2. Created `validatedView` to display error messages when validation fails. This view only appears when the state is `.invalid`.
3. Added a red border to the input field when `validationState` is `.invalid`; otherwise, the border is clear.
4. Integrated `validatedView` into the layout below the existing views.

Before moving on, let's pause on an important design principle. `PasswordInputField` contains conditional logic - checking validation state, showing errors conditionally. So what separates healthy abstraction from the form-engine trap? The answer lies in **shared behaviour vs screen-specific dialects**.

The conditionals here - showing an error message, adding a red border - are **shared**: every screen using `PasswordInputField` gets the same error display. The field responds consistently to whatever `ValidationState` it receives. That's healthy abstraction.

The form-engine trap happens when conditionals create **unshared behaviour**: "if this is the registration screen, shake on error; if it's the settings screen, don't shake; if it's the profile screen, show a tooltip instead." Now we'd have three different dialects of the same component, each screen opting in or out of features. At that point, we'd be better off creating separate components rather than bloating one component with conditionals that only some screens use.

The line is simple: if conditional logic applies to *all* uses of a component, it belongs in the component. If it's specific to *one or two* screens, those screens should either decorate this component, use a different component or handle it themselves.

This distinction will guide us as we come to generalise this solution.

If you run the app and try entering an invalid password, you'll see the error message appear below the field as expected.

But you'll also notice something off: the error appears *immediately* as you start typing. That's not a great user experience 😔.

To smooth this out, we need a helper that waits for a pause in typing before triggering validation. That's where `Debouncer` comes in.

## Debouncing Validation

Looking back at our solution, `PasswordViewModel` triggers validation in `didSet` whenever `value` changes - which means every keystroke. That's what causes the immediate error feedback we just saw.

We need to insert a delay between *user-typed* and *validation-runs*. This is [debouncing](https://developer.mozilla.org/en-US/docs/Glossary/Debounce?ref=williamboles.com): waiting for a pause in activity before taking action. Instead of validating on every keystroke, we'll wait until the user stops typing for a moment.

SwiftUI doesn't provide a debouncer out of the box, so we'll implement a simple version using [Task](https://developer.apple.com/documentation/swift/task?ref=williamboles.com):

```
final class Debouncer {

    // 1
    private let delay: Duration

    // 2
    private var task: Task<void, never="">?

    init(delay: Duration) {
        self.delay = delay
    }

    // 3
    func submit(_ action: @escaping () async -> Void) {
        task?.cancel()
        task = Task {
            try? await Task.sleep(for: delay)

            // 4
            guard !Task.isCancelled else {
                return
            }

            await action()
        }
    }
}</void,>
```

Here's what we did:

1. Added a `delay` property to store how long operations wait before executing.
2. Added a `task` property to store the currently delayed operation and enable cancellation.
3. Implemented `submit(_:)` to handle incoming operations. Each call cancels any existing task (preventing stale operations), waits for the specified delay, and then executes the operation - this "cancel and restart" behaviour is what makes debouncing work.
4. Added a cancellation check before executing the operation. [Task cancellation](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/?ref=williamboles.com#Task-Cancellation) in Swift is cooperative, so merely calling `cancel()` won't automatically stop its execution - we must explicitly check `isCancelled` to respect the cancellation.

Now that we have `Debouncer`, let's make use of it:

```
final class PasswordViewModel {
    var value: String {
        didSet {
            // Omitted unchanged code

            // 1
            debouncer.submit {
                self.validate(self.value)
            }
        }
    }

    // 2
    private let debouncer: Debouncer

    // 3
    init(initialValue: String,
         validator: PasswordValidator,
         debouncer: Debouncer = Debouncer(delay: .milliseconds(500))) {
        // Omitted unchanged code

        self.debouncer = debouncer
    }

    // Omitted other methods
}
```

Here's what we did:

1. Replaced immediate validation with `debouncer.submit(_:)` to delay validation until typing pauses.
2. Added a `debouncer` property to manage delayed validation execution.
3. Defaulted the `debouncer` parameter to a *500-millisecond* delay for better user experience out of the box.

Notice how debouncing stays isolated within the view model, just like validation. `PasswordInputField` doesn't know anything about timing - it simply displays whatever `validationState` it receives. `PasswordValidator` doesn't know anything about timing - it receives input to validate. This preserves unidirectional data flow: the view model coordinates timing, the validator validates, and the view renders. This is a simple illustration of the separation of concerns that is driving our solution.

> An alternative to writing a custom debouncer would have been to use the debouncer provided by the [AsyncAlgorithms](https://github.com/apple/swift-async-algorithms?ref=williamboles.com) library. Using the debouncer from `AsyncAlgorithms` would involve using an [AsyncChannel](https://swiftpackageindex.com/apple/swift-async-algorithms/1.1.1/documentation/asyncalgorithms/asyncchannel?ref=williamboles.com) to drive the validation. I haven't used it here as doing so would distract from the overall theme of the post (that overabstracting is dangerous), but if you have a more complex use case, it would be worthwhile to investigate that approach.

Now, if you run the app, validation only happens once the user has paused long enough to show they mean it.

## Generalising to Any Input

So far, we have a working solution for validating passwords - let's make it more reusable by generalising it to validate any input. To do this, we need to make three components generic:

1. The validator - currently tied to `String` input and `PasswordValidationError`.
2. The view model - currently tied to `PasswordValidator`.
3. The input field - currently tied to secure text entry.

### Generalising the Validator

We'll define a protocol that any validator can conform to - whether validating passwords, emails, phone numbers, or anything else:

```
//1
protocol Validator {

    // 2
    associatedtype Value: Equatable

    // 3
    associatedtype ValidationError: Error

    // 4
    func validate(_ value: Value) throws(ValidationError)
}
```

Here's what we did:

1. `Validator` is a protocol, with two [associated types](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/generics/?ref=williamboles.com#Associated-Types), that all concrete validators will conform to.
2. Added `Value` as an associated type, allowing any conforming concrete validator to validate any input.
3. Added `ValidationError` as an associated type constrained to `Error`, allowing any conforming concrete validator to define its own specific error type.
4. `validate(_:)` makes use of the two associated types.

> `Value` doesn't technically need to conform to `Equatable`, but allowing equality comparisons is so common in validation that having this constraint makes working with a `Validator` instance easier.

Now that we have a way of expressing any validator, we need to conform `PasswordValidator` to it:

```
struct PasswordValidator: Validator {
    // Omitted unchanged code
}
```

The only change needed is to conform `PasswordValidator` to `Validator`; the Swift compiler has enough contextual information to infer that `Value` is `String` and `ValidationError` is `PasswordValidationError`, so we don't need to define them explicitly.

### Generalising the View Model

With the `Validator` protocol in place, we can make `PasswordViewModel` generic so it works with any validator:

```
@Observable

// 1
final class ValidationViewModel {

    // 2
    var value: V.Value {
        didSet {
            guard value != initialValue else {
                validationState = .unchanged

                return
            }

            debouncer.submit {
                self.validate(self.value)
            }
        }
    }

    private(set) var validationState: ValidationState = .unchanged

    // 3
    private let validator: V

    // 4
    private let errorMapper: ((V.ValidationError) -> (String))
    private let debouncer: Debouncer

    // 5
    private let initialValue: V.Value

    // MARK: - Init

    init(initialValue: V.Value,
         validator: V,
         errorMapper: @escaping ((V.ValidationError) -> (String)),
         debouncer: Debouncer = Debouncer(delay: .milliseconds(500))) {
        self.value = initialValue
        self.initialValue = initialValue
        self.validator = validator
        self.errorMapper = errorMapper
        self.debouncer = debouncer
    }

    // MARK: - Validate

    // 6
    private func validate(_ currentValue: V.Value) {
        do {
            try validator.validate(currentValue)

            validationState = .valid
        } catch {
            // 7
            let errorMessage = errorMapper(error)

            validationState = .invalid(errorMessage)
        }
    }
}
```

Here's what we did:

1. The name `PasswordViewModel` doesn't work for a generalised view model, so we've changed it to `ValidationViewModel`. `ValidationViewModel` now has a [generic parameter clause](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/genericparametersandarguments/?ref=williamboles.com) `V` that is constrained to `Validator`. By specialising `ViewModel`, we are able to reuse this view model with any concrete validator.
2. Updated `value` so that it is no longer of type `String`; instead, it has the same type as the `Value` defined in its validator.
3. Updated `validator` to be of type `V`.
4. `ValidationViewModel` doesn't try to guess how a validator's errors should be turned into a `String` to be passed to `ValidationState`. Instead, we use a closure to transform the validator's error into what is needed. This closure keeps the view model generic while still letting each field decide how its errors should be handled.
5. Updated `initialValue` to be the same type as the `Value` type of its validator.
6. Updated `currentValue` to be the same type as the `Value` type of its validator.
7. Updated the `errorMessage` creation to make use of `errorMapper`.

Before moving on, let's add a convenience initialiser to simplify creating instances of `ValidationViewModel` that are specialised to use `String` for `V.Value` and `LocalizedError` for `V.ValidationError`:

```
extension ValidationViewModel {

    // MARK: - Convenience

    convenience init(validator: V) where V.Value == String, V.ValidationError: LocalizedError {
        self.init(initialValue: "",
                  validator: validator,
                  errorMapper: { $0.errorDescription ?? "Unknown error"})
    }
}
```

We now have a view model that can work with any validator. Let's update `RegistrationView` to use `ValidationViewModel`:

```
struct RegistrationView: View {
    @Bindable var viewModel = ValidationViewModel(validator: PasswordValidator())

    // Omitted unchanged code
}
```

### Generalising the Input Field

With a generic view model, we need a generic input field to match. Let's rename `PasswordInputField` to `ValidationInputField`:

```
struct ValidationInputField: View {
    // Omitted unchanged code
}
```

Now that `PasswordInputField` has had a name change to `ValidationInputField`, it feels strange to have it limited to `SecureField`. Let's make it handle both secure and non-secure text input.

Following the pattern used for `validationState`, let's add a new environment value:

```
extension EnvironmentValues {
    @Entry var isSecure: Bool = false
}

extension View {
    func isSecure(_ value: Bool = true) -> some View {
        environment(\.isSecure, value)
    }
}
```

Let's update `ValidationInputField` to respond to `isSecure` changes:

```
struct ValidationInputField: View {
    // 1
    @Environment(\.isSecure) private var isSecure

    // Omitted unchanged properties and methods

    // 2
    @ViewBuilder
    private var inputView: some View {
        // 3
        Group {
            // 4
            if isSecure {
                SecureField(placeholder,
                            text: $value)
            } else {
                TextField(placeholder,
                          text: $value)
            }
        }
        .padding(12)
        .background(Color(.systemGray6))
        .cornerRadius(8)
    }
}
```

Here's what we did:

1. Added a `isSecure` property to read from SwiftUI's environment.
2. Marked `inputView` with `@ViewBuilder` since it can now return different view types.
3. Wrapped the possible subviews in a [Group](https://developer.apple.com/documentation/swiftui/group?ref=williamboles.com) to allow common view modifiers to be applied to whichever one is selected.
4. Checking the `isSecure` property to determine whether to render a `TextField` or `SecureField`.

We can now make use of this change to include an email field in `RegistrationView`:

```
struct RegistrationView: View {
    @Bindable var passwordViewModel = ValidationViewModel(validator: PasswordValidator())
    @Bindable var emailAddressViewModel = ValidationViewModel(validator: EmailAddressValidator())

    var body: some View {
        VStack(spacing: 20) {
            emailAddressFieldView
            passwordFieldView
        }
    }

    private var emailAddressFieldView: some View {
        ValidationInputField(title: "Email Address",
                             placeholder: "Enter your email address",
                             value: $emailAddressViewModel.value)
        .textInputAutocapitalization(.never)
        .autocorrectionDisabled()
        .keyboardType(.emailAddress)
        .textContentType(.emailAddress)
        .validationState(emailAddressViewModel.validationState)
    }

    private var passwordFieldView: some View {
        ValidationInputField(title: "Enter your password",
                             placeholder: "Enter your password",
                             value: $passwordViewModel.value)
        .textContentType(.newPassword)
        .isSecure()
        .validationState(passwordViewModel.validationState)
    }
}
```

With generalisation complete, we now have reusable validation components: `ValidationInputField` handles presentation, `ValidationViewModel` coordinates state, and any `Validator` defines rules. Supporting new input types - like an email field as shown above - is straightforward. Our generalised components allow common functionality to be shared while remaining flexible enough to fit the exact needs of the screen they appear on.

> You might be wondering if you missed the `EmailAddressValidator` implementation - you haven't. You can see it by checking out the [example project](https://github.com/wibosco/Validation-Example?ref=williamboles.com) associated with this post.

## Resisting that Whisper

That whisper to build a form-engine never really goes away - it's tempting, and it feels clever. But clever isn't always helpful. What works better is keeping responsibilities small and honest: a field draws itself, a validator validates, and a view model joins the dots.

This approach isn't clever, but it's easy to customise as the UI evolves. And that's often what you need when you're trying to ship a real feature - not writing a DSL.

Well done on making it to the end! 🏆

To see the complete working example, visit the [repository](https://github.com/wibosco/Validation-Example?ref=williamboles.com) and clone the project.

> Note that the code in the repository is [unit tested](https://williamboles.com/testing-through-different-lenses/), so it isn't 100% the same as the code snippets shown in this post. The changes mainly involve a greater use of protocols to allow for test-doubles to be injected into the various types - see [Let Dependency Injection Lead You to a Better Design](https://williamboles.com/let-dependency-injection-lead-you-to-a-better-design/) for how this works. Sometimes posts like these can present too rosy an image of adding this functionality, and I wanted instead to show what validation would truly look like.