This chapter covers pell’s type system: primitive types, records, optional values, lists, and the type-spelling conventions used by the emitter.
Primitive types
| pell | PL/SQL (default) | Notes |
|---|---|---|
number |
NUMBER |
|
int |
PLS_INTEGER |
integer arithmetic, ~10× faster |
text |
VARCHAR2(4000) |
the everyday string type |
bigtext |
CLOB |
for strings that may exceed 32K |
bool |
BOOLEAN |
NUMBER(1) inside OBJECT TYPE on 19c |
date |
DATE |
|
timestamp |
TIMESTAMP |
|
interval |
INTERVAL DAY TO SECOND |
|
bytes |
RAW(2000) |
|
json |
JSON (CLOB on 19c) |
native JSON datatype |
Unit |
(none) | “no return value” — used for procs |
Literal syntax:
let n: number = 42;
let pi: number = 3.14;
let name: text = "Alice";
let raw_pattern: text = `(\d{3}) - (\d{4})`;
let on: bool = true;
let now: date = sysdate();
For very large literals or contents that aren’t easily expressed inline, read them from a table or pass them as a parameter.
bigtext vs text
text lowers to VARCHAR2(4000). That’s plenty for most identifiers,
labels, short messages, and codes. Once your strings might exceed 32K
(file contents, large XML/JSON payloads, log lines), use bigtext:
pub fn store(payload: bigtext) -> number {
return length(payload);
}
bigtext is text semantically — same operators, same builtins —
but it lowers to CLOB, which Oracle handles via LOB locators
internally. The pell exec and pell repl driver auto-promotes
Python str binds > 4000 bytes to CLOB binds, so you can pass big
payloads from the client without writing LOB code yourself.
Optional values — Option<T>
Option<T> represents “T or absent.” It lowers to T directly (no
wrapper), with NULL representing None:
pub fn find_email(user_id: number) -> Option<text> {
let row: Option<text> = sql! {
select email from users where id = :user_id
}.one_or_none();
return row;
}
If the SELECT returns no rows, .one_or_none() returns None; the
caller can check with if let Some(e) = row { ... } (covered in the
chapter on match/if-let patterns).
Records
Records are PL/SQL RECORD types with named fields:
pub record Employee {
id: number,
name: text,
level: number,
}
lowers to:
CREATE OR REPLACE PACKAGE BODY <pkg> AS
TYPE t_employee IS RECORD (
id NUMBER,
name VARCHAR2(4000),
level NUMBER
);
...
pub record exposes the type in the package spec; without pub, it’s
private to the package body.
Constructing records
Two ways:
// Struct-literal syntax — most readable
let e: Employee = Employee { id: 1, name: "Alice", level: 5 };
// Field-by-field — useful when constructing in steps
let e: Employee;
e.id = 1;
e.name = "Alice";
e.level = 5;
The first form is preferred. Order doesn’t matter; fields by name.
Accessing fields
Dot access:
log::info("{e.name} is level {e.level}");
Records and SQL
Records are the natural target for sql!{...}.collect() and
sql!{...}.one() when the query returns multiple columns. Pell
matches the SELECT list to record fields by position:
pub record EmpRow { id: number, name: text }
pub fn list_active() -> list<EmpRow> {
let xs: list<EmpRow> = sql! {
select id, name from employees where active = 1
}.collect();
return xs;
}
The column count in the SELECT must equal the field count in the record. Column names don’t have to match — only the positional order matters.
Lists — list<T>
list<T> lowers to an associative array — TABLE OF T INDEX BY
PLS_INTEGER:
let xs: list<number> = [1, 2, 3, 4, 5];
let names: list<text> = ["Alice", "Bob"];
let rows: list<EmpRow> = sql! { select id, name from employees }.collect();
The list literal [1, 2, 3] expands to per-index assignments at
compile time:
l_xs(1) := 1;
l_xs(2) := 2;
l_xs(3) := 3;
l_xs(4) := 4;
l_xs(5) := 5;
The corresponding type declaration is generated once per element type at the package level:
TYPE t_number_list IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
Iteration is covered in Iteration.
Type aliases via records
There’s no type alias = T syntax. If you want a named alias for a
type, declare a single-field record:
pub record EmployeeId { v: number }
This is the idiomatic way to make IDs of different entities type-incompatible.
Generic types in the surface
A handful of types take type parameters:
Option<T>— value-or-NULLResult<T, E>— value-or-error (see Errors)list<T>— associative array, 1-basedmap<K, V>— associative array keyed byKset<T>— associative array ofTwith sentinel valuescursor<T>— REF CURSOR, T is the row typestream<T>— pipelined function row streamrowtype<table_or_view>— Oracle%ROWTYPE
Use rowtype to match a table’s column shape without redeclaring it:
pub fn first_employee() -> rowtype<employees> {
return sql! { select * from employees fetch first 1 rows only }.one();
}
What about null / nil?
Pell uses Option<T> for “may be absent.” Bare NULL (Oracle’s NULL)
appears in SQL contexts via Option<T> returning None. There’s no
standalone nil keyword in the surface — that’s a deliberate choice
to avoid making every variable nullable by default.
A practical gotcha: in Oracle, the empty string '' is NULL. So:
let s: text = "";
if s == "" { // FALSE — '' is NULL
log::info("empty");
}
That comparison never matches because both sides are NULL, and NULL =
NULL is unknown (not true). When you need to track “was this set?”,
use an explicit flag:
let seen: number = 0;
let value: text;
if some_condition {
value = "got it";
seen = 1;
}
if seen = 1 {
log::info("value is {value}");
}
Where to go next
- SQL blocks —
sql!{}queries that return records, lists of records, and scalars. - Iteration —
for n in xs,forallfor bulk DML. - Types, sealed types, aggregates — pell’s OO surface and user-defined aggregates.