A small, opinionated SwiftUI form-validation library built for modern Swift: @Observable, Swift 6 concurrency, zero dependencies.
structCreatePlanForm:ValidatableForm,SubmittableForm{@Validated(name:"name",.isNotEmpty(message:"Required"),.minLength(3))varname:String=""varvalidatedFields:[ValidatedField<Self>]{[.init(\.name, wrappedBy: \._name)]}@MainActorfunc submit()asyncthrows->Plan{ /* … */ }}- 🎯
@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 runtime dependencies — Foundation + SwiftUI + Observation. No Combine. The only external packages are Skip's build-time integration for Android, and resolving with
SKIP_ZERO=1strips even those (see Android (Skip)). - 🤖 Android via Skip — compiles natively for Android as a Skip Fuse module; the same
@Validatedforms and modifiers drive Jetpack Compose through SkipFuseUI. - ⚡
@Observablenative — built for iOS 17+ / Swift 5.9+ macros, notObservableObject. - 🔒 Swift 6 concurrency — explicit
@MainActorisolation on the form lifecycle, noSendableheadaches for consumers.
- Swift 6.0+ (built with tools 6.3, language mode v6)
- iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1
- Xcode 16+
- Android: via Skip 1.9.5+ (optional — see Android (Skip))
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.
FormsKit is a SkipFuse (native) framework: the Swift source — including the @Validated property wrapper and the KeyPath-driven focus system — is compiled natively for Android with the Swift SDK for Android, and the SwiftUI modifiers render through SkipFuseUI → Jetpack Compose. (Skip's transpiled mode is not supported: its Swift-to-Kotlin transpiler handles neither custom property wrappers nor key paths, both of which are the heart of this library.)
In a Skip app, add FormsKit as an ordinary dependency of your Fuse module — no extra configuration; Skip detects the module's Skip/skip.yml and wires the Gradle side automatically:
.target(name:"MyApp", dependencies:[.product(name:"SkipFuseUI",package:"skip-fuse-ui"),.product(name:"FormsKit",package:"forms-kit"),], plugins:[.plugin(name:"skipstone",package:"skip")])In an Apple-only project, nothing changes at the call site, and the Skip packages are build-time-only (on Apple platforms SkipFuseUI simply re-exports SwiftUI and compiles away). If you don't want them in your dependency graph at all, resolve with the SKIP_ZERO environment variable set — the manifest then strips the Skip plugin and every Skip dependency, restoring a zero-dependency package:
SKIP_ZERO=1 swift buildPlatform notes:
- The view modifiers are implemented as custom
ViewModifiers with a twist: skip-bridge cannot represent generic types, so on Android the generic modifiers are swapped for non-generic, type-erased twins that skipstone bridges into Kotlin peers (an unbridged customViewModifierrenders as a silent no-op on Android)..formToolbar,.formValidationError, and.focused(on:equals:)render identically on both platforms. .formBindFocus(_:on:)relies on an optional-valued@FocusState, which SkipUI does not fully support yet — prefer.focused(on:equals:)(internallyBool-based) in cross-platform forms.- The view-modifier test suite runs on Apple platforms only (it hosts views via
ImageRenderer/HostingController, which don't exist on Android); all validation, controller, and focus-logic tests run on both platforms.
End-to-end example: a "Create Plan" sheet
import SwiftUI
import FormsKit
structCreatePlanForm:ValidatableForm,SubmittableForm{@Validated(name:"name",.isNotEmpty(message:"Name is required"),.minLength(3))varname:String=""@Validated(name:"email",.email())varownerEmail:String=""varvalidatedFields:[ValidatedField<Self>]{[.init(\.name, wrappedBy: \._name),.init(\.ownerEmail, wrappedBy: \._ownerEmail)]}@MainActorfunc submit()asyncthrows->Plan{tryawait api.createPlan(name: name, ownerEmail: ownerEmail)}}structCreatePlanSheet:View{@Stateprivatevarcontroller=FormController(form:CreatePlanForm())@Environment(\.dismiss)privatevardismissvarbody:someView{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{
_ =tryawait controller.submit()dismiss()}catch{ /* state == .failure(error) */ }}}}}}Wraps any Equatable value and tracks its validation state. The projected value ($field) exposes a Validated.State you can drive UI from.
Basic usage
@Validated(name:"age",.init(/* rules */))varage: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
}Validation modes
// .onChange (default) — stays quiet until invalid, then re-validates on each keystroke
@Validated(name:"name",.isNotEmpty(message:"Required"))varname:String=""
// .always — validates immediately at init time
@Validated(name:"tos", mode:.always,.isTrue(message:"Must accept"))varacceptedTOS:Bool=false
// .onSubmit — only validates when the form is submitted
@Validated(name:"bio", mode:.onSubmit,.maxLength(500))varbio:String=""Optional fields
// A second initializer exists for ExpressibleByNilLiteral types — no `= nil` needed
@Validated(name:"nickname")varnickname:String?Implement the ValidationRule protocol — typed over the value the rule validates. Return nil for valid, an error message for invalid.
Built-in string rules
@Validated(name:"email",.isNotEmpty(message:"Required"),.email(message:"Invalid email"))varemail:String=""@Validated(name:"password",.minLength(8, message:"At least 8 characters"),.maxLength(64),.pattern(#"[A-Z]"#, message:"Must contain an uppercase letter"))varpassword: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
Writing a custom rule
publicstructDivisibleBy:ValidationRule{publicletdivisor:Intpublicletmessage:Stringpublicfunc validate(value:Int)->String?{
value % divisor ==0?nil: message
}}
// Add a static factory for nice call-site syntax
publicextensionValidationRulewhere Self ==DivisibleBy{staticfunc 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"))varquantity:Int=0A form is a struct of @Validated-wrapped fields that conforms to one or more of these protocols.
Declares which fields participate in validation via a validatedFields array of key-path-driven schema entries.
Example
structSignupForm:ValidatableForm{@Validated(name:"email",.isNotEmpty(message:"Required"),.email())varemail:String=""@Validated(name:"password",.minLength(8))varpassword: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.
varvalidatedFields:[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.nameAdds an async submit() that returns a typed Output. Required to be @MainActor — see Concurrency.
Example
structCreatePlanForm:ValidatableForm,SubmittableForm{
// … fields …
@MainActorfunc submit()asyncthrows->Plan{tryawait api.createPlan(name: name)}}For "Edit" flows — hydrate a form from an existing entity. Required to be @MainActor.
Example
extensionCreatePlanForm:PopulatableForm{@MainActormutatingfunc populate(from plan:Plan){
name = plan.name
ownerEmail = plan.ownerEmail
}}
// In the sheet:
@Stateprivatevarcontroller=FormController(form:CreatePlanForm()).onAppear{ controller.form.populate(from: existingPlan)}Data is a Sendable carrier — load it off MainActor, then populate(from:) on MainActor.
@Observable @MainActor controller that wraps a form and manages its submission lifecycle.
State flow
.initial ──submit()──> .loading ──success──> .success
│
└──failure──> .failure(Error)
letcontroller=FormController(form:SignupForm())Task{do{letuser=tryawait controller.submit()
// controller.state == .success
}catchValidationError.invalid(let errors){
// Per-field errors already mapped back onto controller.form fields
}catch{
// controller.state == .failure(error)
}}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.
Example
@MainActorfunc submit()asyncthrows->User{letresponse=tryawait api.signup(email: email, password: password)iflet issues = response.fieldIssues {throwValidationError.invalid(errors: issues)
// e.g. ["email": ["Already taken"]]
// → controller.form.$email becomes .invalid(["Already taken"])
}return response.user
}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().
API surface
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
tryawait controller.submit()Renders error messages under a field when the wrapper is .invalid.
Example
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)Cancel/Submit toolbar that respects the controller's dirty/loading state, with a built-in "Discard changes?" confirmation.
Example
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).
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.
Example
structCreatePlanView:View{@Stateprivatevarcontroller=FormController(form:CreatePlanForm())varbody:someView{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.
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).
Example
structCreatePlanView:View{@Stateprivatevarcontroller=FormController(form:CreatePlanForm())@FocusStateprivatevarfocus:PartialKeyPath<CreatePlanForm>?varbody:someView{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.
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) |
Why submit() is @MainActor (and why that's fine)
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.
Why ValidatableForm is not Sendable
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 SendableData value off-MainActor, then call populate(from:) on MainActor.
- 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.
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.
- Localized default error messages via
String(localized:bundle: .module). - Themeable error color on
formValidationError(currently hardcoded.red). - Localizable strings in
FormToolbarViewModifier("Discard Changes?", etc.). - Additional rule families (
Number,Date,Collection).
MIT — see LICENSE.