Core Concepts · Reference

Field Types

The complete set of field types you can pass to grit generate resource --fields. Each one drives the Go struct field, the shared TypeScript type, the Zod schema, and the default admin form control and table column — from one declaration.

Field syntax

A field is name:type, with optional extra segments. Separate multiple fields with commas inside --fields.

Terminal
$grit generate resource Product --fields "name:string,price:money,category:belongs_to[Category],gallery:files[image]"

A type that takes an argument takes it in brackets, and a field that only applies sometimes says so in words. Read one left to right and it tells you everything about itself.

cycle : select[monthly, annually] if kind == recurring
│ │ │ │
│ │ │ └─ when this field applies
│ │ └─ this type's argument: the options
│ └─ the type
└─ the name: the column, and the JSON key

Labels are derived from the values, so monthly renders as "Monthly" and in_progress as "In Progress". Give one explicitly only when it differs: [monthly, annually="Every year"].

Only some types take an argument: select, radio and check take their options, slug takes the field to slugify, belongs_to, one_to_one and many_to_many take the related model, file and files take what they accept, tel and country take a country code, and rating takes a number of stars. Every other type goes straight from its name to its modifiers, which are appended with colons: email:email:unique.

Terminal
$grit generate resource Project --fields "name:string, kind:select[one_off, recurring], cycle:select[monthly, annually] if kind == recurring, owner:belongs_to[User]"

The older positional spelling still parses, and a great deal of existing documentation and tutorials use it: cycle:select:monthly|annually:when=kind=recurring. The two produce identical fields. Prefer the readable one in anything new: the colon means four different things in the positional form, and the pipe is a shell pipe, so an unquoted example silently loses half its options.

  • Modifiersname:string:unique — append :unique, :required, :optional or :when=. They always come last, after whatever argument the type takes: phone:tel[UG]:unique, category:belongs_to[Category]:optional. String fields default to required; everything else defaults to optional.
  • Conditionalcycle:select[monthly, annually] if kind == recurring — the field applies only while another field of the same resource holds a particular value. kind is a sibling field, named as it appears in the spec; recurring is one of its stored values, not a label. The admin hides the input and drops its required rule when the condition is false; the model gets requiredif:"kind=recurring", which the API enforces on every write. See conditional fields.
  • Auto-numbernumber:string:auto:INV (string only) — the server fills it from an atomic, gap-free counter (INV-202607-0001) in BeforeCreate. Optional and hidden from the form; the prefix is optional. Shorthand for grit generate sequence; use that command directly for yearly/never resets or a custom width.
  • Encryptednotes:text:encrypted (string, text and richtext only). Stored with AES-256-GCM at rest, plaintext in code and over the API; needs FIELD_ENCRYPTION_KEY. Cannot be :unique, and is left out of search, sorting and filters, because ciphertext changes on every write.
  • Optionsstatus:select[draft, sent, paid] (select / radio / check). Labels are derived by capitalising each word (in_progress → In Progress); give one only when it differs: [draft, sent="Sent to client"]. The positional spelling status:select:draft=Draft|sent=Sent still parses.
  • Slug sourceslug:slug[title] — the argument is the field to slugify. Slugs are auto-unique and generated on save.
  • belongs_tocategory:belongs_to (model inferred → Category) or author:belongs_to[User] (explicit). Creates a <name>_id UUID foreign-key column.
  • many_to_manytags:many_to_many[Tag] — the related model is required. GORM builds the join table.
  • File acceptsimage:file[image], doc:file[all], or att:files[pdf, doc, image]. Aliases: image, video, audio, pdf, doc, all — or a bracketed list.
  • Moneyprice:money — stores an integer count of minor units plus an ISO 4217 currency code, in two columns (price_amount, price_currency). Use it for anything you will add up. See the money page.
  • tel optionphone:tel[UG] sets the country the picker starts on and a local number is read in. Without it the picker starts on the browser's country. Modifiers follow the option: phone:tel[UG]:unique.
  • country optioncountry:country[UG] makes UG the value a new record starts with.
  • rating optionscore:rating[10] gives the field 10 stars, from 2 to 10. The default is 5.

When is a field conditional, rather than just optional?

Optional means the answer may be blank. Conditional means the question does not apply. The test: if you asked it about a record where the condition is false, would the answer be meaningless rather than merely empty? A billing cycle on a one-off project is meaningless, so it is conditional. A phone number nobody gave you is empty, so it is optional.

Two things to get right in when=kind=recurring. The field name is a sibling on the same resource, written as it appears in the spec, and it must exist, or the command is refused. The value is the stored value, which for a select is the left-hand side of recurring=Recurring, not the label; it is compared as text, so case matters.

It works on any field type, including a relationship: a plan that only applies to a recurring project is plan:belongs_to[Plan]:when=kind=recurring. For a condition that is more than one field equalling one value, write the predicate by hand in the resource definition and the rule in the service.

Type mapping

How each field type resolves across the stack. Source of truth: internal/generate/field.go.

One field declarationResolves across the stackmaps toprice:money--fieldsGo struct fieldmoney.MoneyTypeScript typeMoneyZod ruleMoneySchemaAdmin formamount + currencyTable columnformatted, right-aligned
DeclarationGoTypeScriptAdmin UI
Declare a field once — Grit resolves it into Go, TypeScript, Zod, and the admin UI
TypeExampleGoTypeScriptZod
stringname:stringstringstringz.string()
textbio:textstringstringz.string()
richtextbody:richtextstringstringz.string()
intqty:intintnumberz.number().int()
uintstock:uintuintnumberz.number().int().nonnegative()
floatweight:floatfloat64numberz.number()
moneyprice:moneymoney.MoneyMoneyMoneySchema
boolactive:boolboolbooleanz.boolean()
toggleactive:toggleboolbooleanz.boolean()
selectstatus:select[draft, paid]string"draft" | "paid"z.enum([...])
radioplan:radio[free, pro]string"free" | "pro"z.enum([...])
checktags:check[news, ops]datatypes.JSONSlice[string]("news" | "ops")[]z.array(z.enum([...]))
datetimepublished_at:datetime*time.Timestring | nullz.string().nullable()
datedue:date*time.Timestring | nullz.string().nullable()
slugslug:slug[title]stringstringz.string()
belongs_tocategory:belongs_tostring (FK)stringz.string().uuid()
many_to_manytags:many_to_many[Tag][]stringstring[]z.array(z.string().uuid())
string_arraysizes:string_arraydatatypes.JSONSlice[string]string[]z.array(z.string())
fileimage:file[image]*files.FileRefFileRef | nullFileRefSchema.nullable()
filesgallery:files[image]files.FileRefsFileRef[]z.array(FileRefSchema)
emailemail:email:uniquestring (size 254)stringEmailSchema
urlwebsite:urlstring (size 2048)stringUrlSchema
domainhost:domainstring (size 253)stringDomainSchema
telphone:tel[UG]string (size 32, E.164)stringPhoneSchema
countrycountry:country[UG]string (size 2)stringCountrySchema
colorbrand:colorstring (size 7)stringColorSchema
percentdiscount:percentfloat64 (decimal 5,2)numberPercentSchema
ratingscore:rating[10]intnumberratingSchema(10)
timeopens_at:timestring (HH:MM)stringTimeSchema
jsonsettings:jsondatatypes.JSONunknownJsonValueSchema

Primary keys are always UUID string (never uint); a belongs_to FK column is a matching UUID string.

Default admin rendering

Each type also picks a default form control and DataTable column format in the generated admin resource. You can override either in the resource definition.

TypeForm controlTable format
stringText inputtext
textTextareatext
richtextRich-text editor (Tiptap)richtext
int / uint / floatNumber inputtext
moneyAmount input + currency pickermoney
boolSwitchboolean
toggleSwitchboolean
selectSearchable dropdown (value=Label options)text
radioRadio-button group (single choice)text
checkCheckbox group (multi)—
datetime / dateDate / time pickerrelative
slugAuto-filled (read-only)text
belongs_toRelationship select (searchable)text (dotted key)
many_to_manyMulti-select—
string_arrayTag / chips inputtext
fileDropzone (single)file
filesDropzone (multiple)files
emailEmail input, lowercased on bluremail (mailto: link)
urlURL inputlink (opens in a new tab)
domainDomain input that strips a pasted scheme and pathdomain (link to https://)
telSearchable country picker with flags and dial codes, number formatted as you typetel (formatted, tel: link)
countrySearchable country pickercountry (flag and name)
colorColour picker plus hex boxcolor (swatch and hex)
percentNumber input with a % suffixpercent (12.5%)
ratingStars (a keyboard radio group)rating (stars)
timeTime inputtime (2:30 PM in the viewer's clock)
jsonJSON editor that checks as you typejson (collapsible)

Formatted types

email, url, domain, tel, country, color, percent, rating, time and json are stored as plain columns with a known shape. The API checks and normalises them on every write that goes through a model: create, update, PATCH, bulk edit, CSV import and sync push. The check lives in internal/fieldtypes, which reads the format tag on the model field.

Terminal
$grit generate resource Company --fields "name:string,email:email:unique,website:url,host:domain,phone:tel[UG],country:country,brand:color,discount:percent,score:rating[10],opens_at:time,settings:json" --seed --faker
TypeStored asRefused
emailLowercased and trimmed: ada@example.comAnything that is not a bare address, including "Ada <ada@example.com>"
urlAs givenAny scheme but http and https, or no host
domainA bare host in lowercase punycode: a pasted https://www.Example.co.ug/about becomes www.example.co.ug, and münchen.de becomes xn--mnchen-3ya.deA name DNS could not resolve, such as one without a dot
telE.164: 0772 123456 in Uganda becomes +256772123456A number that is not valid for its country, by libphonenumber's metadata
countryISO 3166-1 alpha-2 in upper case: UGA code ISO has not assigned
color#rrggbb in lower case: #ABC becomes #aabbccAnything that is not a hex colour
percentRounded to two decimalsBelow 0 or above 100
ratingWhole stars; 0 means not ratedA fraction, or more stars than the field has
timeHH:MM on a 24-hour clock; seconds are droppedA time that does not exist, such as 25:00
jsonjsonb on Postgres, JSON on MySQL, text on SQLiteText that does not parse as JSON

A refused value is a 422 in the usual error shape, with the field named in details. The admin form checks the same rules before it sends, and shows the API's message if one gets through:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Phone is not a valid phone number for UG",
"details": { "phone": "Phone is not a valid phone number for UG" }
}
}

Phone numbers are validated with libphonenumber's metadata on both sides: github.com/nyaruka/phonenumbers in the API and libphonenumber-js in the browser, so the form and the API agree about which numbers are valid. The Go module is added to a project the first time it generates a tel field, not before. The country picker, shared by tel and country, is a Base UI combobox: type a country's name, its code (UG) or its calling code (256), move with the arrow keys and press Enter.

With --faker, every formatted field seeds values its own rule accepts, with the row number mixed in so a unique column seeds thousands of rows without a collision: valid mobile numbers from twenty countries, addresses such as ada.lovelace.12@example.com, ratings weighted towards four and five stars, and times in business hours.

grit g field adds any of them to an existing resource except json, which needs gorm.io/datatypes in the model: regenerate the resource for that one. The Expo form uses the phone keyboard and checks E.164 for tel, and the matching keyboard for email, url and domain; the desktop forms use the browser's own email, url, tel, time and colour inputs.

Escape hatchThe generated types are a starting point. Edit the Go struct tags, tune the Zod rules in packages/shared/schemas, or override the form/table rendering in the admin resource definition — then run grit sync to keep TypeScript in step with Go. Grit generates opinions, not a cage.