Tables
Tables are Parsley's first-class type for working with structured, rectangular data. Think of a Table as a spreadsheet or database result setβrows of data where each row has the same columns. Tables provide powerful SQL-like operations for filtering, sorting, selecting, and aggregating data, all with a clean method-chaining syntax.
let sales = @table [
{product: "Widget", region: "North", amount: $1200},
{product: "Gadget", region: "South", amount: $800},
{product: "Widget", region: "South", amount: $1500},
{product: "Gadget", region: "North", amount: $950}
]
sales
.where(fn(r) { r.region == "South" })
.orderBy("amount", "desc")
.select(["product", "amount"])
Result:
βββββββββββ¬βββββββββ
β product β amount β
βββββββββββΌβββββββββ€
β Widget β $1,500 β
β Gadget β $800 β
βββββββββββ΄βββββββββ
Why Tables?
Parsley is designed for web and data. While arrays of dictionaries work for simple cases, Tables provide:
| Feature | Array of Dicts | Table |
|---|---|---|
| Column consistency | Not enforced | Guaranteed |
| SQL-like operations | Manual loops | Built-in methods |
| Export formats | DIY | .toCSV(), .toHTML(), .toMarkdown() |
| Aggregations | Manual | .sum(), .avg(), .min(), .max() |
| Schema validation | None | Optional @schema integration |
| Memory efficiency | N/A | Copy-on-chain optimization |
Tables are ideal for:
- CSV file processing β Import, transform, export
- Database results β Query, filter, format for display
- Report generation β Aggregate, format as HTML or Markdown
- Data pipelines β Chain transformations like SQL queries
Creating Tables
The @table Literal
The most direct way to create a Table is with the @table literal syntax:
let users = @table [
{name: "Alice", age: 30, active: true},
{name: "Bob", age: 25, active: false},
{name: "Carol", age: 35, active: true}
]
The @table literal provides parse-time validation:
- All rows must be dictionaries
- All rows must have the same keys (columns)
- Column names are inferred from the first row
// This is a PARSE ERROR β "age" missing from second row:
let bad = @table [
{name: "Alice", age: 30},
{name: "Bob"} // Error: missing column 'age'
]
The table() Constructor
For dynamic data, use the table() constructor:
let data = [{x: 1}, {x: 2}, {x: 3}]
let t = table(data)
The constructor validates at runtime:
table([{a: 1}, {b: 2}]) // Error: column mismatch
table("not an array") // Error: requires array
table([1, 2, 3]) // Error: elements must be dictionaries
Empty Tables
Both syntaxes support empty tables:
let empty1 = @table []
let empty2 = table([])
empty1.length // 0
empty1.columns // []
Tables from External Sources
CSV Files
The CSV() function and .parseCSV() method return Tables directly:
// From a file
let sales <== CSV("sales.csv")
// From a string
let data = "name,age\nAlice,30\nBob,25".parseCSV()
CSV columns come from the header row. Values are automatically parsed as numbers, booleans, or strings:
let csv = "product,price,in_stock\nWidget,9.99,true\nGadget,19.99,false"
let products = csv.parseCSV()
products[0].price // 9.99 (number, not string)
products[0].in_stock // true (boolean, not string)
Database Queries
Database table bindings return Tables with full schema information:
// Basil database binding
let users = Users.all()
let active = Users.where(fn(u) { u.active })
// Raw SQL also returns Table
let results <=?=> "SELECT * FROM orders WHERE total > 100"
Database-backed Tables carry schema metadata:
users.schema // Contains column types from database
JSON APIs
JSON APIs return arrays by default. Convert to Table explicitly:
let response <=/= fetch("https://api.example.com/users")
let users = table(response.json)
Tables with Schemas
Typed Tables with @table(Schema)
Combine @table with @schema for validated, typed tables with defaults:
@schema Product {
sku: string
name: string
price: money
in_stock: bool = true
discount: number = 0
}
// Schema defaults are applied when fields are missing from ALL rows
let products = @table(Product) [
{sku: "A001", name: "Widget", price: $9.99},
{sku: "A002", name: "Gadget", price: $19.99}
]
products[0].in_stock // true (default applied)
products[0].discount // 0 (default applied)
products[1].in_stock // true (default applied)
Note: All rows in a
@tablemust have the same columns. To override a default, include the field in every row:
let products = @table(Product) [
{sku: "A001", name: "Widget", price: $9.99, in_stock: true, discount: 0},
{sku: "A002", name: "Gadget", price: $19.99, in_stock: false, discount: 10}
]
Nullable Fields
Use ? suffix for optional fields. Nullable fields can hold null values:
@schema Contact {
name: string
email: email
phone: phone? // nullable β can hold null
}
let contacts = @table(Contact) [
{name: "Alice", email: "alice@example.com", phone: "555-1234"},
{name: "Bob", email: "bob@example.com", phone: null}
]
contacts[0].phone // "555-1234"
contacts[1].phone // null
Note: All rows must include all columns. Use
nullexplicitly for missing nullable values.
Required Field Validation
Non-nullable fields without defaults must be provided:
@schema User {
id: ulid
email: email
}
// This is an ERROR β missing required "email":
let bad = @table(User) [
{id: "01H..."}
]
// Error: Table row 1: missing required field 'email'
Attributes
columns
Returns an array of column names:
let t = @table [{name: "Alice", age: 30}]
t.columns
Result: ["name", "age"]
length
Returns the number of rows:
let t = @table [{x: 1}, {x: 2}, {x: 3}]
t.length
Result: 3
rows
Returns all rows as an array of dictionaries:
let t = @table [{a: 1}, {a: 2}]
t.rows
Result: [{a: 1}, {a: 2}]
schema
Returns the attached schema, or null if none:
@schema Point { x: int, y: int }
let points = @table(Point) [{x: 1, y: 2}]
points.schema // Point schema object
Schema Checking with is
Use the is and is not operators to check whether a Table is bound to a specific schema:
@schema User { name: string }
@schema Product { sku: string }
let users = @table(User) [{name: "Alice"}]
let products = @table(Product) [{sku: "A001"}]
let untyped = @table [{name: "Bob"}]
users is User // true
users is Product // false
products is Product // true
untyped is User // false (no schema)
This is useful when you need to dispatch based on table type or validate that data has the expected schema.
Indexing and Iteration
Row Access
Access rows by index (zero-based):
let t = @table [{name: "Alice"}, {name: "Bob"}, {name: "Carol"}]
t[0] // {name: "Alice"}
t[1] // {name: "Bob"}
t[-1] // {name: "Carol"} (last row)
Iteration
Tables are iterableβuse for to loop over rows:
let users = @table [
{name: "Alice", role: "admin"},
{name: "Bob", role: "user"}
]
for (user in users) {
<p>"{user.name} is a {user.role}"</p>
}
Result:
<p>Alice is a admin</p>
<p>Bob is a user</p>
SQL-Like Query Methods
Tables provide a fluent, chainable API inspired by SQL. Chain methods together to build expressive queries.
where(fn)
Filter rows using a predicate function:
let orders = @table [
{id: 1, customer: "Alice", total: $150},
{id: 2, customer: "Bob", total: $75},
{id: 3, customer: "Alice", total: $200}
]
orders.where(fn(r) { r.total > $100 })
Result:
ββββββ¬βββββββββββ¬ββββββββ
β id β customer β total β
ββββββΌβββββββββββΌββββββββ€
β 1 β Alice β $150 β
β 3 β Alice β $200 β
ββββββ΄βββββββββββ΄ββββββββ
Combine conditions with && and ||:
orders.where(fn(r) { r.customer == "Alice" && r.total > $175 })
orderBy(column) / orderBy(column, direction)
Sort rows by a column. Default is ascending:
let products = @table [
{name: "Banana", price: $1.50},
{name: "Apple", price: $2.00},
{name: "Cherry", price: $3.50}
]
products.orderBy("price")
Result:
ββββββββββ¬ββββββββ
β name β price β
ββββββββββΌββββββββ€
β Banana β $1.50 β
β Apple β $2.00 β
β Cherry β $3.50 β
ββββββββββ΄ββββββββ
columnProps(column)
Returns a dictionary of display metadata for a column. This is used by the DataTable component to derive column display properties from schemas.
Signature: table.columnProps(column) β dictionary
The returned dictionary contains:
| Key | Description |
|---|---|
name |
Column identifier (string) |
label |
From schema title or titlecased column name (e.g. "created_at" β "Created At") |
type |
Original schema type (only present if schema exists) |
align |
"left", "right", or "center" (derived from type) |
format |
Format hint (only present if applicable): "currency", "date", "datetime", "duration", "unit", "boolean" |
Alignment rules:
- Right:
money,integer,int,float,number,duration,unit - Center:
boolean,bool - Left: everything else
Without a schema, returns minimal props with name, label, and align="left".
Without schema:
let t = table([{created_at: "2025-01-01"}])
t.columnProps("created_at")
Result: {name: "created_at", label: "Created At", align: "left"}
With schema:
@schema Order {
total: money | {title: "Order Total"}
active: boolean
}
let orders = table([{total: Β£100.00, active: true}]).as(Order)
orders.columnProps("total")
// β {name: "total", label: "Order Total", type: "money", align: "right", format: "currency"}
orders.columnProps("active")
// β {name: "active", label: "Active", type: "boolean", align: "center", format: "boolean"}
Tables
Tables are Parsley's rectangular data type: each row is a dictionary and every row has the same columns. They power CSV handling, database results, reporting, and any place you would normally reach for SQL-style transforms.
let sales = @table [
{product: "Widget", region: "North", amount: $1200},
{product: "Gadget", region: "South", amount: $800},
{product: "Widget", region: "South", amount: $1500},
{product: "Gadget", region: "North", amount: $950}
]
sales
.where(fn(r) { r.region == "South" })
.orderBy("amount", "desc")
.select(["product", "amount"])
Ways to Create a Table
- Literal
@table [...]β Parse-time rectangular validation. Every element must be a dictionary and every row must have exactly the first row's keys; missing/extra columns raise parse errors. - Typed literal
@table(Schema) [...]β Rows are cast to the schema immediately. Defaults are applied, unknown fields are dropped, and missing required fields raiseTABLE-0005with the row/field. Column order uses sorted schema field names. - Constructor
table(array)β Runtime validation.TABLE-0001if the input is not an array;TABLE-0002if a row is not a dictionary;TABLE-0003/0004for missing/extra columns.table()with no args yields an empty table. - Schema call
Schema([...])β Calling a schema with an array returns a typed (unvalidated) table with defaults applied. Column order uses sorted schema field names (schema declaration order is not preserved in this call). parseCSV(hasHeader=true)β WhenhasHeaderis true (default), returns a Table whose columns come from the header row and values are coerced to int/float/bool/string. WithhasHeader=false, returns an array-of-arrays instead of a Table.- Database/query DSL β
Users.all(),Users.where(...), and@queryresults are Tables with an attached schema andFromDB=true; indexing them returns Records marked validated (no revalidation is run on access). - Compat:
import @std/tableβ Provides the legacytablemodule andtable.fromDict(dict, keyName?, valueName?)helper; prefer literals ortable().
Empty tables are allowed in all forms: @table [] and table([]) both produce length = 0 and columns = [].
Shape, Access, and Properties
- Column order
@table [...]andtable([...]): from the first row's keys (insertion order, excluding keys starting with__).
@table(Schema) [...]: sorted schema field names.table(...).as(Schema):schema.FieldOrderif present, else sorted schema field names.Schema([...]): sorted schema field names (ignores schema declaration order).- Indexing:
table[0],table[-1], etc. Negative indices count from the end. Typed tables return aRecordwhen indexed; database tables return validated Records. - Iteration:
for (row in table) { ... }iterates dictionaries or Records. - Properties
rows: array of dictionaries (not a deep copy)row: first row ornullif emptycolumns: array of column nameslength: number of rowsschema: attached schema ornull
Copy-on-Chain
Chaining is efficient: it only makes one copy.
The first mutating-style method in a chain makes one copy; later calls in the same chain reuse it. The chain ends when you assign, pass as an argument, iterate, or access a property. Use .copy() for an explicit non-chain copy.
Core Query Methods
where(fn(row))β Keep rows where the predicate is truthy.orderBy(col | [spec,...], dir?)β Sort by a column, or by multiple specs (string or[col, dir]).diris"asc"(default) or"desc".select([cols])β Project to specific columns; missing columns becomenull.limit(count, offset=0)β Non-negative count/offset; slices rows.offset(count)β Skip rows; non-negative.
Example:
let top = sales
.where(fn(r) { r.region == "South" })
.orderBy("amount", "desc")
.limit(2)
.select(["product", "amount"])
Aggregations
count()β Row count.sum(col)β Adds integers/floats; parses numeric strings; sums Money in a single currency. Mixing Money with other numeric types or multiple currencies returns an error.avg(col)β Average of numbers or Money (single currency). Returnsnullon empty input.min(col)/max(col)β Ignoresnull; string values are coerced to numbers when possible; returnsnullif nothing compares.
Column and Group Helpers
column(name)β Array of values; errors if the column is missing.columnProps(column)β Dictionary of display metadata for a column:name,label,align, and optionallytypeandformat. Alignment is derived from schema type (right for numeric/money/duration, center for boolean, left for everything else). Without a schema, returns minimal props.rowCount()/columnCount()β Dimensions.unique(colOrCols?)β Removes duplicates; when columns are provided (string or array), uniqueness is based on those fields, otherwise all columns.groupBy(colOrCols, fn(rows)? )- Without
fn: returns rows with the group keys plus arowsarray. - With
fn:fnreceives an array of group rows and should return a dictionary (merged) or a single value stored undervalue. - Grouped tables do not preserve schemas.
- Without
Example with aggregation:
let totals = sales.groupBy(["region", "product"], fn(rows) {
let sum = 0
for (r in rows) { sum = sum + r.amount }
{total: sum}
})
Functional Helpers
map(fn(row))β Returns a new table built from the callback's dictionaries or Records. Schema is preserved only if every result is a Record with the same schema; otherwise the schema is cleared.find(fn(row))β First matching row (Record for typed tables) ornull.any(fn(row))/all(fn(row))β Boolean checks (allistruefor empty tables).
Building and Editing Rows/Columns
appendRow(dict)β Requires columns to match existing columns (or defines columns if the table was empty).insertRowAt(index, dict)β Zero-based; negative indices allowed; bounds checked; column validation likeappendRow.appendCol(name, valuesOrFn)βvaluesOrFnis an array matching row count or a function called per row. New column name must be unique.insertColAfter(existing, name, valuesOrFn)/insertColBefore(existing, name, valuesOrFn)β Insert at a position; validates existing column and unique new name.renameCol(old, new)β Errors ifoldis missing.dropCol(name, ...)β Remove one or more columns.
All of these return new tables (chain copies when part of a chain).
Validation and Typed Tables
table.as(Schema)β Attaches a schema, applying defaults and casting types (no validation yet). Column order follows schema.validate()β Validates every row of a typed table; returns a new typed table with__errors__stored per row.isValid()βtrueonly if every row is valid; uses stored errors when present, otherwise revalidates.errors()β Array of dictionaries{row, field, code, message}(row indices are zero-based). Empty array when untyped.validRows()/invalidRows()β Filtered typed tables using stored errors when present, otherwise revalidating.
Example:
@schema User { id: ulid, email: email }
let raw = table([
{id: "01H7...", email: "alice@example.com"},
{id: "01H8...", email: "not-an-email"}
]).as(User)
let checked = raw.validate()
checked.isValid() // false
checked.errors() // [{row: 1, field: "email", ...}]
checked.validRows() // only the valid row
Export and Presentation
toArray()β Array of row dictionaries.toJSON()β JSON string with pretty newlines and indentation for readability.toCSV()β RFC 4180 CSV with a header row; uses CRLF line endings.toMarkdown()β GitHub-flavored table; returns""if there are no columns.toHTML(footer?)β HTML<table>with<thead>and<tbody>. Optional footer:- string footer: inserted raw inside
<tfoot> - dictionary footer: values aligned to columns; consecutive empty cells are collapsed with
colspan
- string footer: inserted raw inside
toBox(opts?)β Box-drawing table. Options:style(single/double/ascii/rounded),align(left/right/center),title(string),maxWidth(non-negative integer). OtherparseBoxOptionsfields are ignored for tables.
Interop Notes
- Indexing a typed table (including database/query results) returns a
Record; database-backed tables mark records as already validated when indexed. parseCSVwith a header returns a Table directly; without a header you get an array-of-arrays, so wrap withtable()if you need Table methods.- The
tablemodule from@std/tableremains for backward compatibility but is no longer required; prefer@table [...],table(...), and schema calls.
See Also
- Schemas β declaring data shapes for typed tables
- Records β schema-bound dictionaries returned when indexing typed tables
- Dictionaries β tables are arrays of dictionaries
- Arrays β array methods that also apply to table rows
- Data Model β how schemas, records, and tables fit together
- Database β database queries return tables
- Query DSL β declarative queries with table results
- Data Formats β CSV parsing returns tables
- @std/table β SQL-like table constructor and operations
- @std/math β aggregation functions for numeric arrays