Skip to main content

Parse, Don't Validate: Leveraging Swift's Typed Throws for Form Validation


Alexis King’s 2019 article Parse, Don’t Validate introduced a simple but powerful idea: use your type system to make invalid states unrepresentable. Instead of validating data and then passing around the same untyped value, parse it into a type that can only exist if the data is valid. If you have read Eric Evans’ Domain-Driven Design, you will recognize this as a Value Object — a small, immutable type defined by its attributes rather than an identity.

In Swift, this idea maps naturally to structs with throwing initializers. And since Swift 6.0, typed throws let us declare exactly which errors a parse can produce — making the call site exhaustive without a catch-all.

In this article, I want to show how these two ideas combine into a practical pattern for form validation in SwiftUI apps.

To do that, I use examples from the German energy market, because they have enough structure to make the pattern interesting. The principles apply to any domain.

The Problem with Stringly-Typed Validation

Consider a general flow where the user enters an address. The postcode comes from a TextField as a String. A common approach is to validate it at the point of use:

func submit(postcode: String) {
    guard postcode.count == 5, postcode.allSatisfy(\.isNumber) else {
        postcodeError = "Invalid postcode"
        return
    }

    // postcode is still a String — nothing prevents passing an unvalidated one
    service.send(postcode: postcode)
}

This works, but the validated knowledge is immediately lost. Downstream code receives the same String type and has no way to know whether it was already checked. Every function in the chain faces the same choice: trust the caller, or validate again.

Parsing into a Validated Type

Instead, we can define a type that can only be constructed from a valid input:

struct Postcode {
    let value: String

    init?(validating value: String) {
        guard value.count == 5, value.allSatisfy(\.isNumber) else {
            return nil
        }

        self.value = value
    }
}

Now the type itself is proof of validity. Any function that accepts a Postcode knows the value has been checked — the compiler enforces this, not convention.

The failable initializer is fine for simple pass/fail cases. But form validation usually needs to tell the user what went wrong.

Adding Typed Throws

Swift’s typed throws let us declare the exact error type a function can produce.

In the German energy market, every supply point has a Marktlokations-ID (MaLo ID). It is 11 digits long — an issuing-authority prefix, a sequential number, and a Luhn-style check digit. A user typing one into a form can get it wrong in several distinct ways:

struct MaLoID: Hashable, Sendable {
    enum ValidationError: Error {
        case empty
        case invalidLength
        case nonNumeric
        case invalidAuthority
        case checksumMismatch
    }

    let value: String

    init(validating input: String) throws(ValidationError) {
        guard !input.isEmpty else {
            throw .empty
        }

        guard input.count == 11 else {
            throw .invalidLength
        }

        let digits = input.compactMap(\.wholeNumberValue)
        guard digits.count == 11 else {
            throw .nonNumeric
        }

        guard digits[0] >= 1 else {
            throw .invalidAuthority
        }

        guard digits[10] == Self.computeChecksum(digits) else {
            throw .checksumMismatch
        }

        self.value = input
    }

    /// Compute the checksum using the algorithm described in the MaLo ID specification
    private static func computeChecksum(_ digits: [Int]) -> Int {
        fatalError("Implementation details omitted")
    }
}

The throws(ValidationError) annotation is the key. It tells the compiler — and the reader — that this initializer can only throw ValidationError cases. No catch block needs a catch-all for Error.

Exhaustive Error Handling at the Call Site

This is where typed throws really shines. At the call site, we can switch over every error case without a default:

do {
    let malo = try MaLoID(validating: input)
    service.send(maLoID: malo)
} catch {
    // `error` is `MaLoID.ValidationError`, not `any Error`
    postcodeError = switch error {
        case .empty:
            "Please enter a MaLo ID"
        case .invalidLength:
            "A MaLo ID must be exactly 11 digits"
        case .nonNumeric:
            "A MaLo ID may only contain digits"
        case .invalidAuthority:
            "Invalid issuing authority"
        case .checksumMismatch:
            "Check digit does not match - please verify the ID"
    }
}

No default case. No as? cast. If we add an additional ValidationError case tomorrow, every call site that catches this error will produce a compiler warning. Compare this to the untyped alternative, where adding a new error case compiles silently and the new case falls into default, is caught by a general error handler — or worse, is never handled.

Composing Validated Types

The pattern scales naturally when a form has multiple fields. Each field gets its own validated type with its own error. The address form then validates each field independently and collects the results in a way that is specific to the form’s validation logic. In the following example, the validateAll function collects the results of validating the street and house number fields into an AddressData struct.

func validateAll() -> AddressData? {
    var street: Street?
    var houseNumber: Housenumber?

    do throws(Street.ValidationError) {
        street = try validateStreet()
    } catch {
        streetError = error.message
    }
    do throws(Housenumber.ValidationError) {
        houseNumber = try validateHousenumber()
    } catch {
        houseNumberError = error.message
    }

    guard let street, let houseNumber else {
        return nil
    }

    return AddressData(
        street: street,
        houseNumber: houseNumber
    )
}

Notice the typed do throws blocks. In this example, each field validates independently, so a failure in the street field doesn’t prevent the house number from being validated too. The user sees all errors at once, not one at a time. This behavior can, of course, be adjusted to the use case at hand.

Bridging Domain and Server Errors

In practice, validation doesn’t stop at the client. The server might reject a MaLo ID that passed local validation — perhaps the ID is not officially registered, or the server uses a slightly different checksum implementation. Domain-driven design and typed throws help here too, because we can bridge server errors back into the same ValidationError type:

// The MaLoID.ValidationError type lives in the domain layer of our app,
// but the extension would be placed where our data communication happens.
extension MaLoID.ValidationError {
    init?(_ serverError: ServerLogicError) {
        switch serverError {
        case .checksumMismatch:  self = .checksumMismatch
        case .invalidAuthority: self = .invalidAuthority
        default:                return nil
        }
    }
}

The call site then handles both local and server validation uniformly:

do {
    let malo = try MaLoID(validating: input)
    try await service.submit(malo)
} catch let error as MaLoID.ValidationError {
    validationError = error
} catch let error as ServerLogicError {
    if let mapped = MaLoID.ValidationError(error) {
        validationError = mapped
    } else {
        genericError = error
    }
}

Local parse errors and server-side rejections surface through the same UI path. The user sees “Check digit does not match” regardless of which layer caught it.

Why This Works

The “parse, don’t validate” approach gives us three things that plain validation does not:

  1. Compile-time proof. A Postcode cannot exist with invalid data. Functions that accept it don’t need to re-validate.

  2. Exhaustive errors. Typed throws turn error handling from a runtime concern into a compile-time one. Adding a new error case forces every catch site to handle it.

  3. Composability. Each validated type is self-contained. Regex patterns, error cases, and formatting logic live together. The form just orchestrates the results.

The types are small — most are under 30 lines. The overhead of creating them is minimal compared to the confidence they provide. And because the validated value is a separate type from the raw input, the question “has this been validated?” is answered by the type system, not by reading the code path.