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 toValidationRule. - 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.
tvOS 13+ · watchOS 6+
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.
Add the package.
ValidatorKit is distributed exclusively through the Swift Package Manager.
In a Package.swift manifest
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:
https://github.com/Alhiane/ValidatorKit.git
Then import it wherever you validate:
import ValidatorKit
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.
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.
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.
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.
| Rule | Behaviour |
|---|---|
| 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. |
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:
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
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:
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
let schema = ValidationSchema()
.field("score").numeric().min(0).max(100)
.field("rating").range(1...5)
.field("sku").pattern("^[A-Z]{3}-\\d{3}$")
.ready()
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:
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.
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/ValidatorKitTestsis the pattern to follow. - New rules live in
Sources/ValidatorKit/Rules/as a type conforming toValidationRule, plus a matching chainable method onFieldValidator. - If a rule needs a user-facing message, add its default and key to
ValidationMessageand a translation to each of the four.lprojbundles. - Keep SwiftLint clean rather than disabling it.
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.