← Back to portfolio
Open Source · Swift Package

ValidatorKit

A fluent, extensible validation library for Swift — chainable schemas, 24 built-in rules including cross-field and async checks, and localized error messages for form and data validation across Apple platforms.

Swift 6.0 Swift Package Manager iOS 13+ macOS 10.15+ tvOS 13+ watchOS 6+ MIT
01 — Overview

What ValidatorKit is.

ValidatorKit is a small, dependency-free Swift package for validating dictionaries of user input — the shape you typically get out of a form, a decoded JSON body, or a view model. You describe the constraints once as a schema, then hand it any [String: Any] and get back a result that is either valid or carries a per-field list of human-readable error messages.

The API is deliberately boring: one builder, one validate call, one result type. There is no reflection, no property-wrapper ceremony, and no requirement that your model conform to anything.

  • Fluent and chainable — declare every field and rule in a single readable expression.
  • Composable — stack as many rules as you like on one field; they all run and all failing messages are collected.
  • Cross-field aware — matches(), dateBefore() and dateAfter() compare one field against another in the same object, so confirm-password and start/end-date checks stay inside the schema.
  • Async when it has to be — customAsync() and validateAsync() cover the checks only a server can answer, such as whether a username is still free.
  • Extensible — drop in arbitrary logic with custom(message:validation:), or conform your own type to ValidationRule / AsyncValidationRule.
  • Localized — built-in messages ship in English, Arabic, Spanish and French, and every rule accepts a message: override.
  • Tested in CI — every push builds and runs the test suite on a real Xcode 16 toolchain via GitHub Actions, with SwiftLint enforced alongside it.
Platforms
iOS 13+ · macOS 10.15+
tvOS 13+ · watchOS 6+
Toolchain
swift-tools-version 6.0
Distribution
Swift Package Manager
License
MIT

Version note. This page tracks the API on master — every rule listed below ships today, including the cross-field rules, the async rules and passwordStrength(rejectCommon:). Rather than naming a version that goes stale, the page points at the GitHub Releases page, which is the authoritative changelog; the one concrete version number below is the lower bound in Package.swift.

02 — Installation

Add the package.

ValidatorKit is distributed exclusively through the Swift Package Manager. 1.3.1 is the lowest version that carries everything documented here — cross-field rules landed in 1.3.0, async validation in 1.3.1 — and from: resolves forward to any later 1.x. The current list is always on Releases.

In a Package.swift manifest

Package.swift
let package = Package(
    name: "MyApp",
    dependencies: [
        .package(url: "https://github.com/Alhiane/ValidatorKit.git", from: "1.3.1")
    ],
    targets: [
        .target(name: "MyApp", dependencies: ["ValidatorKit"])
    ]
)

In Xcode

Choose File → Add Package Dependencies…, paste the repository URL, and pick Up to Next Major Version starting at 1.3.1:

Repository URL
https://github.com/Alhiane/ValidatorKit.git

Then import it wherever you validate:

Swift
import ValidatorKit
03 — Usage

Build a schema, validate a dictionary.

A schema is assembled by chaining .field("name") followed by one or more rules, and terminated with .ready(), which returns the finished ValidationSchema. Calling .field() again starts a new field on the same schema.

SignUpValidator.swift
import ValidatorKit

let input: [String: Any] = [
    "username": "alhiane",
    "email":    "lahcen@example.com",
    "age":      25,
    "password": "S3cure!passphrase",
    "website":  "https://alhiane.com",
    "role":     "admin"
]

// `requiredIf` takes a Bool, so this condition is evaluated now —
// while the schema is being built — not later, during validate().
let isAdmin = input["role"] as? String == "admin"

let schema = ValidationSchema()
    .field("username").required()
    .field("email").required().email()
    .field("age").required().numeric().greaterThan(18)
    .field("password").required().passwordStrength(
        minLength: 8,
        requireUppercase: true,
        requireDigit: true,
        requireSymbol: true,
        rejectCommon: true
    )
    .field("website").URL(message: "That doesn't look like a valid URL.")
    .field("teamName").requiredIf(isAdmin)
    .ready()

let result = schema.validate(input)

if result.isValid {
    print("Everything checks out.")
} else {
    for (field, messages) in result.errors {
        print("\(field): \(messages.joined(separator: ", "))")
    }
}

The result

validate(_:) returns a ValidationResult — a value type with exactly two members. errors maps each failing field name to every message it produced, so a field with three broken rules reports all three rather than only the first.

ValidationResult
public struct ValidationResult {
    public let errors: [String: [String]]
    public var isValid: Bool { errors.isEmpty }
}

// For example:
let result = schema.validate(["email": "not-an-email", "age": 12])

result.isValid            // false
result.errors["email"]    // ["Please enter a valid email address."]
result.errors["age"]      // ["Value must be greater than 18."]

Missing keys count as missing. required() fails a field whose key is entirely absent from the input dictionary, not just one whose value is an empty string. Validating [:] against a schema with a required "name" field yields isValid == false.

04 — Cross-Field Rules

Rules that see the whole object.

Almost every rule only ever looks at its own field's value. Three do not: matches(), dateBefore() and dateAfter() are handed the entire dictionary under validation, so they can compare one field against another. These are the confirm-password and start/end-date checks that would otherwise have to happen by hand, after the schema has already run.

They chain like any other rule. The field you declare them on is the field that reports the error, and the argument names the field to compare against:

SignUpForm.swift
let schema = ValidationSchema()
    .field("password").required()
    .field("confirmPassword").required().matches("password")
    .field("startDate").required().dateBefore("endDate")
    .field("endDate").required().dateAfter("startDate")
    .ready()

let result = schema.validate([
    "password":        "S3cure!passphrase",
    "confirmPassword": "S3cure!passphrase",
    "startDate":       "2026-11-02",
    "endDate":         "2026-11-09"
])

result.isValid    // true
  • matches(_:) — equality is type-strict: the two values are compared as AnyHashable, so the integer 3 does not match the string "3". Normalise types before validating if your form mixes them.
  • dateBefore(_:format:) and dateAfter(_:format:) — strictly earlier and strictly later. Two identical dates fail both. Either side may be a Date or a String parsed with format, which defaults to "yyyy-MM-dd".
  • A missing counterpart fails. If the other field is absent — or, for the date rules, unparseable — the rule reports its error rather than quietly passing.

Default messages name the field being compared against ("Value must match the password field.") and are localized like every other message; message: overrides them as usual.

Date parsing does not follow the device. Both date rules pin their formatter to the en_US_POSIX locale and the Gregorian calendar, so a fixed format like "yyyy-MM-dd" parses identically everywhere — an ar_SA device will not silently read it against a non-Gregorian calendar.

05 — Async Validation

Checks that need a round-trip.

Some answers only the server has: whether a username is taken, whether a coupon is still live, whether an email is already registered. Register those with customAsync(message:validation:) — the awaited twin of custom — and evaluate the schema with validateAsync(_:).

UsernameCheck.swift
let schema = ValidationSchema()
    .field("username")
    .required()
    .customAsync(message: "This username is already taken.") { value in
        await api.isUsernameAvailable(value as? String ?? "")
    }
    .ready()

let result = await schema.validateAsync(["username": "newuser"])

result.errors["username"]    // ["This username is already taken."] — or nil

What runs, and when

  • validate(_:) ignores async rules entirely. They are only ever awaited by validateAsync(_:), so the synchronous path stays synchronous and never blocks on the network.
  • Local failures short-circuit the round-trip. Synchronous rules run first; a field whose sync rules already failed skips its async rules completely, so an empty or malformed username never costs a request.
  • Fields are checked concurrently. Each field with async rules gets its own child task, so two remote checks overlap rather than queue. Within a single field the rules still run in the order you registered them, which keeps that field's messages in a predictable order.
  • Safe from @MainActor code. Both ValidationSchema and ValidationResult are Sendable, so awaiting validateAsync straight from a SwiftUI view model is exactly the intended use — no actor hops to arrange, no warnings under strict concurrency.
  • Cancellation is cooperative. Cancel the surrounding task and no further rules start; the rules already in flight see Task.isCancelled. Because a rule cannot throw, validateAsync returns the errors gathered so far rather than raising CancellationError.

An async rule of your own

Closures are the quick path; anything conforming to AsyncValidationRule — a message plus func validate(_ value: Any?) async -> ValidationError? — can be registered with asyncRule(_:), which is the better home for a check you reuse across several schemas:

UsernameAvailableRule.swift
struct UsernameAvailableRule: AsyncValidationRule {
    let api: AccountsAPI
    let message = "This username is already taken."

    func validate(_ value: Any?) async -> ValidationError? {
        guard let username = value as? String else {
            return ValidationError(message: message)
        }
        return await api.isUsernameAvailable(username)
            ? nil
            : ValidationError(message: message)
    }
}

let schema = ValidationSchema()
    .field("username").required().asyncRule(UsernameAvailableRule(api: api))
    .ready()

Keep cross-field rules on the synchronous path. validateAsync(_:) evaluates synchronous rules without the surrounding object, so matches(), dateBefore() and dateAfter() see an empty dictionary there and report a failure even when the values agree. Until that is fixed in the library, validate cross-field constraints with validate(_:) — keeping them in a separate schema from your async rules is the simplest way to do both on one form.

06 — Rules Reference

Every built-in rule.

All 24 rules are methods on the field builder and return it, so they chain freely. Every rule additionally accepts an optional message: parameter that replaces the default (localized) text for that one rule — omitted from the signatures below for readability.

RuleBehaviour
required() Field must be present and non-empty. Fails when the key is absent from the dictionary as well as when the value is empty.
requiredIf(_ condition: Bool) Behaves like required() only when condition is true. The condition is a plain Bool, evaluated where you write it — at schema-build time.
email() Value must be a syntactically valid email address.
numeric() Value must be a number, or a string that parses cleanly as one.
min(_ value: Double) Numeric lower bound — the value must be greater than or equal to value.
max(_ value: Double) Numeric upper bound — the value must be less than or equal to value. Pair with min() to bracket a number.
greaterThan(_ value: Double) Strictly greater than value.
lessThan(_ value: Double) Strictly less than value. renamed The earlier misspelling leassThan(_:) still exists as a deprecated alias, so existing call sites keep compiling — with a warning pointing here.
range(_ range: ClosedRange<Int>) Value must fall inside a closed integer range, e.g. range(1...5).
date(range:format:) Value must be a valid date — either a Date, or a String parseable with format (default "yyyy-MM-dd"). Pass a DateRange(from:to:) to additionally bound it; either end may be nil to leave that side open.
pattern(_ pattern: String) Value must match the given regular expression.
URL() Value must be a well-formed URL.
inArray(_ array: [Any]) Value must be one of an explicit allow-list of options.
MIMETypes(_ types: [String]) Value must be one of the permitted MIME types, e.g. ["image/jpeg", "image/png"].
maxFileSize(_ bytes: Int) Value must be a byte count no larger than bytes. Validate it on a separate companion field from the MIME type — one field's value cannot simultaneously be a file type and a size. See Recipes.
passwordStrength(…) Configurable strength check: minLength: Int = 8, requireUppercase, requireLowercase, requireDigit, requireSymbol, rejectCommon — all Bool, all defaulting to false. Only the character classes you switch on are enforced. rejectCommon: true additionally runs the notCommonPassword() list, and reports it with its own message ("Password is too common.") unless you supply a message:, which then covers both failures.
notCommonPassword() Value must not be one of roughly 100 well-known passwords — password, qwerty123, letmein and company — compiled from the public NCSC/SplashData-style lists and matched case-insensitively. Usable on its own, or folded into passwordStrength() via rejectCommon: true.
matches(_ otherField: String) Value must equal the value held by otherField in the same object — the confirm-password check. cross-field Equality is type-strict, so 3 does not match "3"; an absent counterpart fails. See Cross-Field Rules.
dateBefore(_:format:) Value must be strictly earlier than the date in otherField — equal dates fail. cross-field Either side may be a Date or a String parsed with format (default "yyyy-MM-dd") against a pinned en_US_POSIX locale and Gregorian calendar.
dateAfter(_:format:) The mirror of dateBefore(): value must be strictly later than the date in otherField. cross-field Same parsing rules, same strictness.
phoneNumber() Basic international phone-number shape check (E.164-ish: optional +, digits, common separators). This is a format sanity check, not full libphonenumber-grade parsing — it will not tell you whether a number is assignable in a given country.
creditCard() Strips spaces and dashes, requires 12–19 digits, and verifies the Luhn check digit. Confirms the number is well-formed — not that the card exists or is active.
IBAN() Validates IBAN structure (2-letter country code, 2 check digits, up to 30 alphanumerics) and the ISO 7064 mod-97 checksum. Structural validity only; it does not confirm the account exists.
custom(message:validation:) Escape hatch. Supply a (Any?) -> Bool closure and the message to show when it returns false.

Async rules

Two further methods register rules that validate(_:) never runs — they are awaited only by validateAsync(_:). customAsync asks for its message at the call site; asyncRule takes the rule itself, and the text comes from that type's own message property. See Async Validation.

RuleBehaviour
customAsync(message:validation:) The awaited twin of custom. Supply an (Any?) async -> Bool closure and the message to show when it returns false.
asyncRule(_ rule: R) Registers any type conforming to AsyncValidationRule — a message property plus func validate(_ value: Any?) async -> ValidationError?. The reusable counterpart to a one-off closure.
07 — Recipes

Common patterns.

Validating a file upload

File uploads carry two independent facts — what the file is and how big it is — so they are validated as two fields. Give the size its own key alongside the type:

Upload.swift
let schema = ValidationSchema()
    .field("avatar").required().MIMETypes(["image/jpeg", "image/png"])
    .field("avatarSize").required().maxFileSize(5_000_000)  // 5 MB
    .ready()

schema.validate([
    "avatar":     "image/png",
    "avatarSize": 1_248_000
])

Bounding a date

Booking.swift
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"

let schema = ValidationSchema()
    .field("startsOn").required().date(
        range: DateRange(from: Date(), to: formatter.date(from: "2027-12-31")),
        format: "yyyy-MM-dd"
    )
    .ready()

schema.validate(["startsOn": "2026-11-02"])  // valid

A rule of your own

When nothing built in fits, custom takes the value as Any? and expects a Bool back:

Coupon.swift
let schema = ValidationSchema()
    .field("coupon")
    .required()
    .custom(message: "Coupon codes must start with SAVE.") { value in
        guard let code = value as? String else { return false }
        return code.hasPrefix("SAVE")
    }
    .ready()

Banning your own passwords

The list behind rejectCommon is fixed and covers the globally common choices. Product-specific ones — your company name, your app name — are not in it, and the schema builder has no method that takes a pre-built rule, so layer them on with custom:

Password.swift
let banned: Set<String> = ["acme", "acme2026", "acmerocks"]

let schema = ValidationSchema()
    .field("password")
    .required()
    .passwordStrength(minLength: 12, requireDigit: true, rejectCommon: true)
    .custom(message: "Please avoid passwords based on the company name.") { value in
        guard let password = value as? String else { return false }
        return !banned.contains(password.lowercased())
    }
    .ready()

Bracketing a number

Score.swift
let schema = ValidationSchema()
    .field("score").numeric().min(0).max(100)
    .field("rating").range(1...5)
    .field("sku").pattern("^[A-Z]{3}-\\d{3}$")
    .ready()
08 — Localization

Messages in four languages.

Default error messages ship as Localizable.strings resources bundled inside the package, in English, Arabic, Spanish and French. They are resolved through NSLocalizedString against the package's own bundle, which means there is nothing to configure: messages follow the host application's locale automatically, and fall back to the English defaults for any locale that isn't covered.

To override the copy for a specific rule — for wording that fits your product, or for a language the package doesn't ship — pass message:. It takes precedence over the localized default for that rule only:

Localized.swift
let schema = ValidationSchema()
    // Uses the bundled message for the current locale.
    .field("email").required().email()

    // Overrides just this one rule's message.
    .field("vatNumber")
    .required(message: NSLocalizedString("vat.required", comment: ""))
    .pattern("^[A-Z]{2}[0-9]{9}$", message: "VAT numbers look like NL123456789.")
    .ready()

Because the override is an ordinary String, routing it through your app's own NSLocalizedString — as above — is the straightforward way to localize into languages beyond the four bundled.

09 — Contributing

Issues and pull requests welcome.

Bug reports, rule proposals and pull requests are all welcome on GitHub. Continuous integration runs on every push and every pull request: the package is built and the full test suite executed on a real Xcode 16 toolchain, with SwiftLint running alongside. A green CI run is the bar for merging.

  • Add a test alongside any new rule — the existing suite in Tests/ValidatorKitTests is the pattern to follow.
  • New rules live in Sources/ValidatorKit/Rules/ as a type conforming to ValidationRule, plus a matching chainable method on FieldValidator.
  • If a rule needs a user-facing message, add its default and key to ValidationMessage and a translation to each of the four .lproj bundles.
  • Keep SwiftLint clean rather than disabling it.
  • Label every pull request major, minor or patch. Release Drafter turns those labels into the next version number and a changelog on merge; the draft is published by hand, which is what creates the tag SPM pins to.
10 — License

MIT.

ValidatorKit is released under the MIT License — free to use, modify and distribute, in commercial and non-commercial projects alike, provided the copyright notice is retained. The full text lives in LICENSE at the root of the repository.

Built and maintained by Alhiane Lahcen.