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.
$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.
$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.
| Type | Example | Go | TypeScript | Zod |
|---|---|---|---|---|
| string | name:string | string | string | z.string() |
| text | bio:text | string | string | z.string() |
| richtext | body:richtext | string | string | z.string() |
| int | qty:int | int | number | z.number().int() |
| uint | stock:uint | uint | number | z.number().int().nonnegative() |
| float | weight:float | float64 | number | z.number() |
| money | price:money | money.Money | Money | MoneySchema |
| bool | active:bool | bool | boolean | z.boolean() |
| toggle | active:toggle | bool | boolean | z.boolean() |
| select | status:select[draft, paid] | string | "draft" | "paid" | z.enum([...]) |
| radio | plan:radio[free, pro] | string | "free" | "pro" | z.enum([...]) |
| check | tags:check[news, ops] | datatypes.JSONSlice[string] | ("news" | "ops")[] | z.array(z.enum([...])) |
| datetime | published_at:datetime | *time.Time | string | null | z.string().nullable() |
| date | due:date | *time.Time | string | null | z.string().nullable() |
| slug | slug:slug[title] | string | string | z.string() |
| belongs_to | category:belongs_to | string (FK) | string | z.string().uuid() |
| many_to_many | tags:many_to_many[Tag] | []string | string[] | z.array(z.string().uuid()) |
| string_array | sizes:string_array | datatypes.JSONSlice[string] | string[] | z.array(z.string()) |
| file | image:file[image] | *files.FileRef | FileRef | null | FileRefSchema.nullable() |
| files | gallery:files[image] | files.FileRefs | FileRef[] | z.array(FileRefSchema) |
| email:email:unique | string (size 254) | string | EmailSchema | |
| url | website:url | string (size 2048) | string | UrlSchema |
| domain | host:domain | string (size 253) | string | DomainSchema |
| tel | phone:tel[UG] | string (size 32, E.164) | string | PhoneSchema |
| country | country:country[UG] | string (size 2) | string | CountrySchema |
| color | brand:color | string (size 7) | string | ColorSchema |
| percent | discount:percent | float64 (decimal 5,2) | number | PercentSchema |
| rating | score:rating[10] | int | number | ratingSchema(10) |
| time | opens_at:time | string (HH:MM) | string | TimeSchema |
| json | settings:json | datatypes.JSON | unknown | JsonValueSchema |
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.
| Type | Form control | Table format |
|---|---|---|
| string | Text input | text |
| text | Textarea | text |
| richtext | Rich-text editor (Tiptap) | richtext |
| int / uint / float | Number input | text |
| money | Amount input + currency picker | money |
| bool | Switch | boolean |
| toggle | Switch | boolean |
| select | Searchable dropdown (value=Label options) | text |
| radio | Radio-button group (single choice) | text |
| check | Checkbox group (multi) | — |
| datetime / date | Date / time picker | relative |
| slug | Auto-filled (read-only) | text |
| belongs_to | Relationship select (searchable) | text (dotted key) |
| many_to_many | Multi-select | — |
| string_array | Tag / chips input | text |
| file | Dropzone (single) | file |
| files | Dropzone (multiple) | files |
| Email input, lowercased on blur | email (mailto: link) | |
| url | URL input | link (opens in a new tab) |
| domain | Domain input that strips a pasted scheme and path | domain (link to https://) |
| tel | Searchable country picker with flags and dial codes, number formatted as you type | tel (formatted, tel: link) |
| country | Searchable country picker | country (flag and name) |
| color | Colour picker plus hex box | color (swatch and hex) |
| percent | Number input with a % suffix | percent (12.5%) |
| rating | Stars (a keyboard radio group) | rating (stars) |
| time | Time input | time (2:30 PM in the viewer's clock) |
| json | JSON editor that checks as you type | json (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.
$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
| Type | Stored as | Refused |
|---|---|---|
| Lowercased and trimmed: ada@example.com | Anything that is not a bare address, including "Ada <ada@example.com>" | |
| url | As given | Any scheme but http and https, or no host |
| domain | A 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.de | A name DNS could not resolve, such as one without a dot |
| tel | E.164: 0772 123456 in Uganda becomes +256772123456 | A number that is not valid for its country, by libphonenumber's metadata |
| country | ISO 3166-1 alpha-2 in upper case: UG | A code ISO has not assigned |
| color | #rrggbb in lower case: #ABC becomes #aabbcc | Anything that is not a hex colour |
| percent | Rounded to two decimals | Below 0 or above 100 |
| rating | Whole stars; 0 means not rated | A fraction, or more stars than the field has |
| time | HH:MM on a 24-hour clock; seconds are dropped | A time that does not exist, such as 25:00 |
| json | jsonb on Postgres, JSON on MySQL, text on SQLite | Text 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.
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.