Skip to content

Repository files navigation

FormsKit

PlatformsSwift 6.3ReleasecodecovLicense

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{ /* … */ }}

Features

  • 🎯 @Validated<T> property wrapper — declarative per-field validation for any Equatable value, not just String.
  • 🧩 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 protocolsValidatableForm, SubmittableForm, PopulatableForm model a form as a value type.
  • 🎛️ FormController<T>@Observable controller 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:) from submit() and per-field errors flow back onto the corresponding @Validated fields automatically.
  • 🧰 Built-in string rulesisNotEmpty, 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 + interactiveDismissDisabled when 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=1 strips even those (see Android (Skip)).
  • 🤖 Android via Skip — compiles natively for Android as a Skip Fuse module; the same @Validated forms and modifiers drive Jetpack Compose through SkipFuseUI.
  • @Observable native — built for iOS 17+ / Swift 5.9+ macros, not ObservableObject.
  • 🔒 Swift 6 concurrency — explicit @MainActor isolation on the form lifecycle, no Sendable headaches 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+
  • Android: via Skip 1.9.5+ (optional — see Android (Skip))

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.

Android (Skip)

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 build

Platform 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 custom ViewModifier renders 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:) (internally Bool-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.

Quick start

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) */ }}}}}}

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.

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?

Validation rules

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 whitespace
  • minLength(_:message:) / maxLength(_:message:)
  • pattern(_:message:) — NSRegularExpression match
  • email(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=0

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.

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.name

SubmittableForm

Adds 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)}}

PopulatableForm

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.


FormController<T>

@Observable @MainActor controller that wraps a form and manages its submission lifecycle.

Submission state machine

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)
}}

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.

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
}

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 =false

You 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

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()

SwiftUI modifiers

.formValidationError(for:)

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)

.formToolbar(controller:onSubmit:)

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).

.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.

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.

.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).

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.


Concurrency

FormsKit has a deliberate isolation shape:

Type / requirementIsolation
FormController@MainActor
SubmittableForm.submit()@MainActor
PopulatableForm.populate(from:)@MainActor
ValidatableFormunconstrained
Validated<T>, ValidationRule, ValidatedField, rulesunconstrained (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.


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 @Validated is a property wrapper for per-field state; the pointfree type is an applicative result enum.
  • An ObservableObject library — iOS 17 / @Observable is 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.skill into your skills config, or copy Skills/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.md into your agent's system prompt, rules file, or custom-instructions field. references/api-cheatsheet.md is 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 formValidationError (currently hardcoded .red).
  • Localizable strings in FormToolbarViewModifier ("Discard Changes?", etc.).
  • Additional rule families (Number, Date, Collection).

License

MIT — see LICENSE.

About

Modern SwiftUI form validation for iOS 17+. @Validate property wrapper with composable typed rules over any Equatable, @observable FormController with a submission state machine and automatic server-error remap, plus SwiftUI modifiers for inline errors and a dirty-aware toolbar. Swift 6, zero dependencies.

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages