Data Model

Parsley's structured data system is built on three connected concepts: Schema defines the shape, Record holds validated data, and Table organizes rows. This page explains how the pieces fit together. See the individual reference pages for full API details.

The Pipeline

Schema  →  Record  →  Table
(shape)    (data)     (rows)
  1. A Schema declares fields, types, constraints, and metadata.
  2. A Record binds a schema to actual data and tracks validation errors.
  3. A Table is an ordered collection of rows — optionally typed by a schema.

Each layer builds on the previous one. You can use dictionaries and arrays for simple cases, but schemas, records, and tables add validation, type safety, and database integration.

Schema

A schema defines the shape of your data — field names, types, constraints, and UI metadata:

@schema User {
    id: integer
    name: string(min: 2, required)
    email: email(required, unique: true)
    role: enum["user", "admin"] = "user"
}

Schemas are values — you can assign them, pass them to functions, and use them at runtime. They drive:

See Schemas for the full field type and constraint reference.

Record

A Record is a Schema + Data + Errors. Create one by calling the schema as a function:

let user = User({name: "Alice", email: "alice@example.com"})

Records behave like dictionaries — you access fields with dot notation — but they carry their schema and validation state:

user.name                        // "Alice"
user.role                        // "user" (default applied)
user.errors()                    // {} (no errors)
user.valid()                     // true

Invalid data is accepted but tracked:

let bad = User({name: "", email: "not-an-email"})
bad.valid()                      // false
bad.errors()                     // {name: "...", email: "..."}

Records are immutable — updating returns a new record:

let updated = user.set("name", "Bob")
updated.name                     // "Bob"
user.name                        // "Alice" (unchanged)

See Records for methods, form binding, and serialization.

Dictionary vs Record

Dictionary Record
Schema None Required
Validation None Automatic
Errors None Tracked per field
Database Manual Schema-driven
Form binding Manual @field attributes
Type dictionary record

Use dictionaries for ad-hoc data (config, API responses, temporary structures). Use records when you need validation, database mapping, or form binding.

Table

A Table is an ordered collection of rows with named columns. Create one from a literal, CSV, or database query:

// From a literal
let t = @table [
    {name: "Alice", age: 30},
    {name: "Bob", age: 25}
]

// From CSV
let sales <== CSV(@./sales.csv)

// From a database query
let users <== @DB.query("SELECT * FROM users")

Tables provide SQL-like query methods that return new tables (immutable chaining):

let result = t
    .where(fn(r) { r.age > 20 })
    .orderBy("name")
    .select("name")

Typed Tables

When a table has a schema, rows are Records instead of plain dictionaries:

let users = User.table()         // table bound to User schema
let row = users[0]               // a Record, not a dictionary
row is User                      // true

See Tables for query methods, aggregation, and output formats.

Schema Identity — the is Operator

The is operator checks whether a record conforms to a specific schema:

let user = User({name: "Alice", email: "alice@example.com"})
user is User                     // true
user is Product                  // false

This is a schema identity check, not a structural/duck-typing check. A plain dictionary with the same keys would not pass is User — it must be a Record created from that schema.

Table Bindings

A table binding connects a schema to a database, enabling CRUD operations:

let users = User.table()         // in-memory table
let dbUsers = @DB.table(User)    // database-backed table

Database-backed tables support:

Records from database tables are auto-validated against their schema.

The Lifecycle

A typical data flow in a Basil web application:

  1. Define a schema: @schema User { ... }
  2. Create a record from form input: let user = User(formData)
  3. Validate: user.valid() — check before saving
  4. Persist: user ==> @DB.table(User) — write to database
  5. Query: let users <== @DB.table(User).where(...) — read back
  6. Render: <form @record={user}> — bind to HTML form

Each step uses the schema as the single source of truth for field names, types, constraints, and UI metadata.

Key Differences from Other Languages

See Also