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:

  1. Validating user input β€” checking required fields, formats, constraints
  2. Displaying validation errors β€” showing users what went wrong
  3. Populating forms β€” filling inputs with existing data or defaults
  4. 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:

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:

  1. Required β€” Is the field present and non-null?
  2. Type β€” Does the value match the expected type?
  3. Format β€” Does it match the format (email, URL, phone, etc.)?
  4. Length β€” Does string length meet min/max?
  5. Value β€” Do numbers meet min/max?
  6. Pattern β€” Does it match the regex pattern?
  7. 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:

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:

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:

  1. Wrapper div β€” with class "field" (customizable)
  2. Label element β€” with for attribute and text from schema title
  3. Input element β€” with all schema-derived attributes
  4. Help text β€” optional <span class="help"> if help prop provided
  5. 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:

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

  1. Sets name attribute β€” for form submission
  2. Sets value attribute β€” from record data
  3. Sets type attribute β€” derived from schema type
  4. Adds constraint attributes β€” required, minlength, maxlength, min, max, pattern
  5. 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:

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:

  1. Explicit metadata β€” Always wins
  2. Field name patterns β€” Common naming conventions (case-insensitive)
  3. 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