rozd/forms-kit
A small, opinionated SwiftUI form-validation library built for modern Swift: @Observable, Swift 6 concurrency, zero dependencies.
Features
- π―
@Validated<T>property wrapper β declarative per-field validation for anyEquatablevalue, not justString. - π§© Composable typed rules β chain multiple rules per field; each rule is a plain value type.
- π Validation modes β
.always,.onChange(default),.onSubmit. Errors auto-clear as the user fixes them. - π Form protocols β
ValidatableForm,SubmittableForm,PopulatableFormmodel a form as a value type. - ποΈ
FormController<T>β@Observablecontroller with a submission state machine (initial/loading/success/failure). - π― Focus management β programmatic
controller.focus, key-path-driven field traversal, automatic focus on the first invalid field after submission failure. - π°οΈ Server-error remap β throw
ValidationError.invalid(errors:)fromsubmit()and per-field errors flow back onto the corresponding@Validatedfields automatically. - π§° Built-in string rules β
isNotEmpty,minLength,maxLength,pattern,email. - π¨ SwiftUI modifiers β
.formValidationError(for:)for inline field errors,.formToolbar(...)for a Cancel/Submit toolbar,.focused(on:equals:)and.formBindFocus(_:on:)for focus traversal. - π‘οΈ Dirty-state-aware dismiss β discard confirmation dialog +
interactiveDismissDisabledwhen the form has unsaved changes. - πͺΆ Zero dependencies β Foundation + SwiftUI + Observation. No Combine, no third-party packages.
- β‘
@Observablenative β built for iOS 17+ / Swift 5.9+ macros, notObservableObject. - π Swift 6 concurrency β explicit
@MainActorisolation on the form lifecycle, noSendableheadaches for consumers.
Requirements
- Swift 6.0+ (built with tools 6.3, language mode v6)
- iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1
- Xcode 16+
Installation
Swift Package Manager β add to Package.swift:
dependencies: [
.package(url: "https://github.com/rozd/forms-kit.git", from: "0.1.0"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "FormsKit", package: "forms-kit"),
]),
]Or in Xcode: File β Add Package Dependenciesβ¦ and paste the repository URL.
Quick start
<details> <summary>End-to-end example: a "Create Plan" sheet</summary>
import SwiftUI
import FormsKit
struct CreatePlanForm: ValidatableForm, SubmittableForm {
@Validated(name: "name", .isNotEmpty(message: "Name is required"), .minLength(3))
var name: String = ""
@Validated(name: "email", .email())
var ownerEmail: String = ""
var validatedFields: [ValidatedField<Self>] {
[.init(\.name, wrappedBy: \._name),
.init(\.ownerEmail, wrappedBy: \._ownerEmail)]
}
@MainActor
func submit() async throws -> Plan {
try await api.createPlan(name: name, ownerEmail: ownerEmail)
}
}
struct CreatePlanSheet: View {
@State private var controller = FormController(form: CreatePlanForm())
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
TextField("Name", text: $controller.form.name)
.focused(on: $controller, equals: \.name)
.formValidationError(for: controller.form.$name)
TextField("Owner email", text: $controller.form.ownerEmail)
.focused(on: $controller, equals: \.ownerEmail)
.formValidationError(for: controller.form.$ownerEmail)
}
.navigationTitle("New Plan")
.formToolbar(controller: controller) {
Task {
do {
_ = try await controller.submit()
dismiss()
} catch { /* state == .failure(error) */ }
}
}
}
}
}</details>
Validation
@Validated<T> property wrapper
Wraps any Equatable value and tracks its validation state. The projected value ($field) exposes a Validated.State you can drive UI from.
<details> <summary>Basic usage</summary>
@Validated(name: "age", .init(/* rules */)) var age: Int = 18
// Read state from the projected value
switch $age {
case .idle: // not edited yet
case .editing: // user touched the field, not yet validated
case .valid: // passed all rules
case .invalid(let messages): // failed; messages contains all rule failures
}</details>
<details> <summary>Validation modes</summary>
// .onChange (default) β stays quiet until invalid, then re-validates on each keystroke
@Validated(name: "name", .isNotEmpty(message: "Required"))
var name: String = ""
// .always β validates immediately at init time
@Validated(name: "tos", mode: .always, .isTrue(message: "Must accept"))
var acceptedTOS: Bool = false
// .onSubmit β only validates when the form is submitted
@Validated(name: "bio", mode: .onSubmit, .maxLength(500))
var bio: String = ""</details>
<details> <summary>Optional fields</summary>
// A second initializer exists for ExpressibleByNilLiteral types β no `= nil` needed
@Validated(name: "nickname") var nickname: String?</details>
Validation rules
Implement the ValidationRule protocol β typed over the value the rule validates. Return nil for valid, an error message for invalid.
<details> <summary>Built-in string rules</summary>
@Validated(name: "email",
.isNotEmpty(message: "Required"),
.email(message: "Invalid email"))
var email: String = ""
@Validated(name: "password",
.minLength(8, message: "At least 8 characters"),
.maxLength(64),
.pattern(#"[A-Z]"#, message: "Must contain an uppercase letter"))
var password: String = ""Available rules in the StringValidationRules/ folder:
isNotEmpty(message:)β non-empty after trimming whitespaceminLength(:message:)/maxLength(:message:)pattern(_:message:)β NSRegularExpression matchemail(message:)β basic RFC-ish email shape
</details>
<details> <summary>Writing a custom rule</summary>
public struct DivisibleBy: ValidationRule {
public let divisor: Int
public let message: String
public func validate(value: Int) -> String? {
value % divisor == 0 ? nil : message
}
}
// Add a static factory for nice call-site syntax
public extension ValidationRule where Self == DivisibleBy {
static func divisibleBy(_ n: Int, message: String) -> DivisibleBy {
DivisibleBy(divisor: n, message: message)
}
}
// Use it
@Validated(name: "quantity", .divisibleBy(5, message: "Must be a multiple of 5"))
var quantity: Int = 0</details>
Forms
A form is a struct of @Validated-wrapped fields that conforms to one or more of these protocols.
ValidatableForm
Declares which fields participate in validation via a validatedFields array of key-path-driven schema entries.
<details> <summary>Example</summary>
struct SignupForm: ValidatableForm {
@Validated(name: "email", .isNotEmpty(message: "Required"), .email())
var email: String = ""
@Validated(name: "password", .minLength(8))
var password: String = ""
// First arg is the value key path (\.email); `wrappedBy:` carries the
// wrapper key path (\._email). Leading dot is required by Swift 6 when the
// root type is inferred from context. Using the value path here makes
// \.email writable as a focus identifier from any view file.
var validatedFields: [ValidatedField<Self>] {
[.init(\.email, wrappedBy: \._email),
.init(\.password, wrappedBy: \._password)]
}
}
// Free helpers from the protocol extension:
form.isValid // Bool
form.validationErrors // [String: [String]] keyed by Validated.name</details>
SubmittableForm
Adds an async submit() that returns a typed Output. Required to be @MainActor β see Concurrency.
<details> <summary>Example</summary>
struct CreatePlanForm: ValidatableForm, SubmittableForm {
// β¦ fields β¦
@MainActor
func submit() async throws -> Plan {
try await api.createPlan(name: name)
}
}</details>
PopulatableForm
For "Edit" flows β hydrate a form from an existing entity. Required to be @MainActor.
<details> <summary>Example</summary>
extension CreatePlanForm: PopulatableForm {
@MainActor
mutating func populate(from plan: Plan) {
name = plan.name
ownerEmail = plan.ownerEmail
}
}
// In the sheet:
@State private var controller = FormController(form: CreatePlanForm())
.onAppear { controller.form.populate(from: existingPlan) }Data is a Sendable carrier β load it off MainActor, then populate(from:) on MainActor.
</details>
`FormController<T>`
@Observable @MainActor controller that wraps a form and manages its submission lifecycle.
Submission state machine
<details> <summary>State flow</summary>
.initial ββsubmit()ββ> .loading ββsuccessββ> .success
β
βββfailureββ> .failure(Error)let controller = FormController(form: SignupForm())
Task {
do {
let user = try await controller.submit()
// controller.state == .success
} catch ValidationError.invalid(let errors) {
// Per-field errors already mapped back onto controller.form fields
} catch {
// controller.state == .failure(error)
}
}</details>
Server-side error remap
When submit() throws ValidationError.invalid(errors:), the controller maps each per-field error onto the matching @Validated field by name. The next render shows them inline automatically.
<details> <summary>Example</summary>
@MainActor
func submit() async throws -> User {
let response = try await api.signup(email: email, password: password)
if let issues = response.fieldIssues {
throw ValidationError.invalid(errors: issues)
// e.g. ["email": ["Already taken"]]
// β controller.form.$email becomes .invalid(["Already taken"])
}
return response.user
}</details>
Focus management
The controller exposes a key-path-driven focus property: focus: PartialKeyPath<T>?. Setting it programmatically moves keyboard focus to the matching field; SwiftUI focus changes flow back into it via the focus view modifiers (see .focused(on:equals:) and .formBindFocus(_:on:)).
controller.focus is freely mutable from MainActor β useful for "focus on appear," "focus after server-side correction," or scroll-to-error overlays that observe it.
Auto-focus on submit failure is on by default. When submit() produces validation errors (either pre-flight or from server-side remap), the controller calls focusFirstInvalidField(), which sets focus to the first invalid field's key path. Disable with:
controller.shouldFocusFirstInvalidFieldOnSubmit = falseYou can also call focusFirstInvalidField() manually, or set controller.focus = \.fieldName directly. Any KeyPath<Form, V> works as a focus identifier β including non-validated fields β but only validated fields participate in focusFirstInvalidField().
Convenience accessors
<details> <summary>API surface</summary>
controller.form // T (the form struct)
controller.state // .initial / .loading / .success / .failure
controller.focus // PartialKeyPath<T>? β currently focused field
controller.isDirty // any field has been edited
controller.isValid // all fields are .valid
controller.isLoading // state == .loading
controller.shouldFocusFirstInvalidFieldOnSubmit // Bool, default true
controller.focusFirstInvalidField() // move focus to first invalid field
controller.validate() // runs all rules; mutates field states
try await controller.submit()</details>
SwiftUI modifiers
.formValidationError(for:)
Renders error messages under a field when the wrapper is .invalid.
<details> <summary>Example</summary>
TextField("Email", text: $controller.form.email)
.formValidationError(for: controller.form.$email)
// Optional layout overrides
TextField("Bio", text: $controller.form.bio)
.formValidationError(for: controller.form.$bio, alignment: .leading, spacing: 6)</details>
.formToolbar(controller:onSubmit:)
Cancel/Submit toolbar that respects the controller's dirty/loading state, with a built-in "Discard changes?" confirmation.
<details> <summary>Example</summary>
NavigationStack {
Form { /* β¦ */ }
.navigationTitle("New Plan")
.formToolbar(controller: controller) {
Task { try? await controller.submit() }
}
}
// Customize titles or opt out of dismiss protection
.formToolbar(
controller: controller,
cancelTitle: "Close",
submitTitle: "Create",
preventsAccidentalDismiss: false,
) {
Task { try? await controller.submit() }
}Submit is auto-disabled when !isDirty || isLoading. Cancel triggers a confirmation dialog when the form is dirty and preventsAccidentalDismiss is on (default true).
</details>
.focused(on:equals:)
Zero-ceremony focus binding. The modifier internally owns a hidden @FocusState<Bool> and bidirectionally syncs it with controller.focus. No @FocusState declaration on the view, no separate bridging modifier.
<details> <summary>Example</summary>
struct CreatePlanView: View {
@State private var controller = FormController(form: CreatePlanForm())
var body: some View {
Form {
TextField("Name", text: $controller.form.name)
.focused(on: $controller, equals: \.name)
.formValidationError(for: controller.form.$name)
TextField("Description", text: $controller.form.description)
.focused(on: $controller, equals: \.description)
}
}
}Use value key paths (\.name), not wrapper key paths (\._name) β they're universally accessible across view files. Any KeyPath<Form, V> works as a focus identifier; non-validated focusable fields are first-class.
</details>
.formBindFocus(_:on:)
Shared @FocusState binding. Use this when you need the @FocusState for something else in the same view (e.g., a non-form search field, or a scroll-to-error overlay observing focus.wrappedValue).
<details> <summary>Example</summary>
struct CreatePlanView: View {
@State private var controller = FormController(form: CreatePlanForm())
@FocusState private var focus: PartialKeyPath<CreatePlanForm>?
var body: some View {
Form {
TextField("Name", text: $controller.form.name)
.focused($focus, equals: \.name)
.formValidationError(for: controller.form.$name)
TextField("Description", text: $controller.form.description)
.focused($focus, equals: \.description)
}
.formBindFocus($focus, on: controller)
}
}The bridge is bidirectional β writes to $focus flow into controller.focus, and programmatic writes to controller.focus flow back into $focus. The two focus modifiers (.focused(on:equals:) and .formBindFocus(_:on:)) can be mixed on different fields in the same form.
</details>
Concurrency
FormsKit has a deliberate isolation shape:
| Type / requirement | Isolation | |---|---| | FormController | @MainActor | | SubmittableForm.submit() | @MainActor | | PopulatableForm.populate(from:) | @MainActor | | ValidatableForm | unconstrained | | Validated<T>, ValidationRule, ValidatedField, rules | unconstrained (value types) |
<details> <summary>Why <code>submit()</code> is <code>@MainActor</code> (and why that's fine)</summary>
A @MainActor async function only enters and resumes on MainActor. Any await inside (URLSession, Firestore, etc.) suspends and frees MainActor while the awaited work runs on its own executor; resumption hops back to MainActor for the next line. So your network call doesn't block the UI β only the entry, the resume, and assignments to the controller happen on MainActor.
The benefit: the form (T) never crosses an isolation boundary, so consumers don't need to make every form, every field, and every rule Sendable.
</details>
<details> <summary>Why <code>ValidatableForm</code> is not <code>Sendable</code></summary>
It's intentional. Making the protocol Sendable would force the constraint through every layer (T, each ValidationRule, the closures inside ValidatedField) for a capability the design doesn't use β forms don't cross actor boundaries in normal flows. If you need to load form data off-MainActor, use PopulatableForm: load a Sendable Data value off-MainActor, then call populate(from:) on MainActor.
</details>
What this package is *not*
- A UI kit β four modifiers total, intentionally minimal styling.
- A binding/navigation/router helper.
- A general-purpose
Validated<E, A>applicative type (cf.pointfreeco/swift-validated) β different abstraction. FormsKit's@Validatedis a property wrapper for per-field state; the pointfree type is an applicative result enum. - An
ObservableObjectlibrary β iOS 17 /@Observableis the floor.
Using FormsKit with an AI coding assistant
FormsKit ships an agent skill at Skills/formskit-expert/ that teaches an AI assistant how to build forms with this package β the protocols, the @Validated property wrapper, the FormController lifecycle, the focus modifiers, and the gotchas worth knowing before writing code. It works with any AI coding assistant; how you install it depends on the tool:
- Claude Code: drop the bundled file at
Skills/formskit-expert.skillinto your skills config, or copySkills/formskit-expert/into~/.claude/skills/to make it available across all projects. - Cursor / Cline / Copilot / Codex / ChatGPT: paste the contents of
Skills/formskit-expert/SKILL.mdinto your agent's system prompt, rules file, or custom-instructions field.references/api-cheatsheet.mdis a compact API reference you can attach as additional context. - Other: feed the markdown to whatever your agent reads at session start. The Skill is plain prose and is self-contained.
Roadmap
- Localized default error messages via
String(localized:bundle: .module). - Themeable error color on
FormValidationErrorModifier(currently hardcoded.red). - Localizable strings in
FormToolbarViewModifier("Discard Changes?", etc.). - Additional rule families (
Number,Date,Collection).
License
MIT β see LICENSE.
Package Metadata
Repository: rozd/forms-kit
Default branch: main
README: README.md