HTML forms generated from your model schema — validation included, no
reflection, TinyGo/WASM-ready. You don't declare forms: you declare a
model.Definition once, and fields whose
kind is an input.* type become form inputs automatically.
This package is part of the tinywasm ecosystem — Go libraries for building full-stack web apps compiled to WebAssembly.
go get github.com/tinywasm/form # brings tinywasm/model as dependencyThe code generator tooling is covered in step 2 of the Quick Start.
Two imports: model provides the schema
types and base kinds (model.Text(), model.Int(), …); input is this
package's sub-package with the form kinds (input.Text(), input.Email(), …).
The kind you choose per field decides everything: input.* = form input +
validation; model.* = validation only (never rendered).
import (
"github.com/tinywasm/model"
"github.com/tinywasm/form/input"
)
var UserModel = model.Definition{
Name: "user",
Fields: model.Fields{
{Name: "id", Type: model.Int(), DB: &model.FieldDB{PK: true, AutoInc: true}},
{Name: "name", Type: input.Text(), NotNull: true},
{Name: "email", Type: input.Email(), NotNull: true},
{Name: "sku", Type: SKU()}, // custom input — defined in your own package
{Name: "notes", Type: model.Text()}, // validation only — never rendered
},
}Auto-increment PKs are skipped automatically (not editable).
A custom input like SKU() is a type in your own package that embeds
input.Base and configures its validation rules — it then works as a kind
like any built-in. Minimal shape (full pattern:
input/README.md):
type sku struct{ input.Base }
func SKU() input.Input {
s := &sku{}
s.Letters, s.Numbers = true, true
s.Maximum = 12
s.InitBase("", "", "text")
return s
}
func (s *sku) Clone(parentID, name string) input.Input {
c := *s
c.InitBase(parentID, name, "text")
return &c
}From your model.Definition vars, the generator emits <file>_orm.go next
to each model file: the row struct plus the model.Fielder methods
(Schema(), Pointers(), Values()). You never write these by hand.
Recommended: use the tinywasm dev
environment — it watches your model files and regenerates *_orm.go
automatically with hot reload:
go install github.com/tinywasm/app/cmd/tinywasm@latest
tinywasm -tui # interactive dev server (or -mcp for AI agents)Manual alternative: run ormc
(the standalone generator that tinywasm uses internally) once in your
module:
go install github.com/tinywasm/ormc/cmd/ormc@latest
ormcNote you pass the generated struct instance, not the Definition: the form
needs a data holder — it reads initial values from it and writes submitted
values back into it. The schema travels along anyway: the generated
Schema() method returns UserModel.Fields.
import "github.com/tinywasm/form"
f, err := form.New("parent-id", &User{Name: "John"}) // "John" pre-fills the name input
html := f.String() // SSR: render to HTML stringdom mounts components in the browser
(compiled with TinyGo):
import "github.com/tinywasm/dom"
f.LoadValues(record) // registro → formulario (el usuario selecciona)
f.Validate() // el usuario edita y guarda
f.SyncValues(record) // formulario → registro (listo para enviar)
f.OnSubmit(func(data model.Fielder, done func(error)) {
// send data to your API, then:
done(nil) // nil = success → form resets (see NoResetOnSuccess)
})
dom.Mount("root", f)Mounted forms get live per-field validation on input, a submit button bound to the submitting state, and IME-safe reactive updates. Detail: Interactivity & Mounting.
Runtime tweaks: f.Input("Field").SetPlaceholder(...),
f.SetOptions("Field", ...), f.SetValues("Field", ...).
The library ships structure, the project owns the look (CSS-first doctrine):
-
Stable class contract — every bound field renders as:
<div class='tw-field'> <input ... /> <span class='tw-field-error' aria-live='polite'></span> </div>
Hook classes:
tw-field,tw-field-error,tw-field-error--visible,tw-radio-group. Override them in your project stylesheet to theme every form at once. -
RenderCSS()(!wasm) — returns the base styles as an additivecss.Stylesheet. You don't wire it manually: the tinywasm SSR pipeline discovers package-levelRenderCSS()functions in your imports and bundles them into the initial HTML automatically. Your overrides live in the project's CSS entry point — by conventionconfig/css.goat the project root — whereRootCSS()declares token overrides and your own rules win the cascade:// config/css.go //go:build !wasm package config import "github.com/tinywasm/css" func RootCSS() *css.Stylesheet { return css.Root( css.Declare(css.ColorPrimary, "#FF6B35"), // ...your theme tokens; add Rule(".tw-field", ...) overrides here too ) }
See tinywasm/css for the token/theming contract.
-
form.SetGlobalClass("my-app-form")andf.SetClass("local-class")— adds classes to the<form>, useful for scoping:.my-app-form .tw-field { ... }.
Custom markup for custom inputs is possible by implementing form.Renderer;
see input/README.md.
18 types in input/ (full reference):
| Input | HTML type | Input | HTML type | |
|---|---|---|---|---|
Address |
text |
Password |
password |
|
Checkbox |
checkbox |
Phone |
tel |
|
Datalist |
text |
Radio |
radio |
|
Date |
date |
Rut |
text |
|
Email |
email |
Search |
search |
|
Filepath |
text |
Select |
select |
|
Gender |
radio |
Text |
text |
|
Hour |
time |
Textarea |
textarea |
|
IP |
text |
Number |
number |
Need one that isn't here? Custom inputs live in your own package: embed
input.Base, configure the Permitted rules, override Validate if needed.
Full pattern: input/README.md.
Creates a Form from any Fielder. Form id = parentID + "." + name,
where the name comes from the optional Namer interface
(FormName() string, default "form").
| Method | Description |
|---|---|
String() string |
Generates form HTML |
Render() *dom.Element |
WASM — reactive DOM tree (dom.ViewRenderer) |
SetSSR(bool) *Form |
SSR mode: adds method/action attributes |
OnSubmit(func(model.Fielder, func(error))) *Form |
WASM submit callback |
Validate() error |
Validates all inputs, returns first error |
LoadValues(model.Fielder) error |
Populates every input from data, the inverse of SyncValues |
SyncValues(model.Fielder) error |
Copies input values back into the data struct |
ValidateData(byte, model.Fielder) error |
Server-side validation (crudp.DataValidator) |
Input(fieldName string) input.Input |
Returns the input for a field name |
SetOptions(fieldName, ...fmt.KeyValue) *Form |
Options for select/radio/datalist |
SetValues(fieldName, ...string) *Form |
Sets a value programmatically |
Submit() error |
Runs sync + validate + OnSubmit callback programmatically; returns first validation error |
Reset() |
Clears all values and error messages |
NoResetOnSuccess() *Form |
Keeps values after a successful submit |
SubmitLabel(string) *Form |
Submit button text (default "Submit") |
SubmitLoadingLabel(string) *Form |
Button text while submitting (default label + "...") |
HideSubmit() *Form |
Renders without a submit button |
SetClass(...string) *Form |
Appends CSS classes to this form (on top of SetGlobalClass) |
GetID() string |
Form's HTML id |
Package-level: form.SetGlobalClass(classes ...string) — CSS classes for all
forms created afterwards.
form.New() iterates data.Schema(): a field becomes a form input iff its
Type (a model.Kind) also implements input.Input — capability by
interface, no registry, no name matching. Base kinds (model.Text(), …) are
skipped for rendering but still validate (fail-closed). Bound inputs are
positioned clones (Clone(formID, fieldName)) with constraint defaults
applied (NotNull → required) and current values bound from Pointers().
The input package stays free of dom imports (edge-safe); HTML rendering
is owned by form. Validation baselines come from model.Permitted
character whitelists — see API Reference.
- API Reference — Validate, SyncValues, Permitted detail
- Design & Architecture — core layers and philosophy
- Standard Types — the 18 input types in detail
- Interactivity & Mounting — WASM event handling
- Implementation Notes — contributors: library rules, file map, adding built-in inputs, test layout
- input/README.md — input package: custom inputs, composition,
Base