← Back to portfolio
Open Source · Swift Package

ValidatorKit

A fluent, extensible validation library for Swift — chainable schemas, 20+ built-in rules, 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.
  • Extensible — drop in arbitrary logic with custom(message:validation:), or conform your own type to ValidationRule.
  • 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 documents the complete API as of v1.2.1 — every rule listed below, including max(), missing-key handling in required(), lessThan(), passwordStrength(), maxFileSize(), phoneNumber(), creditCard() and IBAN(), ships on master.

02 — Installation

Add the package.

ValidatorKit is distributed exclusively through the Swift Package Manager.

In a Package.swift manifest

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

In Xcode

Choose File → Add Package Dependencies…, paste the repository URL, and pick the 1.2.1 version rule:

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
    )
    .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 — Rules Reference

Every built-in rule.

All 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 — all Bool, all defaulting to false. Only the character classes you switch on are enforced.
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.
05 — 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()

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()
06 — 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.

07 — 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.
08 — 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.