CELL
A database and a language,
under one roof.
The database stores one typed value. Code is data. Everything else is a program.
ONE VALUE.
ONE TYPE.
ONE WRITE.
type
defines the shape
schema
inhabits that type
const Greeting = struct { message: string }
const main = action () => {
db.evolve((s) => ({
schema: Greeting,
value: { message: "hello, cell" }
}))
io.log(db.get().value.message)
}
return { main }
11 chaptersFrom first value to full-stack app
15 MINUTES · FIRST CONTACT
Run Cell, then inspect what persisted.
Cell programs are modules. The module loads purely, exports an effectful main action, and the CLI calls it. Your database persists beside the program unless you choose another path.
node src/cli.ts run examples/01-basics.cell
bob is a minor for 3 more years
point: { first: 3, second: 4 }
node src/cli.ts state \
--db examples/02-todos.cell.db
value: the stored value
seq: committed write count
The execution model
- Load. The file is parsed, checked, and evaluated as a pure module.
- Export. The module's
returnvalue becomes its exports. - Run. If the exports contain
main, it must be an action and the CLI calls it. - Stay alive when needed. An inbox, listener, or peer subscription keeps a program running like a server.
A fresh Cell database already contains something. What?
Reveal
The unit value {} at type struct {}, with sequence 0. Initialization is simply the first migration away from that state.
VALUES · EXPRESSIONS · CONTROL FLOW
Small syntax. Explicit effects.
Cell is expression-oriented and immutable. Newlines and semicolons both separate statements. Blocks return through an explicit return; there is no reassignment and no exception recovery construct.
42 · 3.14 · -7"cell\nline"true · false[1, 2, 3]{ name: "Ada", active: true }{}Bindings and annotations
const creates an immutable lexical binding. An annotation checks and coerces the value; at nominal boundaries, that is also where raw values become sealed brands.
const answer: number = 42
const person = { name: "Ada", year: 1815 }
const renamed = { ...person, name: "Augusta" }Functions come in two kinds
A plain arrow is pure. An action may perform effects. Pure code can call only pure code; actions can call both. The rule is checked at definition time when possible and enforced at runtime through every call depth.
const square = (n: number): number => n * n
const report = action (n: number) => {
io.log("square:", square(n))
return {}
}
// Effectful function types say action fn
const useReporter = (f: action fn(number) => data) => f
return { square, report }
| Level | Operators | Notes |
|---|---|---|
| Unary | ! - | Boolean not; numeric negation |
| Multiply | * / % | Numeric operations |
| Add | + - | + also concatenates strings |
| Compare | < > <= >= | Numbers, or strings for ordering |
| Equality | == != | Deep structural equality; nominal brands matter |
| Logic | && || | Boolean logic, lowest precedence |
const abs = (n: number): number =>
if (n < 0) -n else n
const fact = (n: number): number =>
if (n <= 1) 1 else n * fact(n - 1)
const requireName = (name: string): string => {
if (name == "") throw "name is required"
return name
}
Control flow is made of expressions
if (condition) a else b produces a value. Either branch may be a block. Functions may recurse.
throw is for unrecoverable failure. Cell deliberately has no try; expected failure belongs in an enum such as Option(T) or your own Result.
Comments and strings
Use // line comments or /* block comments */. Strings support \n, \t, \", and \\ escapes.
STRUCTURAL CORE · NOMINAL CAPABILITIES
Types are values you can compute.
The primitives are number, string, boolean, data, and type. Types can be bound, compared, passed to functions, returned, and stored as the database schema.
extra fields are fineone exhaustive variantcapability-controlledStructs: width-subtyped records
A value may have more fields than the struct type requires, but not fewer. Struct literals support shorthand fields and spread.
Enums: tagged alternatives
A variant may carry one payload. Construct it through the type value, then destructure it with match. Annotated matches are checked for exhaustiveness; _ is the wildcard.
const Named = struct { name: string }
const ada: Named = { name: "Ada", role: "mathematician" }
const Shape = enum { circle: number, square: number, dot }
const area = (s: Shape): number =>
match (s) {
circle(r) => 3 * r * r,
square(w) => w * w,
dot => 0
}
T[]ordered valuesT{}stable identitiesfn(A) => Rrepeatableaction fn(A) => ReffectfulRef · DbRefpersistent identityGenerics are ordinary functions.
Module-load time is compile time. A function can accept values of type type and return a new type. No angle-bracket syntax or separate generic system is needed.
const Pair = (A: type, B: type) =>
struct { first: A, second: B }
const point: Pair(number, number) =
{ first: 3, second: 4 }
numbertransparentUserIdUserIdopaquecreate / elimhide makes a nominal capability
hide UserId = number binds a plain struct containing type, create, and elim. Inside the defining module, raw numbers and UserId are transparent to one another. An annotation seals values at the boundary.
Export { UserId: UserId.type } to reveal only the opaque type. Export { UserId } to grant creation and elimination capabilities.
hide UserId = number
const issue = (n: number): UserId => n
// Only the type crosses the module boundary.
return {
UserId: UserId.type,
issue
}
const claim = { claim: "positive" }
const p = verify((n: number) => n > 0, claim, 5)
const q = axiom(claim)
const grant = (proof: Proof(claim)): string =>
"granted"
// p == q: proof irrelevance
return { result: grant(p), same: p == q }
Proofs are branded, storable evidence
verify(predicate, proposition, subject) runs a pure decidable check. axiom(proposition) asserts the same evidence without checking. Proof(proposition) is a singleton nominal type, so evidence for another proposition is rejected and any two proofs of the same proposition compare equal.
At function creation, Cell reports definite field typos, operand and arity mistakes, non-exhaustive matches, purity violations, and opaque-brand access. Unannotated dynamic code remains allowed and is checked when values cross runtime boundaries.
PURE AT LOAD · SOURCE AS DATA
A module is a pure function.
Top-level evaluation is pure, and the module's return is its exports. That makes importing conceptually identical to calling a function—and makes source text safe to store, version, query, and reload.
import x from "./x.cell"statement sugaruse("./x.cell")file + cacheload(source)text from anywhere{ exports }ordinary valueimport std from "./lib/std.cell"
const source =
"return { double: (n: number) => n * 2, seed: params.seed }"
const generated = load(source, { seed: 7 })
const configured = use("./worker.cell", { mode: "fast" })
return {
answer: generated.double(21),
seed: generated.seed,
transact: std.transact
}
use and import
use(path, params?) resolves relative to the current module. Without params, file modules are cached by absolute path and circular imports are rejected. import name from "path" is sugar for the one-argument form.
load
load(source, params?) evaluates source text without filesystem access. Loaded modules receive params, just like CLI parameters, and mint nominal brands from their content identity.
Effects enter through actions
io.log(...) and io.read(path) are actions. Reading source from disk therefore happens before passing that string into the pure load operation.
Why does Cell store source text instead of closures?
Reveal
Closures capture runtime environments and do not serialize honestly. Source is readable, diffable data; load revives it into a module. The database rejects stored functions and tells you to store source instead.
STATE · EVOLVE · ATOMIC COERCION
The database is one dependent pair.
Every store holds { schema: type, value: schema }. There is one write primitive: db.evolve. The transition is pure, the returned value is checked against the returned schema, and the pair commits atomically or not at all.
{ schema: V1,
value: { n: 1 } }{ schema: V2,
value: { n: 2, s: "hi" } }schema check + data check + atomic persist + signal propagation
const V1 = struct { n: number }
const V2 = struct { n: number, note: string }
const main = action () => {
// initialize
db.evolve((s) => ({ schema: V1, value: { n: 1 } }))
// transaction: preserve schema
db.evolve((s) => ({
schema: s.schema,
value: { n: s.value.n + 1 }
}))
// migration: change both
db.evolve((s) => ({
schema: V2,
value: { ...s.value, note: "ready" }
}))
}
Initialization is a migration
A fresh database begins at { schema: struct {}, value: {} }. Moving to your first application schema is the same operation as every later migration.
Transactions are a two-line library
const transact = action (f: fn(data) => data) =>
db.evolve((s) => ({ schema: s.schema, value: f(s.value) }))db.get() is effectful
Reading the current store state is an action because the value changes over time. Inside an evolve transition, use the supplied state s; calling db.get() there would violate purity.
Atomic-rename snapshot containing sequence, serialized schema, and serialized value.
An operation ledger records each committed sequence and timestamp.
Types, nominal brands, refs, database refs, and collection identities survive restart.
An invalid next value throws before assignment, so the old pair remains unchanged.
TABLES WHEN YOU WANT THEM
Collections add stable identity to values.
An array is positional. A collection assigns every element a stable internal id and returns a Ref when you add a value. This is enough to recover tables and foreign keys without making them the database foundation.
{ title: "read", done: true }{ title: "write", done: false }Ref(c1,#2){ title: "ship", done: false }map changes values in place, so the reference still points to element #2 after a migration.
const empty = collection([])
const added = empty.add({ title: "learn", done: false })
const todos = added.collection
const ref = added.ref
const done = todos.update(ref, (t) => ({ ...t, done: true }))
const found = done.get(ref) // some(todo)
const entries = done.entries() // [{ ref, value }]
const values = done.values() // [todo]
const migrated = done.map((t) => ({ ...t, priority: 1 }))
const removed = migrated.remove(ref)
The collection API
.size- number of live elements
.add(value){ collection, ref }.update(ref, pureFn)- new collection, same id
.remove(ref)- new collection without that id
.get(ref)Option(T).values()- id-ordered
T[] .entries(){ ref, value }[].map(pureFn)- identity-preserving transform
deref(container, ref) walks nested data to locate the referenced collection and returns an Option. A ref never grants mutation.
Is a collection a separate table outside the database value?
Reveal
No. A collection is an ordinary immutable data value nested anywhere in the one stored value. Its stable ids make table-like relationships possible without changing the database model.
PURE QUERIES · CHANGE PROPAGATION
The database is reactive by construction.
A source signal is a pure query over the state pair. It records a baseline when created and fires only after a commit changes its result. Observers are actions; the graph therefore pushes effects safely downstream from pure computation.
{ todos, page }done.length"2 complete"io.log(...)const doneCount = db.signal((s) =>
s.value.todos.values().filter((t) => t.done).length)
const label = doneCount.map(
(n) => str(n) + " complete",
(a, b) => a == b
)
label.out(action (text) => io.log(text))
// Later:
label.stop()
No initial fire. Creation computes a baseline; do initial rendering explicitly.
Source suppression. db.signal(query, equals?) fires only when the query result changes.
Unforgetful map. map(f) forwards every upstream fire, even if its own output repeats.
Forgetful map. map(f, equals) suppresses equivalent derived values.
Custom equivalence. Equality can be any pure two-argument relation—even a domain-specific notion such as equal string length.
Stop downstream. stop() detaches a node and prevents its observers from running.
sig.each(build) gives every key its own subgraph.
When a key appears, Cell runs an action that builds signals for that key. Every node created during the build is captured in its scope. When the key disappears, the entire scope is torn down. Duplicate keys create only one scope.
["lab", "attic"]Cascades may write again
An out observer may call db.evolve, producing another propagation pass. Convergent loops settle because unchanged results are suppressed. A depth guard stops genuinely self-feeding cycles with a clear error after 64 nested fires.
EXPAND · MIGRATE · CONTRACT
A migration is an evolve that changes type and value.
Because readers are signals and collection refs survive map, the familiar expand–migrate–contract rollout is a composition of ordinary primitives—not a special migration subsystem.
// EXPAND: keep old field, add new fields
std.migrate(V2, (v) => ({
people: v.people.map((p) => ({
name: p.name,
firstName: part(p.name.split(" "), 0),
lastName: part(p.name.split(" "), 1)
}))
}))
const newReader = db.signal((s) =>
s.value.people.values().map((p) => p.firstName).join(" & "))
oldReader.stop()
// CONTRACT: refs survive; newReader's result does not change
std.migrate(V3, (v) => ({
people: v.people.map((p) => ({ firstName: p.firstName, lastName: p.lastName }))
}))
Can new code observe an old schema inside one deployment?
Reveal
Not when code, schema, and data live in the same state pair and one evolve migrates all three. Across separate sovereign deployments, version skew is normal and can be handled with expand–migrate–contract or by inspecting a peer's first-class schema.
SOVEREIGN ENVIRONMENTS · THREE RELATIONS
No database gives up its writes.
Every store is sovereign. Environments relate through ownership, read-only subscription, or durable messages. “Upstream” is a per-query relationship, not a global hierarchy.
Parent → child. The parent created and controls the child database.
const eu = db.spawn("eu")
db.open(eu).evolve(...)Subscriber pulls a read-only view from a peer.
const peer = db.connect(addr)
peer.signal(query)Sender pushes data; the owner's own action decides what it means.
peer.send(message)
db.inbox(action (msg) => ...)Local directory or HTTP URL
db.connect(addr) accepts a database directory or an http(s):// URL. In-process peers deliver synchronously. Other processes are watched through the filesystem; remote subscriptions use HTTP long-poll.
Remote views are eventually consistent
peer.get() returns the newest state this process has seen. Starting a signal begins live listening; a send-only program holds no subscription and can exit.
Messages are durable, at least once
A file-peer message to an offline owner lands in its inbox directory and is consumed when an inbox handler starts. Handlers should tolerate retry. Authorization is ordinary owner policy in the handler.
const main = action () => {
const platform = db.connect(params.platform)
platform.signal((s) => s.value.status)
.out(action (status) => io.log("live:", status))
platform.send({
kind: "deploy",
source: io.read(params.source),
key: params.secret
})
}
return { main }
db.listen(7070)serves exactly
GET /statesnapshotGET /waitlong-pollPOST /inboxmessagesTHE TAB IS ANOTHER PEER
Full stack means the same language on both sides.
Pass a page query to db.listen(port, page). Cell serves an HTML shell, the dependency-free interpreter, and the page module returned by that query. The browser receives server and dom as its effect boundaries.
const main = action () => {
if (db.get().schema == struct {}) {
const page = io.read("./page.cell")
db.evolve((s) => ({
schema: struct { todos: data, page: string },
value: { todos: collection([]), page: page }
}))
}
db.inbox(action (msg) => io.log("message:", msg))
db.listen(num(params.port), (s) => s.value.page)
}
return { main }
const item = (t: data): string => "<li>" + t.title + "</li>"
const view = (s: data): string =>
"<h1>todos</h1>" +
s.value.todos.values().map(item).join("")
const main = action () => {
dom.render(view(server.get()))
server.signal(view).out(
action (html) => dom.render(html))
dom.on("click", "#add", action (arg) =>
server.send({ kind: "add", title: dom.value("#title") }))
}
return { main }
dom.render(html)replace the root HTMLdom.on(event, selector, action)delegate an event; data-arg becomes the action argumentdom.value(selector)read an input valuedom.list(selector, keys)create keyed list slotsdom.patch(key, html)replace exactly one keyed slotDeploying UI is a data write
When the stored page source changes, connected browsers fetch the new module and hot-reload it without replacing server state. Combine signal.each, dom.list, and dom.patch to give every item its own subgraph: toggling one todo patches one slot with no virtual DOM.
node src/cli.ts run examples/08-web/app.cell --param port=8080
Then open localhost:8080. Push another stored UI with examples/08-web/push-ui.cell.
EVERY IMPLEMENTED SURFACE
Keep this beside your editor.
This appendix covers the complete current built-in surface. Cell is a prototype; source and tests remain authoritative when it evolves.
TYPE FORMS08
| Form | Meaning |
|---|---|
number string boolean | Primitive scalar types |
data | Any serializable Cell data; excludes closures, builtins, signals |
type | The type of type values |
struct { a: A } | Structural record; extra fields accepted |
enum { some: T, none } | Tagged union |
T[] / Array(T) | Array type |
T{} / Collection(T) | Identity-bearing collection type |
fn(A) => R / action fn(A) => R | Pure or effectful function type |
GLOBALS & BUILT-INS15
| Name | Result / role |
|---|---|
Option(T), some(v), none | Standard optional-value enum |
Proof(p), verify(pred,p,x), axiom(p) | Exact branded evidence |
collection(array?) | New collection; empty when omitted |
str(value) | Display conversion to string |
num(stringOrNumber) | Numeric conversion; throws for invalid text |
assert(bool, message?) | Returns unit or throws assertion failure |
deref(container, ref) | Find referenced value anywhere in nested data |
load(source, params?) | Purely evaluate source text |
use(path, params?) | Evaluate a file module relative to current module |
io.log(...values) | Effectful stdout logging |
io.read(path) | Effectful file read, available in file modules |
params | Struct passed by CLI, load, or use; empty unit by default |
db | The current sovereign database handle |
Ref | Collection element reference type |
DbRef | Child database reference type; values expose .name |
ARRAY & STRING MEMBERS11
| Member | Behavior |
|---|---|
array.length | Element count |
.map(pureFn) | Transform each element |
.filter(purePredicate) | Keep matching elements |
.reduce(pureFn, initial) | Fold left |
.find(purePredicate) | First match as Option(T) |
.each(fn) | Call for every element; an action callback may have effects |
.at(index) | Element as Option(T) |
.concat(other) | Combine arrays |
.append(value) | New array with one value added |
.join(separator) | Display elements and join as string |
string.length / string.split(separator) | Character count / string array |
COLLECTION MEMBERS08
| Member | Behavior |
|---|---|
.size | Live element count |
.add(value) | { collection, ref } |
.update(ref, pureFn) | Replace one value while keeping identity |
.remove(ref) | Delete one element |
.get(ref) | Value as Option(T) |
.values() | Values ordered by element id |
.entries() | { ref, value }[] |
.map(pureFn) | Transform all values, preserving ids and refs |
DATABASE HANDLE08
| Member | Behavior |
|---|---|
db.get() | Current { schema, value } |
db.evolve(pureTransition) | Atomic checked write |
db.signal(query, equals?) | Reactive pure query |
db.spawn(name) | Create/open owned child and return DbRef |
db.open(dbRef) | Child database handle |
db.connect(address) | Read/send peer handle for directory or URL |
db.inbox(actionHandler) | Handle pending and future messages |
db.listen(port, pageQuery?) | Serve peer protocol; optionally browser runtime |
SIGNAL MEMBERS04
| Member | Behavior |
|---|---|
.map(pureFn, equals?) | Derived signal; without equality it forwards every upstream fire |
.out(action) | Effect observer, returns a stoppable signal node |
.each(actionBuilder) | One scoped subgraph per distinct key in an array signal |
.stop() | Detach this node; an each also tears down its scopes |
PEER HANDLE04
| Member | Behavior |
|---|---|
peer.addr | Resolved directory or normalized URL |
peer.get() | Latest state seen locally |
peer.signal(query, equals?) | Subscribe; URL long-poll starts on first call |
peer.send(data) | Fire-and-forget message; data values only |
CLI03
node src/cli.ts run file.cell
node src/cli.ts run file.cell --db path/to/store --param key=value --param other=value
node src/cli.ts state --db path/to/store
All CLI parameters arrive as strings in params; convert numbers explicitly with num(params.port).
examples/01-basics.cellexamples/02-todos.cellexamples/03-migrate.cellexamples/04-vcs-hmr.cellexamples/05-deployments.cellexamples/06-platform/examples/07-remote/examples/08-web/Now read the examples as one program.
Each advanced example recombines the same few ideas: values, types, one atomic evolve, pure queries, effectful observers, and source as data.
RETRACE FROM THE START