Records
A Record is a schema-bound dictionary that carries its type information, validation state, and metadata. Records are the primary data structure for building HTML forms, processing user input, and working with database rows in Basil web applications.
Key insight: A Record = Schema + Data + Errors. Nothing more.
@schema User {
name: string(min: 2, required)
email: email(required)
}
let user = User({name: "Alice", email: "alice@example.com"})
user.name // "Alice" (data access)
user.isValid() // false (not yet validated)
let validated = user.validate()
validated.isValid() // true
validated.errors() // {}
Why Records?
When building websites with database-driven content, you repeatedly face the same challenges:
- Validating user input β checking required fields, formats, constraints
- Displaying validation errors β showing users what went wrong
- Populating forms β filling inputs with existing data or defaults
- Type safety β ensuring form data matches your data model
Records solve all of these in a single, composable type. They flow naturally from database to form and back:
Database β Record β HTML Form β User edits β Record β Validation β Database
Creating Records
From a Schema
The primary way to create a Record is by calling a schema with a dictionary:
@schema User {
name: string(min: 2, required)
email: email(required)
age: int(min: 0)
}
// Dict β Record
let user = User({name: "Alice", email: "alice@example.com", age: 30})
From Form Data
In a Basil handler, props contains form data as a dictionary. Pass it directly to your schema:
export save = fn(props) {
let form = User(props).validate()
if (form.isValid()) {
// Save to database
} else {
// Re-render form with errors
}
}
Using .as() for Chaining
The .as(Schema) method converts a dictionary to a Record, useful in pipelines:
let user = fetchFromAPI().parse().as(User)
let row = csvRows[0].as(User)
From Database Queries
Records returned from database queries are automatically validated:
let Users = db.bind(User, "users")
// Single row β Record (auto-validated)
let user = @query(Users | id == {id} ?-> *)
user.isValid() // true (data from DB is trusted)
// Multiple rows β Table of Records
let admins = @query(Users | role == "admin" ??-> *)
admins[0].name // Access data directly
When to Use Which
| Syntax | Best for |
|---|---|
Schema({...}) |
Primary idiom, literal data, form processing |
Schema([...]) |
Creating typed Tables from arrays |
{...}.as(Schema) |
Chaining, pipelines, API responses |
| Database queries | Rows come pre-validated |
Schemas: Defining Your Data Shape
Schemas define the structure, types, constraints, and metadata for your data. They use the same syntax as the Query DSL.
Basic Schema Definition
@schema User {
id: int(auto) // Database-generated
name: string(min: 2, required)
email: email(required)
age: int(min: 0, max: 150)
role: enum["user", "admin", "moderator"]
website: url
createdAt: datetime(auto) // Server-generated timestamp
}
Field Types
| Type | Description | HTML Input Type |
|---|---|---|
string |
Text value | text |
text |
Long text | textarea |
int |
Integer number | number |
float/decimal |
Decimal number | number |
bool |
Boolean | checkbox |
email |
Email address | email |
url |
URL | url |
phone |
Phone number | tel |
date |
Date | date |
datetime |
Date and time | datetime-local |
time |
Time | time |
money |
Currency amount | number |
slug |
URL-safe string | text |
uuid/ulid |
Unique identifiers | text |
enum[...] |
Fixed set of values | select or radio |
Constraints
| Constraint | Applies To | Example |
|---|---|---|
required |
All types | string(required) |
min |
String length, numbers | string(min: 2), int(min: 0) |
max |
String length, numbers | string(max: 100), int(max: 150) |
pattern |
Strings | string(pattern: "^[A-Z]+$") |
default |
All types | int(default: 1) |
auto |
IDs, timestamps | int(auto) β skipped in validation |
The auto Constraint
Fields marked auto are generated by the database (like auto-increment IDs or timestamps). They're skipped during validation and cannot be updated:
@schema User {
id: int(auto) // Can be omitted when creating
createdAt: datetime(auto) // Server sets this
name: string(required)
}
let user = User({name: "Alice"}) // id and createdAt omitted
user.validate().isValid() // true β auto fields skipped
user.update({id: 999}) // Error: cannot update auto field
Metadata: Schema for Humans
Schemas can carry metadata for display, using the pipe (|) syntax to separate validation from presentation:
@schema User {
name: string(min: 2, required) | {title: "Full Name", placeholder: "Enter your name"}
email: email(required) | {title: "Email Address", placeholder: "you@example.com"}
age: int(min: 0) | {title: "Age", help: "Must be a positive number"}
salary: decimal | {title: "Salary", format: "currency"}
createdAt: datetime(auto) | {title: "Created", format: "date", hidden: true}
}
Core Metadata Keys
| Key | Purpose | Used by |
|---|---|---|
title |
Human-readable field name | Form labels, table headers |
placeholder |
Input placeholder text | Form inputs |
help |
Help text below field | Form hints |
format |
Display format | Value formatting |
hidden |
Exclude from default display | Auto-generated forms |
Open Metadata
The metadata dictionary accepts any key-value pairs. Use this for custom features:
@schema User {
name: string(required) | {title: "Name", sortable: true, searchWeight: 2}
avatar: string | {title: "Avatar", widget: "image-upload", maxSize: "5mb"}
}
// Access custom metadata
User.meta("name", "sortable") // true
User.meta("avatar", "widget") // "image-upload"
Accessing Metadata from Records
Records provide shorthand methods to access schema metadata:
let form = User({name: "Alice"})
// Shorthand (preferred)
form.title("name") // "Full Name"
form.placeholder("email") // "you@example.com"
form.meta("name", "help") // "Your legal name"
form.enumValues("role") // ["user", "admin", "moderator"]
// Long form (also works)
form.schema().title("name")
Built-in Formats
The format() method formats values according to metadata hints:
@schema Metrics {
views: int | {format: "number"}
revenue: decimal | {format: "currency"}
conversionRate: float | {format: "percent"}
createdAt: datetime | {format: "date"}
}
let m = Metrics({views: 1234567, revenue: 52000, conversionRate: 0.15})
m.format("views") // "1,234,567"
m.format("revenue") // "$52,000.00"
m.format("conversionRate") // "15%"
m.format("createdAt") // "Jan 15, 2025"
| Format | Example Input | Example Output |
|---|---|---|
"number" |
1234567 |
"1,234,567" |
"currency" |
52000 |
"$52,000.00" |
"percent" |
0.15 |
"15%" |
"date" |
2025-01-15 |
"Jan 15, 2025" |
"datetime" |
2025-01-15T14:30:00Z |
"Jan 15, 2025 2:30 PM" |
Field Props: All-in-One Input Attributes
The fieldProps(field, overrides?) method returns a dictionary of HTML input attributes for a field, combining schema type, metadata, value, and validation state into a single call. This is useful when building forms programmatically or in component libraries where @field binding isn't available.
Signature: record.fieldProps(field, overrides?) β dictionary
Returned keys:
| Key | Source | Description |
|---|---|---|
name |
Field name | The field name for form submission |
type |
Schema type | HTML input type (see mapping below) |
label |
Metadata title or title-cased name |
Human-readable label |
placeholder |
Metadata placeholder |
Input placeholder text, or null |
value |
Record data | Current field value, formatted for inputs |
required |
Schema constraints | Whether the field is required |
error |
Validation state | Error message, or null |
autocomplete |
Type/name/metadata | Autocomplete hint, or null |
inputmode |
Schema type | Input mode hint, or null |
options |
Enum values | Array of options for enum fields, or null |
Type mapping:
| Schema Type | type |
inputmode |
|---|---|---|
email |
"email" |
"email" |
url |
"url" |
"url" |
phone |
"tel" |
"tel" |
integer |
"number" |
"numeric" |
float |
"text" |
"decimal" |
boolean |
"checkbox" |
null |
money |
"text" |
"decimal" |
date |
"date" |
null |
datetime |
"datetime-local" |
null |
unit |
"text" |
"numeric" |
enum |
"select" |
null |
Value formatting: Values are formatted for HTML input consumption:
- money β decimal string (e.g.
"49.99") - datetime β ISO local format without Z suffix (e.g.
"2025-01-15T14:30:00") - unit β numeric part only (e.g.
"100")
Basic example:
@schema User {
email: email | {title: "Email Address", placeholder: "you@example.com"}
}
let user = User({email: "test@example.com"})
user.fieldProps("email")
// β {name: "email", type: "email", label: "Email Address", placeholder: "you@example.com",
// value: "test@example.com", required: true, autocomplete: "email", inputmode: "email"}
With overrides: The optional second argument is a dictionary of overrides that merge in (overrides win). Extra keys are passed through, so you can inject class names, data attributes, or any custom properties:
user.fieldProps("email", {label: "Work Email", class: "wide"})
// β {name: "email", type: "email", label: "Work Email", placeholder: "you@example.com",
// value: "test@example.com", required: true, autocomplete: "email", inputmode: "email",
// class: "wide"}
Programmatic form rendering:
@schema Contact {
name: string(required) | {title: "Full Name"}
email: email(required) | {title: "Email"}
phone: phone | {title: "Phone Number"}
}
let form = Contact(props).validate()
for (field in form.keys()) {
let p = form.fieldProps(field)
<div class="field">
<label for={p.name}>p.label</label>
<input name={p.name} type={p.type} value={p.value}
placeholder={p.placeholder} inputmode={p.inputmode}/>
if (p.error) {
<span class="error">p.error</span>
}
</div>
}
Validation
The Validation Flow
Records start unvalidated. Call validate() to check constraints:
// Create record (no validation yet)
let record = User({name: "A", email: "bad"})
record.isValid() // false (not validated)
// Validate
let validated = record.validate()
validated.isValid() // false (has errors)
validated.errors() // {name: {code: "MIN_LENGTH", ...}, email: {code: "FORMAT", ...}}
Validation checks in order:
- Required β Is the field present and non-null?
- Type β Does the value match the expected type?
- Format β Does it match the format (email, URL, phone, etc.)?
- Length β Does string length meet min/max?
- Value β Do numbers meet min/max?
- Pattern β Does it match the regex pattern?
- Enum β Is the value in the allowed set?
Error Codes
| Code | Meaning | Example Message |
|---|---|---|
REQUIRED |
Missing required field | "This field is required" |
TYPE |
Wrong data type | "Must be a number" |
FORMAT |
Invalid format | "Email is not a valid email address" |
ENUM |
Value not in allowed set | "Must be one of: user, admin" |
MIN_LENGTH |
String too short | "Must be at least 2 characters" |
MAX_LENGTH |
String too long | "Must be at most 100 characters" |
MIN_VALUE |
Number too small | "Must be at least 0" |
MAX_VALUE |
Number too large | "Must be at most 150" |
PATTERN |
Doesn't match regex | "Invalid format" |
CUSTOM |
Added via withError() |
(Your message) |
Accessing Errors
let form = User({name: "A", email: "bad"}).validate()
// All errors as a dictionary
form.errors() // {name: {code: "MIN_LENGTH", message: "..."},
// email: {code: "FORMAT", message: "..."}}
// Single field
form.error("name") // "Must be at least 2 characters"
form.errorCode("name") // "MIN_LENGTH"
form.hasError("name") // true
// As an array (useful for iteration)
form.errorList() // [{field: "name", code: "MIN_LENGTH", message: "..."},
// {field: "email", code: "FORMAT", message: "..."}]
Custom Validation
For business rules and cross-field validation, use withError():
let form = User(props).validate()
// Cross-field validation
if (form.password != props.confirmPassword) {
form = form.withError("confirmPassword", "MISMATCH", "Passwords don't match")
}
// Business rule validation
if (isEmailTaken(form.email)) {
form = form.withError("email", "DUPLICATE", "Email already registered")
}
State-Only Errors (No Message)
Sometimes you want to highlight multiple fields as invalid without showing individual error messagesβfor example, when validating composite fields or checking for duplicate records. Use withError(field) with just the field name:
@schema Person {
firstName: string(required)
lastName: string(required)
day: int(min: 1, max: 31)
month: int(min: 1, max: 12)
year: int(min: 1900, max: 2100)
}
let form = Person(props).validate()
// Check if person already exists (multi-field validation)
if (personExists(form.firstName, form.lastName)) {
// Flag both name fields as having errors, but show one combined message
form = form
.withError("firstName") // State only - no individual message
.withError("lastName") // State only - no individual message
.withError("_form", "A person with this name already exists")
}
// Check if date fields form a valid date
if (not valid.date(`{form.year}-{form.month}-{form.day}`)) {
// Highlight all three date fields
form = form
.withError("day")
.withError("month")
.withError("year")
.withError("_form", "Please enter a valid date")
}
How it works:
withError(field)β Setsaria-invalid="true"on the input, but<error @field>renders nothingwithError(field, msg)β Sets error state AND displays the messagerecord.hasError(field)returnstruefor both formsrecord.error(field)returns""(empty string) for state-only errors
This lets you show a single general error message while visually highlighting all the affected fields.
Type Casting
Schema types drive automatic casting when creating records:
@schema Settings {
age: int
active: bool
}
// Form data comes as strings
let record = Settings({age: "42", active: "true"})
record.age // 42 (Integer, not "42" string)
record.active // true (Boolean, not "true" string)
Default Values
Defaults are applied on creation, not validation:
@schema Request {
page: int(default: 1)
limit: int(default: 20)
sort: string(default: "created_at")
}
let r = Request({}) // r.page = 1, r.limit = 20, r.sort = "created_at"
let r = Request({page: 5}) // r.page = 5, r.limit = 20, r.sort = "created_at"
let r = Request({page: null}) // r.page = 1 (null treated as missing)
Field Filtering (Whitelisting)
Schema fields act as a whitelist. Unknown fields are silently ignored:
@schema User {
name: string
email: email
}
// is_admin is silently dropped β not in schema
let record = User({name: "Alice", email: "a@b.com", is_admin: true})
record.data() // {name: "Alice", email: "a@b.com"}
This provides security by default β malicious fields can't sneak through.
readOnly Field Filtering
Fields marked readOnly in the schema are also filtered during record creation. This prevents clients from setting protected values, but has an important implication for delete operations:
@schema Person {
id: int(auto, readOnly) // Protected from client input
name: string
}
// Form submits {id: "123", name: "Alice"}
let person = Person(formData)
person.id // null (filtered!)
// This FAILS β id is null
People.delete(person) // Error: no primary key value
// Solutions:
People.delete(formData.id) // β
Pass ID directly
People.delete(params.id) // β
Use URL parameter
let p = People.find(params.id)
People.delete(p) // β
Load from DB first
Schema Checking
Use the is and is not operators to check whether a value is a Record bound to a specific schema. This is useful when working with mixed data or implementing type-safe dispatch.
Basic Usage
@schema User { name: string }
@schema Product { sku: string }
let user = User({name: "Alice"})
user is User // true
user is Product // false
user is not Product // true
Identity Comparison
Schema checking uses pointer identity, not structural matching. Two schemas with identical fields are still different schemas:
@schema UserA { name: string }
@schema UserB { name: string } // Same fields, different schema
let record = UserA({name: "Bob"})
record is UserA // true
record is UserB // false
Filtering by Schema
A common use case is filtering mixed arrays by schema:
@schema User { name: string }
@schema Product { sku: string }
let items = [
User({name: "Alice"}),
Product({sku: "A001"}),
User({name: "Bob"})
]
// Get only users
let users = items.filter(fn(x) { x is User })
users.length() // 2
// Get non-users
let nonUsers = items.filter(fn(x) { x is not User })
nonUsers.length() // 1
Safe Type Checking
For non-record values (strings, numbers, plain dicts), is safely returns false:
"hello" is User // false
42 is User // false
{name: "Alice"} is User // false (plain dict, not a Record)
null is User // false
This makes is safe to use without prior type checking.
Updating Records
Records are immutable. The update() method returns a new record with merged fields and auto-revalidates:
let user = User({name: "Alice", email: "a@b.com"}).validate()
user.isValid() // true
// Update returns new revalidated record
let updated = user.update({name: "A"}) // too short
updated.isValid() // false (auto-revalidated)
updated.error("name") // "Must be at least 2 characters"
user.name // "Alice" (original unchanged)
updated.name // "A" (new record)
Why auto-revalidate? Like type-checking in a typed language β validation happens automatically so you can't forget. Update multiple fields at once for efficiency:
// Efficient: validates once
let r = record.update({a: 1, b: 2, c: 3})
Form Binding
Records shine brightest when building HTML forms. Basil provides special syntax to bind records to form elements, automatically handling values, validation attributes, and accessibility.
Abstraction Levels
Parsley offers four levels of form binding, from most convenient to most flexible:
| Level | Syntax | Use Case |
|---|---|---|
| Level 4 | <field name="email"/> |
Rapid prototyping, standard forms |
| Level 3 | <input @field="email"/> |
Custom markup with schema binding |
| Level 2 | record.fieldProps("email") |
Component libraries, programmatic forms |
| Level 1 | Manual attributes | Full control, no schema |
Choose based on your needs:
- Start with Level 4 for quick forms
- Drop to Level 3 when you need custom wrapper markup
- Use Level 2 when building reusable components
- Use Level 1 for forms without schemas or edge cases
Form Context
The @record attribute establishes form context:
<form @record={form} method="POST">
// Form elements can now use @field binding
</form>
The @record attribute itself is removed from the output β it's a compile-time directive.
Automatic Hidden ID Field
When a record has a non-null id field, Basil automatically inserts a hidden input at the start of the form:
@schema User {
id: int(auto)
name: string
}
let user = User({id: 42, name: "Alice"})
<form @record={user} method="POST" action="/save">
<input @field="name"/>
</form>
Renders to:
<form method="POST" action="/save">
<input type="hidden" name="id" value="42"/>
<input name="name" value="Alice" type="text"/>
</form>
This enables edit forms to identify which record is being updated. For new records (where id is null), no hidden field is inserted.
The <field/> Tag (Level 4)
The <field/> tag outputs a complete, accessible field structure with a single line:
<form @record={user} method="POST">
<field name="email"/>
</form>
This outputs:
<form method="POST">
<div class="field">
<label for="email">Email</label>
<input type="email" name="email" id="email" value="alice@example.com"
required aria-required="true" autocomplete="email"/>
</div>
</form>
What <field/> generates:
- Wrapper div β with class
"field"(customizable) - Label element β with
forattribute and text from schema title - Input element β with all schema-derived attributes
- Help text β optional
<span class="help">ifhelpprop provided - Error span β only rendered when validation fails, with
role="alert"
Props:
| Prop | Description |
|---|---|
name |
Field name (required) |
as |
Override input type: "textarea", "select" |
class |
Wrapper class (default: "field") |
id |
Override input ID |
label |
Override label text |
placeholder |
Override placeholder |
help |
Add help text below input |
Examples:
// Basic field
<field name="email"/>
// Custom label and help text
<field name="email" label="Work Email" help="We'll never share this"/>
// Textarea for long text
<field name="bio" as="textarea"/>
// Select for enums (auto-detected from schema, or explicit)
<field name="role" as="select"/>
// Custom wrapper class
<field name="name" class="field field--large"/>
Boolean fields: For checkbox inputs, <field/> automatically:
- Renders the input before the label (standard checkbox UX)
- Adds
field--checkboxclass to the wrapper
<field name="subscribe"/>
// β <div class="field field--checkbox">
// <input type="checkbox" name="subscribe" id="subscribe"/>
// <label for="subscribe">Subscribe</label>
// </div>
Validation errors: When the record has validation errors, the error span appears:
let form = User({email: "invalid"}).validate()
<form @record={form} method="POST">
<field name="email"/>
</form>
// β <div class="field">
// <label for="email">Email</label>
// <input ... aria-invalid="true" aria-describedby="email-error"/>
// <span id="email-error" class="error" role="alert">Invalid email format</span>
// </div>
Input Binding with @field (Level 3)
The @field attribute binds an input to a schema field while giving you full control over markup:
<form @record={form} method="POST">
<input @field="name"/>
<input @field="email"/>
</form>
What @field does:
- Sets
nameattribute β for form submission - Sets
valueattribute β from record data - Sets
typeattribute β derived from schema type - Adds constraint attributes β
required,minlength,maxlength,min,max,pattern - Adds accessibility attributes β
aria-invalid,aria-describedby,aria-required
This single input:
<input @field="email"/>
Expands to:
<input name="email"
value="alice@example.com"
type="email"
required
aria-invalid="false"
aria-describedby="email-error"
aria-required="true"/>
Type Derivation
Form inputs automatically derive their type from the schema:
| Schema Type | HTML Input Type |
|---|---|
email |
type="email" |
url |
type="url" |
phone |
type="tel" |
int, float |
type="number" |
date |
type="date" |
datetime |
type="datetime-local" |
time |
type="time" |
bool |
type="checkbox" |
Override with an explicit type attribute if needed:
<input @field="phone" type="text"/> // Uses text instead of tel
Checkbox and Radio Binding
Checkboxes bind to boolean fields:
<input @field="active" type="checkbox"/>
// Renders with checked={form.active}
Radio buttons check against a specific value:
<input @field="role" type="radio" value="admin"/>
<input @field="role" type="radio" value="user"/>
// First is checked if form.role == "admin"
The <label> Element
<label @field> renders a label with the field's title from metadata:
// Self-closing form (generates for attribute)
<label @field="email"/>
// Renders: <label for="email">Email Address</label>
// With children (wraps content)
<label @field="name">" (required)"</label>
// Renders: <label for="name">Full Name (required)</label>
If no title metadata exists, the field name is title-cased automatically:
firstNameβ "First Name"emailβ "Email"
Use @tag to change the element type:
<label @field="email" @tag="span"/>
// Renders: <span>Email Address</span>
The <error> Element
<error @field> conditionally renders validation errors:
<error @field="email"/>
// When error exists: <span id="email-error" class="error" role="alert">Invalid email</span>
// When no error: (nothing rendered)
The id matches aria-describedby on the input, connecting them for screen readers. The role="alert" announces errors when they appear.
Use @tag to change the element:
<error @field="email" @tag="div"/>
<error @field="email" @tag="small"/>
The <val> Element
<val @field @key> renders any metadata value:
<val @field="email" @key="help"/>
// Renders: <span>We'll never share your email</span>
// Or nothing if key doesn't exist
Use for help text, hints, or custom metadata:
<val @field="email" @key="help" @tag="small"/>
// Renders: <small>We'll never share your email</small>
The <select> Element
<select @field> auto-generates options for enum fields:
@schema User {
status: enum["active", "pending", "inactive"]
}
<select @field="status"/>
Renders:
<select name="status" required aria-required="true" aria-invalid="false">
<option value="">Select status</option>
<option value="active">active</option>
<option value="pending" selected>pending</option>
<option value="inactive">inactive</option>
</select>
Custom placeholder:
<select @field="status" placeholder="Choose a status..."/>
For complex cases (optgroups, custom labels), build manually:
<select @field="status">
<option value="">form.placeholder("status")</option>
for (val in form.enumValues("status")) {
<option value={val} selected={form.status == val}>val</option>
}
</select>
Accessibility Attributes
Form binding automatically adds ARIA attributes for screen readers:
| Attribute | Value | Purpose |
|---|---|---|
aria-invalid |
"true"/"false" |
Indicates validation state |
aria-describedby |
"{field}-error" |
Links input to error message |
aria-required |
"true" |
Mirrors HTML required |
Note: aria-invalid="false" is explicitly set (not omitted) so CSS selectors like [aria-invalid="true"] and [aria-invalid="false"] can style valid/invalid states.
Autocomplete
Form binding automatically derives autocomplete attributes to enable browser autofill. Values are derived based on:
- Explicit metadata β Always wins
- Field name patterns β Common naming conventions (case-insensitive)
- Schema type β Type-based defaults
Type-Based Autocomplete
| Schema Type | Autocomplete Value |
|---|---|
email |
"email" |
phone, tel |
"tel" |
url |
"url" |
Field Name Patterns
Common field names are automatically mapped:
| Field Name | Autocomplete Value |
|---|---|
firstName, givenName |
"given-name" |
lastName, familyName, surname |
"family-name" |
username |
"username" |
password |
"current-password" |
newPassword, confirmPassword |
"new-password" |
street, address |
"street-address" |
city |
"address-level2" |
state, province |
"address-level1" |
zip, zipCode, postalCode |
"postal-code" |
country |
"country-name" |
organization, company |
"organization" |
cardNumber |
"cc-number" |
birthday, dob |
"bday" |
Explicit Override
Use the autocomplete metadata key to override or disable:
@schema Checkout {
// Auto-derived from type: autocomplete="email"
email: email
// Explicit shipping context
shippingStreet: string | {autocomplete: "shipping street-address"}
shippingCity: string | {autocomplete: "shipping address-level2"}
// Disable autocomplete
captcha: string | {autocomplete: "off"}
}
Examples
Login form β auto-derived:
@schema Login {
email: email // autocomplete="email"
password: string // autocomplete="current-password"
}
Registration form β explicit for new password:
@schema Registration {
email: email // autocomplete="email"
password: string | {autocomplete: "new-password"}
}
Checkout form β shipping/billing sections:
@schema Checkout {
shippingStreet: string | {autocomplete: "shipping street-address"}
billingStreet: string | {autocomplete: "billing street-address"}
}
Complete Form Example
Here's a complete form with validation:
@schema User {
name: string(min: 2, required) | {title: "Full Name", help: "Your legal name"}
email: email(required) | {title: "Email", help: "We'll never share this"}
role: enum["user", "admin"] | {title: "Role"}
}
export default = fn(props) {
let form = props.form ?? User({})
<form @record={form} method="POST" action="save">
<div class="field">
<label @field="name"/>
<input @field="name"/>
<error @field="name"/>
<val @field="name" @key="help" @tag="small"/>
</div>
<div class="field">
<label @field="email"/>
<input @field="email"/>
<error @field="email"/>
<val @field="email" @key="help" @tag="small"/>
</div>
<div class="field">
<label @field="role"/>
<select @field="role"/>
<error @field="role"/>
</div>
<button type="submit">"Save"</button>
</form>
}
export save = fn(props) {
let form = User(props).validate()
if (form.isValid()) {
@insert(Users |< ...form .)
<div class="success">"User saved successfully!"</div>
} else {
default({form: form})
}
}
Data and Serialization
Extracting Data
let user = User({name: "Alice", email: "alice@example.com"})
user.data() // {name: "Alice", email: "alice@example.com"}
user.keys() // ["name", "email"] (declaration order)
user.toJSON() // '{"name":"Alice","email":"alice@example.com"}'
Direct Property Access
Access data fields directly on the record:
user.name // "Alice"
user.email // "alice@example.com"
user.unknownField // null
Dictionary Compatibility
Records work where dictionaries are expected:
let user = User({name: "Alice"})
// Spread into dictionary
let merged = {...user, extraField: "value"}
// JSON encoding
json.encode(user) // Encodes data fields only
// Pass to function expecting dictionary
someFunc(user) // Works
Integration with Tables and Databases
Creating Typed Tables
Pass an array to a schema to create a typed table:
let users = User([
{name: "Alice", email: "alice@example.com"},
{name: "Bob", email: "bob@example.com"}
])
// Or use table() with .as()
let users = table(csvData).as(User)
Table Validation
Tables support bulk validation:
let validated = users.validate()
validated.isValid() // true if ALL rows valid
validated.errors() // [{row: 0, field: "email", code: "FORMAT", message: "..."}]
validated.validRows() // Table of valid rows only
validated.invalidRows() // Table of invalid rows with errors
// Individual rows are Records
validated[0].errors() // {email: {code: "FORMAT", message: "..."}}
Database Queries Return Records
When a table is bound to a schema, queries return Records:
let db = @sqlite("app.db")
let Users = db.bind(User, "users")
// Single row β Record
let user = @query(Users | id == {id} ?-> *)
user.name // Data access
user.isValid() // true (auto-validated from DB)
user.title("name") // "Full Name" (metadata access)
// Multiple rows β Table of Records
let admins = @query(Users | role == "admin" ??-> *)
Records vs Tables
| Type | Created By | Use Case |
|---|---|---|
| Record | Schema({...}) or {...}.as(Schema) |
Single entity: form, API request, one DB row |
| Table | Schema([...]) or table(data).as(Schema) |
Multiple entities: CSV, query results, lists |
For form validation: Use Records.
For bulk import/pipelines: Use Tables with .validate().
Method Reference
Validation Methods
| Method | Returns | Description |
|---|---|---|
validate() |
Record | Validate and return with errors populated |
isValid() |
Boolean | True if validated AND no errors |
errors() |
Dictionary | All errors: {field: {code, message}} |
error(field) |
String or null | Error message for field |
errorCode(field) |
String or null | Error code for field |
errorList() |
Array | Errors as: [{field, code, message}] |
hasError(field) |
Boolean | True if field has error |
withError(field) |
Record | Flag field as error state (no message) |
withError(field, msg) |
Record | Add custom error with message |
withError(field, code, msg) |
Record | Add custom error with code and message |
Metadata Methods
| Method | Returns | Description |
|---|---|---|
title(field) |
String | Title from metadata or title-cased name |
placeholder(field) |
String or null | Placeholder from metadata |
meta(field, key) |
Any or null | Any metadata value |
enumValues(field) |
Array | Enum options or empty array |
format(field) |
String | Formatted value using metadata hint |
fieldProps(field) |
Dictionary | All input attributes for a field |
fieldProps(field, overrides) |
Dictionary | Input attributes with overrides merged in |
Data Methods
| Method | Returns | Description |
|---|---|---|
data() |
Dictionary | Plain dict of all data |
keys() |
Array | Field names in declaration order |
schema() |
Schema or null | The bound schema |
toJSON() |
String | JSON encoding of data |
Tip:
toJSON()converts dates to strings and money to numbers. For lossless record serialization, use PLN:user ==> PLN(@./user.pln). See Data Formats.
Update Methods
| Method | Returns | Description |
|---|---|---|
update(dict) |
Record | Merge fields and auto-revalidate |
Quick Reference
// Define schema with metadata
@schema User {
name: string(min: 2, required) | {title: "Full Name"}
email: email(required) | {title: "Email"}
role: enum["user", "admin"] | {title: "Role"}
}
// Create record
let form = User({name: "Alice", email: "alice@example.com"})
// Validate
let validated = form.validate()
validated.isValid() // true/false
validated.error("name") // Error message or null
// Build form
<form @record={validated} method="POST">
<label @field="name"/>
<input @field="name"/>
<error @field="name"/>
<label @field="email"/>
<input @field="email"/>
<error @field="email"/>
<label @field="role"/>
<select @field="role"/>
<error @field="role"/>
<button type="submit">"Save"</button>
</form>
See Also
- Schemas β declaring data shapes with types, constraints, and metadata
- Dictionaries β records extend dictionaries with schema binding
- Tables β arrays of records with tabular operations
- Data Model β how schemas, records, and tables fit together
- Tags β
@recordand@fieldform binding syntax - Database β database queries return records
- Query DSL β declarative queries with schema-typed results
- @std/table β SQL-like operations on arrays of records
- @std/valid β validation predicates for field values