Knot is a functional relational programming language. Relations are the primary data structure, computation is pure and functional, and state is automatically persisted.
- Relations are the data structure — not lists, not arrays.
[T]is a typed set ofTvalues. - Effects are inferred — the compiler tracks reads and writes. No annotations needed.
- ADTs are native to relations — a
[Shape]holds circles and rects in one relation. The tag is an implementation detail. - No keys — relations are sets. Identity is structural. The runtime handles indexing.
- State is visible — source relations (mutable, persisted) are prefixed with
*, derived relations (read-only) with&. Every reference site shows whether you're touching state. No ORM, no SQL.
A relation is a typed set of values. Duplicate values cannot exist — it's a set.
-- Literal relation (constant — pure, no DB references)
names = ["Alice", "Bob", "Carol"]
-- Empty relation
none = [ ]
There are five kinds of top-level declarations:
-- Source: stored in DB, mutable via `*people = ...`
*people : [Person]
*orders : [{customer: Text, amount: Int 1}]
-- View: defined by a query over source relations, settable (writes propagate back)
*openTodos = do
t <- *todos
yield {title t.title owner t.owner priority t.priority status Open {}}
-- Constant: a pure expression with no DB references (zero-argument function)
maxRetries = 3
defaultPriority = Low {}
httpCodes = [{code 200 name "OK"}, {code 404 name "Not Found"}]
-- Derived: references source relations, recomputed on access (read-only)
&seniors = do
people <- *people
yield (filter (\p -> p.age > 65) people)
-- Type alias: just a name for a type
type Person = {name: Text, age: Int 1}
The prefix determines mutability, the presence of a body determines whether it's stored or computed:
| Declaration | Category | How the compiler knows |
|---|---|---|
*foo : [T] |
Source (persisted) | * prefix, no body |
*foo = expr |
View (read/write) | * prefix, has body |
&foo = expr |
Derived (read-only) | & prefix, has body |
foo = expr (pure) |
Constant | No prefix, no effects inferred |
type Foo = T |
Type alias | type keyword |
Every ADT defines a relation schema. Each constructor is a record variant. A [T] holds values of any variant of T in the same relation.
data Shape
= Circle {radius: Float 1}
| Rect {width: Float 1, height: Float 1}
*shapes : [Shape] -- source (no body)
Single-variant types are equivalent to bare records:
-- These are the same:
*people : [{name: Text, age: Int 1}]
*people : [Person]
Constructors are the interface for building values, inserting, and querying. The tag/discriminator is an internal storage detail that never appears in the language.
Every constructor requires {} — even those with no fields. This keeps the syntax uniform: a constructor is always Name {fields}, whether it has fields or not. There is no distinction between "a constructor" and "a constructor applied to a record."
Bool, Maybe, and Result are built-in — their constructors (True/False, Nothing/Just, Ok/Err) are always available without a data declaration. Booleans are written Bool.True {} and Bool.False {} and can be used in case patterns. do-notation works on Maybe and Result out of the box via the compiler's structural support (short-circuiting on Nothing/Err), with no instances to write.
data Maybe a = Nothing {} | Just {value: a}
data List a = Nil {} | Cons {head: a, tail: List a}
Any type can be a column type — including sum types, nested records, and nested relations.
data Priority = Low {} | Medium {} | High {} | Critical {}
data Status
= Open {}
| InProgress {assignee: Text}
| Resolved {resolution: Text}
| Blocked {reason: Text, dependencies: [{title: Text}]}
*tickets : [{title: Text, priority: Priority, status: Status}]
A field can hold a [] — a set nested inside a row. This departs from SQL's first normal form restriction.
type Person = {name: Text, age: Int 1}
*teams : [{name: Text, members: [Person]}]
Bind through multiple levels with <-:
-- All people across all teams
&allMembers = do
teams <- *teams
with {result do
t <- teams
m <- t.members
yield {team t.name member m.name}}
(do
yield result)
-- Engineers on large teams
&engineers = do
teams <- *teams
with {result do
t <- teams
where (count t.members) > 10
m <- t.members
where m.role == "engineer"
yield {team t.name name m.name}}
(do
yield result)
Write *rel = ... with a map over the outer relation that transforms the nested relation:
-- Add a member to a team
addMember = \teamName person -> do
teams <- *teams
*teams = do
t <- teams
yield (if t.name == teamName
then {t | members union t.members [person]}
else t)
-- Remove a member from all teams
removePerson = \personName -> do
teams <- *teams
*teams = do
t <- teams
yield {t | members do
m <- t.members
where m.name != personName
yield m}
Convert between flat and nested representations:
-- Flat relation
type FlatMembership = {team: Text, member: Text, age: Int 1}
*memberships : [FlatMembership]
-- Nest: group a flat relation into nested structure
&nested = do
memberships <- *memberships
with {result do
t <- do m <- memberships; yield m.team
yield {name t members (do
m <- memberships
where m.team == t
yield {name m.member age m.age})}}
(do
yield result)
-- Flatten: expand nested relation into flat rows
&flat = do
teams <- *teams
with {result do
t <- teams
m <- t.members
yield {team t.name member m.name age m.age}}
(do
yield result)
Nesting is arbitrarily deep:
type Course = {name: Text, students: [{name: Text, grades: [{subject: Text, score: Int 1}]}]}
*departments : [{name: Text, courses: [Course]}]
-- Find all failing grades across all departments
&failing = do
departments <- *departments
with {result do
d <- departments
c <- d.courses
s <- c.students
g <- s.grades
where g.score < 50
yield {dept d.name course c.name student s.name subject g.subject score g.score}}
(do
yield result)
do syntax is not trait-polymorphic — there is no Monad trait a user type can
implement. Instead the compiler recognizes do blocks over exactly four types
and compiles each by a dedicated path:
[a](relation comprehension):<-iterates rows,wherefilters,yieldemits a row. Compiled to SQL when over source relations (see Querying), otherwise to an in-memory loop.IO {e} a:<-sequences effects,yieldreturns a pure value. IO do blocks (those containing IO-returning expressions like*rel,println,readFile,now) use a dedicated IO compilation path that sequences actions directly.Maybe a:<-unwrapsJust, short-circuits onNothing.Result e a:<-unwrapsOk, short-circuits onErr.
Higher-order operations (map, fold, traverse, bind, …) are ordinary
polymorphic functions over these concrete types — there are no instances to
write, no dictionaries, and no higher-kinded trait bounds. take/drop work on
both Text (characters) and [a] (rows) as built-in polymorphic functions, so
the same take 5 x works on a string or a relation.
Do blocks can appear anywhere an expression is expected, including as function arguments: f do ... or f (do ...).
x <- exprsequences/binds, according to which of the four typesexprhasyield xproduces a result in that typewhere condfilters (relation comprehension) or guards (Maybe/Result)
IO do blocks (those containing IO-returning expressions like *rel, println, readFile, now) are not desugared to a generic bind — they use a dedicated compilation path that sequences IO actions directly.
-- do with [] (pure relation comprehension over plain values)
richOnes = \employees departments -> do
e <- employees
d <- departments
where e.dept == d.name
yield {e.name, e.salary, d.budget}
-- IO do block (binds from *rel, which returns IO)
&richEmployees = do
employees <- *employees
departments <- *departments
yield (richOnes employees departments)
-- do with Maybe
safeDivide = \a b -> case b == 0 of Bool.True {} -> Nothing {}; Bool.False {} -> Just {value: a / b}
tryCompute = do
x <- safeDivide 10 2
y <- safeDivide x 5
yield (x + y)
-- do with Result
safeDivideR = \a b -> case b == 0 of Bool.True {} -> Err {error "div by zero"}; Bool.False {} -> Ok {value: a / b}
computeR = do
x <- safeDivideR 10 2
yield (x + 1)
The only []-specific primitive is the relation write; everything else is a
built-in function over relations:
| Primitive | Form | Description |
|---|---|---|
| relation write | *rel = expr : IO {} {} |
Make a persistent relation equal to expr (use replace *rel = expr to force a full overwrite) |
The relation operations are built-in functions, not trait methods:
| Operation | Built-in |
|---|---|
| empty relation | [] literal |
yield (singleton) |
comprehension yield |
<- (iterate) |
comprehension <- |
union |
base.union |
fold |
base.fold |
map |
base.map |
These are built-in functions over relations plus the *rel = expr write. The compiler recognizes these patterns and executes them as efficient set operations (hash joins, indexed lookups, SQL pushdown, etc.) — the operations define semantics, the runtime chooses the strategy.
where — keep matching rows (in a comprehension, or via base.filter):
where = \cond -> case cond of Bool.True {} -> yield {}; Bool.False {} -> empty
filter — filter rows:
filter = \p rel -> do
x <- rel
where (p x)
yield x
join — combine relations on a condition:
join = \a b -> do
x <- a
y <- b
where (x.id == y.id)
yield {x, y}
diff — rows in one relation but not another:
elem = \x rel -> fold (\acc r -> acc || r == x) False {} rel
diff = \a b -> do
x <- a
where (not (elem x b))
yield x
inter — rows in both relations:
inter = \a b -> do
x <- a
where (contains x b)
yield x
insert — add a value (union with a singleton). Recognized as an INSERT:
*rel = union *rel [x]
delete — remove matching rows (keep the rest). Recognized as a DELETE:
*rel = filter (\x -> not (p x)) *rel
update — transform matching rows. Recognized as an UPDATE:
*rel = map (\x -> case p x of Bool.True {} -> f x; Bool.False {} -> x) *rel
count, sum, avg — folds:
count = \rel -> fold (\n _ -> n + 1) 0 rel
sum = \f rel -> fold (\acc x -> acc + f x) 0 rel
match — filter a relation of ADT values to one variant, extracting the payload (built-in base.match):
circles = base.match Shape.Circle shapes -- [Shape] -> [{radius: Float 1}]
Relation comprehensions use do syntax with yield to produce rows. A read-only comprehension binds each row of a source with <- (like FROM), filters with where (like WHERE), and emits rows with yield:
with {result (do
e <- *employees
d <- *departments
where e.dept == d.name -- the join condition
where d.budget > 1000000 -- extra filter
yield {name e.ename salary e.salary budget d.budget})}
yield result
Binding two sources and relating them with an equi-join predicate (e.dept == d.name) is a join. A read-only join/filter comprehension like this compiles to a single multi-table SQL SELECT ... FROM "_knot_employees" AS t0, "_knot_departments" AS t1 WHERE t0."dept" = t1."name" AND t1."budget" > ?, with the join and filter columns auto-indexed; anything the planner can't translate falls back to an in-memory join with identical results.
Record fields in the yield need explicit names — {name e.ename}, not {e.ename} (there is no field-name shorthand). Relation references (*rel, &rel) return IO {} value; binding them inside a read-only comprehension is handled directly by the compiler, which reads the sources as part of the query.
Derived combinators like filter compose with |>:
&highEarners = do
employees <- *employees
yield (employees
|> filter (\e -> e.salary > 150000)
|> map (\e -> {name e.name salary e.salary}))
match filters to one variant and exposes its fields:
&circles = do -- : IO {} [{radius: Float 1}]
shapes <- *shapes
yield (shapes |> match Circle)
&rects = do -- : IO {} [{width: Float 1, height: Float 1}]
shapes <- *shapes
yield (shapes |> match Rect)
&bigCircles = do
circles <- &circles
yield (filter (\c -> c.radius > 10) circles)
Pattern matching on <- filters and binds in one step:
&bigCircleAreas = do
shapes <- *shapes
with {result do
Shape.Circle c <- shapes
where c.radius > 10
yield {area pi * c.radius * c.radius}}
(do
yield result)
&blockedDetails = do
tickets <- *tickets
with {result do
t <- tickets
Blocked {dependencies} <- t.status
dep <- dependencies
yield {t.title, dep}}
(do
yield result)
Operate on the whole relation with case:
scale = \factor -> do
shapes <- *shapes
*shapes = do
s <- shapes
yield (case s of
Circle {radius} -> Circle {radius radius * factor}
Rect {width, height} -> Rect {width width * factor height height * factor})
describe = \rel -> case rel of
[ ] -> "empty"
[{name: n}] -> "just " ++ n
Cons h _ -> "first of many: " ++ show h
[ ] matches an empty relation. [p1, p2, ...] matches a relation with exactly that many rows in any iteration order. Cons head tail matches a non-empty relation, binding head to the first row and tail to the rest (the relation has no inherent order; Cons chooses a deterministic iteration order for the match).
groupBy partitions a relation by key fields, like SQL's GROUP BY. After groupBy, the bound variable becomes a sub-relation (the group), enabling aggregation:
&workload = do
todos <- *todos
with {result do
t <- todos
where t.done == 0
groupBy {t.owner}
yield {owner t.owner count count t}}
(do
yield result)
The key expression is a record literal whose fields select the grouping columns. After groupBy {t.owner}, t is rebound from a single row to a sub-relation of all rows sharing that owner value. Field access on a group (e.g. t.owner) returns the shared key value. Aggregate functions like count operate on the whole group.
Multiple key fields group by their combination:
&summary = do
orders <- *orders
with {result do
o <- orders
groupBy {region o.region status o.status}
yield {region o.region status o.status total count o}}
(do
yield result)
Grouping is executed via SQLite — key columns are inserted into a temp table and sorted with ORDER BY, then consecutive rows with matching keys are collected into groups.
All state operations in Knot return IO values. The IO type carries an effect set that distinguishes DB operations from external effects:
- DB operations return
IO {} value— the empty effect set{}indicates pure database interaction with no external side effects. Source refs (*rel), derived refs (&rel), and relation writes (*rel = expr,replace *rel = expr) all returnIO {} value. - External effects carry specific tags:
IO {console} {},IO {fs} Text,IO {network} Result,IO {clock} Int Ms,IO {random} Float 1.
This unified model means all stateful code lives in IO do-blocks, while pure comprehensions over plain values remain non-IO.
Effectful functions return descriptions of effects (IO {effects} a) rather than performing them. IO values are thunks that execute when run.
-- DB operations return IO with empty effects
*people : [Person]
-- *people : IO {} [Person]
-- println returns an IO action with console effect
println : a -> IO {console} {}
-- readFile returns an IO action with fs effect
readFile : Text -> IO {fs} Text
-- now returns an IO action with clock effect, tagged with the built-in Ms unit
now : IO {clock} Int Ms
IO do-blocks sequence effects. The <- operator runs an IO action and binds its result. Since relation references return IO, you bind to get the plain value, then use pure comprehensions:
main = do
people <- *people -- IO {} [Person] → binds [Person]
content <- readFile "input.txt" -- IO {fs} Text → binds Text
println content -- IO {console} {}
t <- now -- IO {clock} Int Ms → binds Int Ms
println ("time: " ++ show t)
yield {}
-- overall type: IO {fs, console, clock} {}
The pattern for querying relations is: IO-bind to get the value, then pure comprehension on the plain value:
&richEmployees = do
employees <- *employees -- IO bind: [Employee] from IO {} [Employee]
with {result do -- pure comprehension on the value
e <- employees
where e.salary > 100000
yield e}
(do
yield result)
The compiler detects whether a do-block is IO or relational based on the types of bound expressions. IO do-blocks work correctly in all positions, including as arms of a case.
DB effects are still inferred as fine-grained capabilities ({r *rel}, {w *rel}), but all relation access returns IO values:
-- Pure (inferred: no effects)
formatName = \n -> toUpper (take 1 n) ++ drop 1 n
-- DB read (inferred: {r *people})
&seniors = do
people <- *people
yield (filter (\p -> p.age > 65) people)
-- DB write (inferred: {rw *people})
birthday = \name -> do
people <- *people
*people = do
p <- people
yield (case p.name == name of Bool.True {} -> {p | age p.age + 1}; Bool.False {} -> p)
Effect signatures are inferred but can be written explicitly:
birthday : {rw *people} Text -> IO {} {}
birthday = \name -> do
people <- *people
*people = do
p <- people
yield (case p.name == name of Bool.True {} -> {p | age p.age + 1}; Bool.False {} -> p)
If the body uses a capability not listed in the signature, the compiler rejects it.
atomic takes an IO body and runs it in a transaction. The body must only contain DB interactions (empty effect set) — no external IO (console, fs, etc.) is allowed inside atomic.
atomic : IO {} a -> IO {} a
-- DB writes go in `atomic`, IO happens after commit
handleOrder = \req -> do
orderId <- atomic do
orders <- *orders
*orders = union orders [{item req.body.item qty 1}]
newOrders <- *orders
yield (count newOrders)
println ("New order #" ++ show orderId)
yield {orderId}
retry is used inside atomic blocks to implement STM (Software Transactional Memory) style concurrency. When executed, retry causes the transaction to rollback and wait until some relation changes, then re-executes the entire atomic block.
retry : forall a. a -- bottom type, never returns
The compiler enforces that retry is only used inside atomic. This enables blocking waits on relation state without busy-polling:
-- Wait until a condition is met
waitForReady = atomic do
status <- *status
where (count (filter (\s -> s.ready) status)) == 0
retry
A naive STM implementation wakes every parked watcher on every commit. Knot narrows wakeups to rows the atomic block actually read:
- Codegen inspects each
WHERE/single (filter (\r -> r.col OP expr) rows)pattern in the atomic body and, for the SQL-pushed-down query path, registers a row-level read filter alongside the broad table read. Supported column predicates are equality (==/!=), ordered comparison (</<=/>/>=), and membership (r.col == a || r.col == b, treated asIN (a, b)). - Each write — INSERT, UPDATE, or DELETE — emits a
WriteEventcarrying the affected rows' column values. The runtime evaluates each watcher's filter against the event; only matching watchers wake. - A bulk replacement (
*rel = ...) emitsWriteEvent::Bulkwhich wakes every watcher on that table conservatively, since the row deltas are not enumerated.
This means a worker retrying on WHERE id = 1 is unaffected by writes to
id = 2, and a worker retrying on status IN ("queued", "running") is
unaffected by writes that leave the status outside that set. The end result is
the contention pattern of a fine-grained lock manager but expressed as
ordinary functional code.
Built-in functions for file I/O. All return IO {fs} values.
| Function | Type | Description |
|---|---|---|
readFile |
Text -> IO {fs} Text |
Read entire file contents as text |
writeFile |
Text -> Text -> IO {fs} {} |
Write text to a file (creates or overwrites) |
appendFile |
Text -> Text -> IO {fs} {} |
Append text to a file |
fileExists |
Text -> IO {fs} Bool |
Check whether a path exists |
removeFile |
Text -> IO {fs} {} |
Delete a file |
listDir |
Text -> IO {fs} [Text] |
List directory entries as a relation of filenames |
-- Copy a file (IO do-block)
copyFile = \src dst -> do
content <- readFile src
writeFile dst content
-- Append a log line
log = \msg -> appendFile "app.log" (msg ++ "\n")
-- List .knot files
knotFiles = do
files <- listDir "."
yield (filter (\f -> contains ".knot" f) files)
-- Conditional read
loadConfig = \path -> do
exists <- fileExists path
if exists
then readFile path
else yield "{}"
fork runs an IO action on a new OS thread. It is fire-and-forget — the forked action runs independently, but its effects are still visible in the caller's IO type. The spawned action can return any value (the result is discarded). Each spawned thread gets its own SQLite connection (WAL mode enables concurrent access). The main thread waits for all spawned threads before exiting.
fork : IO {| r} a -> IO {| r} {}
The spawned action's effect row r propagates through fork to the caller — a program that forks an IO that calls println is visibly typed with {console} in its IO row, so the effect system still reflects what the program can do. Do blocks can be passed as arguments without parentheses: fork do ....
*counter : [{n: Int 1}]
increment = do
c <- *counter
*counter = [{n (fold (\_ x -> x.n) 0 c) + 1}]
main = do
*counter = [{n 0}]
fork do
increment
increment
fork do
increment
increment
-- main waits for all threads before exiting
The combination of fork, atomic, and retry enables STM-style concurrent coordination:
*tasks : [{id: Int 1, status: Text}]
waitForCompletion = \id -> atomic do
tasks <- *tasks
with {task do
t <- tasks
where t.id == id
where t.status == "done"
yield t}
(do
where (count task) == 0
retry
yield task)
main = do
*tasks = [{id 1 status "pending"}]
fork do
-- simulate work
atomic do
*tasks = [{id 1 status "done"}]
result <- waitForCompletion 1
println result
SQLite WAL mode ensures that concurrent readers and writers do not block each other. Each thread operates on its own connection, and atomic provides transaction isolation within a thread.
race runs two IO actions concurrently and returns as soon as one wins. Each
argument carries its own effect row; the result IO's row is the union of both,
written r1 \/ r2. Any effects required by either side propagate into the
result IO. The winner is reported via the built-in Result a b ADT —
Err {error: a} when the left action wins, Ok {value: b} when the right
action wins.
race : IO {| r1} a -> IO {| r2} b -> IO {| r1 \/ r2} (Result a b)
slow = do
sleep 1000 Ms
yield "slow"
fast = do
sleep 50 Ms
yield "fast"
main = do
r <- race slow fast
case r of
Err {error: a} -> println ("left won: " ++ a)
Ok {value: b} -> println ("right won: " ++ b)
yield {}
Cancellation is cooperative but aggressive:
- The parent never joins the loser. It returns as soon as it observes a winner, so the loser does not block program progress.
- Each worker carries a thread-local
CancelToken.knot_io_runchecks the token between every IO thunk, so the loser unwinds at its next bind/then boundary instead of running to completion. - Blocking primitives like
sleeppark on the token's condvar instead ofstd::thread::sleep, so a loser stuck in a long sleep wakes immediately when the peer wins. - The loser is still tracked for the final program-exit join (the runtime waits for every spawned thread before closing the database), so cancellation is best-effort progress rather than thread termination.
race is not permitted inside atomic blocks — its effects are not part of the savepoint and cannot be rolled back.
Routes are first-class. A route declaration defines an ADT and its HTTP mapping in one place. Each line maps a method + typed path to a constructor. The constructor's fields are the union of path params, query params, body fields, and request headers.
/{name: Type}in the path — path parameter?{name: Type, ...}after the path — query parameters{name: Type, ...}after the verb — request bodyheaders {name: Type, ...}after query params — request headersheaders {name: Type, ...}after response type — response headers
Header field names use camelCase and auto-convert to HTTP-Header-Case (authorization → Authorization, contentType → Content-Type, xRequestId → X-Request-Id). Optional headers use Maybe type.
Constructors are bare names — their fields are automatically the union of path, query, body, and header params.
route Api where
GET /todos/{user: Text}?{page: Int 1, limit: Int 1} = GetTodos
POST {title: Text, owner: Text, priority: Priority} /todos = AddTodo
PUT {owner: Text, person: Text} /todos/{title: Text}/assign = AssignTodo
GET /workload = GetWorkload
Handlers are bound per-endpoint with serve API where — the compiler ensures every endpoint has exactly one handler:
api = serve Api where
GetTodos = \{user, page, limit} -> do
todos <- pendingFor user page limit
yield Ok {value todos}
AddTodo = \{title, owner, priority} -> do
atomic (add title owner priority)
yield Ok {value {ok True {}}}
AssignTodo = \{title, owner, person} -> do
atomic (assign title owner person)
yield Ok {value {ok True {}}}
GetWorkload = \{} -> do
w <- &workload
yield Ok {value: w}
main = listen 8080 api
serve API where produces a value of type Server API _ (a polymorphic row variable when handlers have no concrete effects) or Server API {effects} when handlers carry concrete effects — e.g. Server API {console} if a handler calls println. Each handler receives the request record (path/query/body/header fields) and returns Result HttpError T, where T is the response type declared on the endpoint and HttpError = {status: Int 1, message: Text}. Handler effects propagate through listen into the program's IO type. No string routes, no untyped params, no missing handlers.
Handlers return Result HttpError T. Ok {value: v} responds with HTTP 200 and serializes v as JSON. Err {error: {status, message}} responds with the given status code and a JSON error body:
api = serve Api where
GetUser = \{id} -> do
users <- *people
case filter (\u -> u.id == id) users of
[ ] -> yield Err {error {status 404 message "user not found"}}
Cons u _ -> yield Ok {value: u}
CreateUser = \{name, email} -> do
case length name == 0 of
Bool.True {} -> yield Err {error {status 400 message "name required"}}
Bool.False {} -> do
atomic do
users <- *people
*people = union users [{name name email email}]
yield Ok {value {name name email email}}
Status codes are clamped to the range 100..=599. Common codes: 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found), 409 (conflict), 500 (internal error). The runtime emits 400 automatically for path/query/body/header parsing failures and refinement violations, and 404 for unmatched routes — handlers only need to return Err for application-level errors.
Return types can be declared per-endpoint:
route Api where
GET /todos/{user: Text} -> [{title: Text, priority: Priority}] = GetTodos
POST {title: Text, owner: Text} /todos -> {ok: Bool} = AddTodo
GET /workload -> [{owner: Text, count: Int 1}] = GetWorkload
The compiler checks that each handler returns the declared type.
Request and response headers are declared with the headers keyword:
route Api where
GET /todos headers {authorization: Text} -> [Todo] headers {xTotalCount: Int 1, xPage: Int 1} = GetTodos
POST {title: Text} /todos headers {authorization: Text, xIdempotencyKey: Text} -> {id: Int 1} = CreateTodo
GET /health -> {status: Text} = HealthCheck
Request headers become constructor fields, just like body/query/path params. The handler destructures them:
api = serve Api where
GetTodos = \{authorization} ->
with {todos allTodos}
(do
yield Ok {value {body todos headers {xTotalCount length todos xPage 1}}})
CreateTodo = \{title, authorization, xIdempotencyKey} ->
with {id addTodo title}
(do
yield Ok {value {body {id id} headers {}}})
HealthCheck = \{} -> yield Ok {value {status "ok"}}
When response headers are declared, the success branch wraps a {body: ..., headers: ...} record inside Ok {value: ...}. Without response headers, Ok carries the body directly. Error responses (Err {error: {status, message}}) never include custom headers — only the status code and JSON error body.
Optional headers use Maybe:
route Api where
GET /todos headers {authorization: Maybe Text} -> [Todo] = GetTodos
The server gets Nothing {} if the header is absent, Just {value: "..."} if present. In fetch, Nothing headers are skipped.
On the fetch side, request headers are sent automatically from constructor fields. When response headers are declared, the result wraps as {body: ResponseType, headers: {h: T}}:
result <- fetch "https://api.example.com" (GetTodos {authorization "Bearer tok"})
-- result : IO {network} (Result ... {body: [Todo], headers: {xTotalCount: Int 1, xPage: Int 1}})
Endpoints may declare a per-route token-bucket rate limit with the rateLimit clause, placed after the response type (and after response headers, if any) and before =. The clause takes a single expression of type RateLimit input a:
type RequestCtx = {
clientIp: Text,
receivedAt: Int Ms,
header: Text -> Maybe Text -- case-insensitive lookup
}
type RateLimit input a = {key input -> RequestCtx -> Maybe a -- Ord a; Nothing exempts this request
limit: {requests: Int 1, window: Int Ms}}
The key function receives the same input record the handler does (path params, query params, body fields, request headers — combined into one record), plus the runtime-supplied RequestCtx. Returning Nothing exempts the request from rate limiting; returning Just k puts the request into the bucket named by k. The key type a only has to satisfy Ord — the runtime serializes it (via show) for the SQLite bucket key, so any Ord value works (text, int, tuples, records, ADTs).
byClientIp = \input ctx -> Just {value ctx.clientIp}
byOwner = \{owner} ctx -> Just {value owner} -- key by path/query/body field
byApiKey = \input ctx -> case ctx.header "Authorization" of
Just {value: k} -> Just {value: k}
Nothing {} -> Just {value ctx.clientIp} -- fall back to IP
route Api where
GET /hello -> {message: Text}
rateLimit {key byClientIp limit {requests 100 window 60000 Ms}}
= Hello
GET /user/{owner: Text} -> {message: Text}
rateLimit {key byOwner limit {requests 10 window 60000 Ms}}
= User
POST {body: Text} /upload -> {ok: Bool}
rateLimit {key byApiKey limit {requests 10 window 60000 Ms}}
= Upload
GET /open -> {message: Text} = Open -- no clause = unlimited
The clause accepts any expression of type RateLimit input a, so common keying strategies and limits can be extracted into top-level bindings and reused:
serverLimit = {key \input ctx -> Just {value ctx.clientIp} limit {requests 1000 window 60000 Ms}}
route Api where
POST {events: [Event]} /federation/gossip -> {} rateLimit serverLimit = RecvGossip
Algorithm. A token bucket per (route, key) pair, refilled lazily on access at rate limit.requests / limit.window. A request that finds at least one token consumes one and is dispatched normally; otherwise the runtime responds 429 Too Many Requests with body {"error":"Rate limit exceeded"} and a Retry-After: <seconds> header. The handler is not invoked.
Storage. Buckets persist in a SQLite table _knot_rate_limits created lazily on first use:
CREATE TABLE _knot_rate_limits (
route TEXT NOT NULL, -- endpoint constructor name
key TEXT NOT NULL, -- show(keyFn(ctx))
tokens REAL NOT NULL,
last_refill INTEGER NOT NULL,
PRIMARY KEY (route, key)
) WITHOUT ROWID;The check runs in a BEGIN IMMEDIATE transaction so concurrent requests for the same client serialize correctly; different keys do not contend.
Effects. Rate limiting reads and writes a hidden table; these effects are internal and not surfaced in user-visible effect rows.
Factor out common path prefixes with nesting:
route Api where
/todos
GET /{user: Text} = GetTodos
POST {title: Text, owner: Text} / = AddTodo
PUT {owner: Text, person: Text} /{title: Text}/assign = AssignTodo
/admin
GET /stats = Stats
POST /reset = Reset
Prefixes nest arbitrarily:
route Api where
/api/v1
/users
GET / = ListUsers
GET /{id: Int 1} = GetUser
POST {name: Text, email: Text} / = CreateUser
/teams
GET / = ListTeams
GET /{id: Int 1}/members = GetMembers
Routes compose — combine multiple route types:
route TodoApi where
/todos
GET /{user: Text} = ListTodos
POST {title: Text, owner: Text} / = CreateTodo
route AdminApi where
/admin
GET /stats = Stats
POST /reset = Reset
route Api = TodoApi | AdminApi
DB writes within handlers must use atomic. IO happens outside atomic:
api = serve Api where
CreateOrder = \{item, qty} -> do
orderId <- atomic do
orders <- *orders
*orders = union orders [{item item qty qty}]
newOrders <- *orders
yield (count newOrders)
println ("New order #" ++ show orderId)
yield Ok {value {orderId orderId}}
For sub-transaction boundaries:
batchTransfer = \transfers ->
map (\t -> atomic (transfer t.from t.to t.amount)) transfers
All mutation is done through the *rel = expr write, which makes a persistent relation equal to expr (there is no set keyword — the bare assignment is the write). The compiler recognizes common shapes (union *rel [...] → INSERT, conditional map → UPDATE, filter → DELETE) and emits minimal SQL; otherwise it rewrites the whole relation. replace *rel = expr forces a full overwrite. Since relation references return IO, you bind to get the current value first:
-- Insert: union with a singleton
addPerson = do
people <- *people
*people = union people [{name "Alice" age 30}]
-- Update: map with a conditional
birthday = \name -> do
people <- *people
*people = do
p <- people
yield (case p.name == "Alice" of Bool.True {} -> {p | age p.age + 1}; Bool.False {} -> p)
-- Delete: filter to keep the rest
removePerson = \name -> do
people <- *people
*people = do
p <- people
where p.name != name
yield p
Relations are sets. Two rows are the same row iff all their fields are equal. Setting a relation to a value that includes a duplicate is a no-op for that row.
-- Adding an already-existing row changes nothing
*people = union *people [{name "Alice" age 30}]
*people = union *people [{name "Alice" age 30}] -- no change
No surrogate IDs, no key declarations. Data identifies itself.
Automatic. The runtime observes query patterns and indexes accordingly. No CREATE INDEX, no key declarations.
ADT tables get an index on the discriminator (_tag) at table creation time. Columns referenced in DELETE WHERE, UPDATE WHERE, and READ WHERE clauses are auto-indexed on first use. Columns inside the WHERE and ORDER BY clauses of pushed-down SELECT and aggregate queries — including filtered counts, sortBy, and multi-table join keys (where e.dept == d.name indexes both join columns) — are auto-indexed as well. The compiler emits CREATE INDEX IF NOT EXISTS and per-session bookkeeping deduplicates redundant DDL.
For UUIDv7 primary keys, time-ordered values mean inserts append to the right edge of the index — no random hot-page churn.
A *-prefixed relation with a body is a view — a bidirectional query over source relations. Reads compute the query; writes propagate back to the underlying sources.
&seniorStaff = do -- read-only (& prefix)
employees <- *employees
yield (filter (\e -> e.salary > 100000) employees)
*openTodos = do -- settable (* prefix)
t <- *todos
yield {title t.title owner t.owner priority t.priority status Open {}}
The compiler tracks each column in a view's yield:
| Kind | Syntax in yield |
On read | On write | In view type |
|---|---|---|---|---|
| source | t.column |
passthrough | passthrough | yes |
| constant | literal or constructor | filter | auto-fill | no |
| computed | expr |
computed | error | yes (read-only) |
For *openTodos above:
| Column | Kind | Read | Write |
|---|---|---|---|
title |
source (t.title) |
passthrough | passthrough |
owner |
source (t.owner) |
passthrough | passthrough |
priority |
source (t.priority) |
passthrough | passthrough |
status |
constant (Open {}) |
filter | auto-fill |
The constant column is hidden from the type — its value is fixed by definition:
*openTodos : [{title: Text, owner: Text, priority: Priority}]
Writing through a view auto-fills constants and propagates source columns:
-- Insert through view — status auto-filled as Open {}
addOpenTodo = do
openTodos <- *openTodos
*openTodos = union openTodos [{title "New task" owner "Alice" priority High {}}]
-- Compiler rewrites →
-- *todos = union *todos [{title "New task" owner "Alice" priority High {} status Open {}}]
-- Delete through view — only affects rows matching the constant
removeAliceTodos = do
openTodos <- *openTodos
*openTodos = do
t <- openTodos
where t.owner != "Alice"
yield t
-- Only removes Alice's Open todos; resolved/in-progress ones are untouched
Multiple constants create narrow slices:
*criticalOpen = do
t <- *todos
yield {title t.title owner t.owner status Open {} priority Critical {}}
-- Type: [{title: Text, owner: Text}]
-- Reads: only critical open todos
-- Writes: auto-fills status=Open, priority=Critical
Datalog-style transitive closure:
*manages : [{manager: Text, report: Text}]
&reportsTo : [{ancestor: Text, descendant: Text}] = do
manages <- *manages
reportsTo <- &reportsTo
yield (union
(do m <- manages
yield {m.manager, m.report})
(do r <- reportsTo
m <- manages
where r.descendant == m.manager
yield {r.ancestor, m.report}))
The compiler checks stratification.
The compiler maintains a schema lockfile (schema.lock) that records the persisted schema. The lockfile uses the same syntax as source code — it's valid Knot containing only type declarations, data definitions, and relation signatures.
-- schema.lock (auto-generated, do not edit)
-- Commit to source control.
data Priority = Low {} | Medium {} | High {} | Critical {}
data Status
= Open {}
| InProgress {assignee: Text}
| Resolved {resolution: Text}
*people : [{name: Text, age: Int 1, email: Text}]
*todos : [{title: Text, owner: Text, priority: Priority, status: Status}]
migrate *people
from {name: Text, age: Int 1}
to {name: Text, age: Int 1, email: Text}
using (\old -> {old | email old.name ++ "@unknown.com"})
Since it's valid Knot, it can be parsed by the same compiler frontend — no separate schema format. Migrations are recorded in the lockfile so the compiler can detect if a migration is accidentally removed from source.
On each compile, the compiler diffs the source types against schema.lock:
| Change | Compiler action |
|---|---|
Add Maybe field to record |
Auto-update lockfile |
| Add variant to ADT | Auto-update lockfile |
| Add new relation | Auto-add to lockfile |
Add new migrate block |
Auto-add to lockfile |
| Remove field or variant | Error: require migrate |
| Add non-Maybe field | Error: require migrate |
| Change field type | Error: require migrate |
Remove a migrate block |
Error: migration exists in lockfile |
| Remove relation | Warning (data will be orphaned) |
Breaking changes require a migrate block:
migrate *people
from {name: Text, age: Int 1}
to {name: Text, age: Int 1, email: Text}
using (\old -> {old | email old.name ++ "@unknown.com"})
ADT migrations use pattern matching:
migrate *todos
from {title: Text, owner: Text, priority: Priority, status: <Open {} | InProgress {assignee: Text} | Resolved {resolution: Text}>}
to {title: Text, owner: Text, priority: Priority, status: Status}
using (\old -> {old | status case old.status of
InProgress {assignee} -> Resolved {resolution "closed by " ++ assignee}
other -> other})
After a successful compile, schema.lock is updated.
The runtime stores the compiled schema version in the database. On startup it compares against the stored version and applies any pending migrations in order. Already-applied migrations are skipped.
migrate blocks accumulate in source code. The lockfile tracks all migrations — if a migration present in the lockfile is missing from source, the compiler rejects the build. This prevents accidental deletion. Old migrations can be pruned only by explicitly removing them from both source and lockfile.
| Type | Description |
|---|---|
Int 1 |
64-bit signed integer (i64); arithmetic is checked and panics on overflow |
Float 1 |
64-bit float |
Int u |
Integer tagged with a compile-time unit (Int Usd) |
Float u |
Float tagged with a compile-time unit (Float M, Float (M/S^2)) |
Text |
Unicode string |
Bool |
Bool.True {} / Bool.False {} |
Bytes |
Opaque byte string |
Uuid |
RFC 9562 UUIDv7 identifier — generated by randomUuid, stored as TEXT in SQLite |
Maybe a |
Nothing {} / Just {value: a} |
Result e a |
Err {error: e} / Ok {value: a} |
Uuid is a primitive (not an ADT) so it can be the column type of a source relation without any wrapper constructor. UUIDv7 values are time-ordered, which makes them well-suited as primary keys — inserts append to the right edge of any index built on the column.
Functions can be generic over records and relations with specific fields:
getName : {name: Text | r} -> Text
getName = \r -> r.name
Functions can be generic over any ADT that has a particular variant:
countOpen = \rel ->
rel |> filter (\r -> case r.status of Open {} -> True {}; _ -> False {}) |> count
-- Inferred: [{status <Open {} | r> | s}] -> Int 1
-- Works on tickets, issues, orders — anything with an Open status variant
Compile-time units on Int and Float. Units are fully erased at runtime — no performance cost, no runtime representation. Every Int and Float type must carry a unit — there is no bare Int/Float. A dimensionless numeric is written explicitly as Int 1 / Float 1.
Units are not declared. Any name used in a unit position is a unit — the compiler figures out that something is a unit from how it's used, and since a unit has no body (only a name), there is nothing to declare. Compound units are written inline as expressions:
height : Float M
force : Float (Kg * M / S^2)
frequency : Float (1 / S)
Postfix unit argument on numeric types only. Concrete units are uppercase; lowercase names are unit variables (see Unit Polymorphism).
height : Float M
mass : Float Kg
speed : Float (M / S)
force : Float (Kg * M / S^2)
acceleration : Float (M / S^2)
cents : Int Usd
Literals are unit-polymorphic and pick up their unit from an annotation:
distance = (42.0 : Float M)
duration = (3.5 : Float S)
price = (999 : Int Usd)
pi = 3.14159 -- dimensionless (Float 1)
+/- require matching units. *// compose units. The compiler normalizes unit expressions algebraically (M * S / S → M, M / M → 1).
-- Same-unit addition/subtraction
(10.0 : Float M) + (5.0 : Float M) -- Float M
(10.0 : Float M) + (5.0 : Float S) -- type error
-- Unit composition
(10.0 : Float M) * (5.0 : Float M) -- Float (M^2)
(100.0 : Float M) / (10.0 : Float S) -- Float (M/S)
(2.0 : Float Kg) * (9.8 : Float (M / S^2)) -- Float (Kg * M / S^2)
-- Dimensionless scalars
2.0 * (5.0 : Float M) -- Float M
(5.0 : Float M) / 2.0 -- Float M
-- Negation preserves units
-((5.0 : Float M)) -- Float M
Arbitrary integer powers arise naturally from multiplication: M * M = M^2, S * S * S = S^3. Powers can also be written directly in type annotations: Float (M^2), Float (S^-1).
Concrete units are uppercase; lowercase names inside <...> are unit variables — no extra syntax needed:
double : Float u -> Float u
double = \x -> x + x
computeSpeed : Float d -> Float t -> Float (d / t)
computeSpeed = \distance time -> distance / time
Unit variables are inferred like type variables:
double = \x -> x + x
-- inferred: Float u -> Float u (or Int u -> Int u via Num)
stripUnit / withUnit (Int 1) and stripFloatUnit / withFloatUnit (Float 1) are identity functions that exist only for the type checker. Use them to drop a unit tag and re-attach a different one. The result of withUnit/withFloatUnit carries a free unit variable, so the caller pins the target unit via the surrounding type context (e.g. the function's return signature) or an explicit annotation:
stripUnit : Int u -> Int 1 -- drop unit from Int 1
withUnit : Int 1 -> Int u -- attach unit to Int 1
stripFloatUnit : Float u -> Float 1
withFloatUnit : Float 1 -> Float u
toS : Int Ms -> Int S
toS = \ms -> withUnit (stripUnit ms / 1000)
toMiles : Float Km -> Float Mi
toMiles = \d -> withFloatUnit (stripFloatUnit d * 0.621371)
The generalized top-level pair strip : a u -> a 1 and dress : a 1 -> a u performs the same rebranding across both numeric types with one call. The u is a unit variable of kind Unit, so in practice a is a unit-carrying numeric (Int or Float); these are registered directly in the compiler because the surface syntax cannot write a 1 (1 is not a type). Both are identity at runtime:
toS : Int Ms -> Int S
toS = \ms -> dress (strip ms / 1000)
Every numeric type carries a unit — a bare Int or Float is a compile error; you must write a unit. Use Int 1 / Float 1 for the dimensionless case (e.g. counts, indices). A value of a concrete unit does not implicitly convert to the dimensionless form — x : Float 1; x = (1.5 : Float M) is a type error (expected Float 1, found Float M). Numeric literals are unit-polymorphic: 1.5 has type Float u for a fresh unit variable, so it flows into whatever unit the context demands ((1.5 : Float M), sum over [Float M], or a Float 1 field) and defaults to dimensionless when unconstrained. These helpers are only needed when you must rebrand a value with a different concrete unit.
For explicit unit ascription you can put a type annotation on any expression, either inside parens or as a bare postfix:
count = 0 : Int Usd -- bare postfix annotation
total = (acc + delta) : Float M -- parenthesized form
sum, avg, minOn, maxOn, and binary min/max preserve units:
sum : [a] -> a -- direct; use `map` to project first
avg : (a -> Float u) -> [a] -> Float u
minOn : (a -> b) -> [a] -> b -- units flow through via b
maxOn : (a -> b) -> [a] -> b
min : a -> a -> a -- binary
max : a -> a -> a -- binary
sum takes the relation directly — there is no projection argument. To sum a
field of a record relation, project first with map:
sum (map (\r -> r.price) rows)
rows |> map (\r -> r.price) |> sum
show on a value with a concrete unit appends the unit string. The compiler knows the unit statically and emits the string as a constant:
show (9.8 : Float (M / S^2)) -- "9.8 M/S^2"
show (42.0 : Float M) -- "42.0 M"
show 3.14 -- "3.14"
Int 1 units are appended the same way, including the built-in Ms that clock operations carry — now : IO {clock} Int Ms, so show on a timestamp reads "1783814121719 Ms". Use stripUnit to print the bare number.
When the unit is polymorphic (inside a unit-generic function), show prints just the number: the function body is compiled once, for every unit its caller may instantiate.
The compiler uses a canonical form for unit strings: alphabetical numerator, alphabetical denominator, powers collapsed. This same canonical form determines type equality (m * s = s * m).
Units are phantom — SQLite stores raw numbers. Schema descriptors ignore units.
type Measurement = {distance: Float M, time: Float S}
*measurements : [Measurement]
-- Units flow through queries
&speeds = do
measurements <- *measurements
with {result do
m <- measurements
yield {speed m.distance / m.time}} -- Float (M/S)
(do
yield result)
Units live outside the value level as a compile-time overlay. Arithmetic is
intrinsic (checked/panicking on Int 1); the compiler applies unit algebra
rules as an additional layer on top. + on Float M evaluates directly while
the compiler separately verifies that both operands share the unit M and
propagates M to the result.
A refined type is a base type restricted by a predicate. The predicate is an ordinary Knot function (T -> Bool) — any pure function works, no restrictions.
-- Standalone refined type
type Nat = Int where \x -> x >= 0
type Percentage = Float where \x -> x >= 0.0 && x <= 100.0
type NonEmptyText = Text where \s -> length s > 0
type Email = Text where \s -> contains "@" s && length s >= 3
-- Stacking: inner refinement inherited, predicates conjoin
type Age = Nat where \x -> x <= 150
-- equivalent to: Int where \x -> x >= 0 && x <= 150
Refinements attach to individual record fields:
type Person = {
name: Text where \s -> length s > 0,
age: Int where \x -> x >= 0 && x <= 150,
email: Text
}
A where after the closing } constrains the whole record. Multiple where clauses are conjunctive:
type DateRange = {
start: Int 1,
end: Int 1
} where \r -> r.start <= r.end
type Discount = {
percent: Float where \x -> x >= 0.0 && x <= 1.0,
minQty: Int where \x -> x >= 0
} where \d -> d.percent < 0.5 || d.minQty >= 10
Refinements can appear on constructor fields:
data Shape
= Circle {radius: Float where \r -> r > 0.0}
| Rect {width: Float where \w -> w > 0.0, height: Float where \h -> h > 0.0}
Source declarations carry refinement predicates for each field; cross-relation
constraints (referential integrity, uniqueness) are written as top-level
subset constraints with <=:
*people : [{
name: Text where \s -> length s > 0,
age: Int where \x -> x >= 0,
email: Text where \s -> contains "@" s
}]
*orders : [{customer: Text, amount: Int where \x -> x > 0}]
-- Referential integrity: every orders.customer must appear in people.email
*orders.customer <= *people.email
-- Uniqueness: people.email values must not duplicate (relation-on-itself form)
*people <= *people.email
Two subset-constraint shapes:
| Form | Meaning |
|---|---|
*sub.field <= *sup.field |
Every value of sub.field must appear in sup.field (foreign key) |
*rel <= *rel.field |
Field values are unique within rel |
Field-level and cross-field refinements are enforced row-by-row before each relation write commits. Subset constraints are enforced by runtime triggers maintained on the underlying SQLite tables. Either failure mode panics with a refinement error or a constraint-violation message.
Refined(T, p) <: T. A refined type is a subtype of its base.
Nat <: Int 1
Age <: Nat <: Int 1
Upcasting (refined → base) is implicit, no check:
f : Int 1 -> Int 1
f (x : Nat) -- fine: Nat <: Int 1
Downcasting (base → refined) requires refine. refine expr has type Result RefinementError T where T is the target refined type, inferred from context. If context doesn't determine T, it's a type error.
f : Nat -> Text
-- In a Result do-block (bind unwraps the Result):
do
n <- refine someInt -- n : Nat (inferred from f's parameter type)
yield (f n)
-- : Result RefinementError Text
-- With case:
case refine someInt of
Ok {value: n} -> f n -- Nat inferred from f's parameter
Err {error} -> "invalid"
Two refined types with the same base but different predicates are unrelated — no subtyping between Nat and Percentage. Stacked refinements are the exception: Age <: Nat because Age was defined as Nat where ....
Arithmetic on refined types returns the base type:
x : Nat = ...
y : Nat = ...
x + y -- Int 1, not Nat (no attempt to prove result satisfies predicate)
refine expr checks the refinement predicate at runtime. It returns Result RefinementError T where T is the target refined type, inferred from context:
-- Target type Nat inferred from binding annotation
let r : Result RefinementError Nat = refine 42
-- r = Ok {value 42}
let r : Result RefinementError Nat = refine (-1)
-- r = Err {error {typeName: "Nat", violations: [{field Nothing {} message "expected x >= 0 got -1"}]}}
The error type:
type RefinementError = {
typeName: Text,
violations: [{
field: Maybe Text, -- Nothing for whole-value, Just "age" for field-level
message: Text
}]
}
refine checks all predicates and reports all violations, not just the first.
In do-blocks over Result, <- unwraps on Ok and short-circuits on Err:
validateOrder : {customer: Text, amount: Int 1} -> Result RefinementError {customer: NonEmptyText, amount: Nat}
validateOrder = \raw -> do
customer <- refine raw.customer -- NonEmptyText inferred from return type
amount <- refine raw.amount -- Nat inferred from return type
yield {customer, amount}
Checks happen at two boundaries:
| Boundary | Mechanism | On failure |
|---|---|---|
refine expr |
Explicit coercion | Returns Result RefinementError T |
*rel = value |
Implicit per-row check | Panics with RefinementError |
A relation write panics because constraint violations at the persistence boundary are programming errors — input should be validated with refine first, so that error handling happens explicitly before the write rather than at the write itself.
Predicates in type-level refinements must be pure — no IO, no relation references. They are ordinary Knot functions with no restrictions on what pure operations they use (recursion, pattern matching, higher-order functions, etc.).
Predicates in relation where clauses follow the same rule — they are pure functions over individual rows. Relational constraints (subset and uniqueness via <=) are separate top-level declarations, not predicates.
Units and refinements are orthogonal — units are compile-time phantom, refinements are runtime-checked:
type PositiveDistance = Float M where \x -> x > 0.0
type Speed = Float (M/S) where \x -> x >= 0.0
Refinements are part of the schema, tracked in the lockfile:
| Change | Compiler action |
|---|---|
| Add refinement to existing field | Warning: tightening — existing data may violate |
| Remove refinement | Auto-update lockfile (loosening) |
| Change predicate | Error: require migrate |
Adding a refinement to an existing relation requires a validation migration to ensure existing data satisfies the new predicate.
type Nat = Int where \x -> x >= 0
type NonEmptyText = Text where \s -> length s > 0
type Email = Text where \s -> contains "@" s && length s >= 3
type Person = {
name: NonEmptyText,
age: Nat where \x -> x <= 150,
email: Email
} where \p -> p.age >= 13
*people : [Person]
*orders : [{
customer: Email,
amount: Nat where \x -> x <= 1000000,
items: [{name: NonEmptyText, qty: Nat where \q -> q > 0}]
}]
-- Referential integrity is a separate top-level subset constraint:
*orders.customer <= *people.email
route Api where
POST {name: Text, age: Int 1, email: Text} /users -> {ok: Bool, error: Maybe Text} = CreateUser
api = serve Api where
CreateUser = \{name, age, email} ->
case refine {name, age, email} of -- Person inferred from *people
Ok {value person} -> do
atomic do
people <- *people
*people = union people [person]
yield Ok {value {ok (Bool.True {}) error Nothing {}}}
Err {error} ->
with {msg fold (\acc v -> acc ++ v.message ++ "; ") "" error.violations}
(do
yield Ok {value {ok (Bool.False {}) error Just {value msg}}})
Knot has no trait system you can use. The parser rejects trait
declarations, impl blocks, deriving, associated types, and Num a =>-style
bounds — writing any of them is a syntax error. (Earlier drafts of this design
had a Rust-style trait system; it was removed. The mechanism that replaced it
for ad-hoc polymorphism is Implicit Dictionaries, below.)
What remains:
- Operators (
==<+++%…) are intrinsic — the compiler evaluates them directly on the supported types (Int 1,Float 1,Text,Boolfor equality; numerics for arithmetic/ordering;Textand[a]for++). No type class, no instance, no dictionary. - Higher-order functions (
base.map,base.fold,base.traverse,base.bind,base.display,base.show, …) are ordinary polymorphic functions over concrete types ([a],Maybe a,Result e a,IO, and anyafordisplay/show, which work structurally via the runtime). They have no bounds. - Equality, ordering, and
show/displaywork structurally on any value — there is nothing to derive.
For polymorphism the user controls, see the next section: implicit record dictionaries, where the operations travel as fields of an ordinary record resolved from lexical scope.
Traits are gone; the replacement is record dictionaries — ordinary records
whose fields carry the operations, resolved from lexical scope. The lightest
form is the implicit-field reference ^field inside a function body, which
projects field off whichever in-scope record supplies it. The
(^field : T) => signature constraint lifts that to a function type: it
declares that the function needs a dictionary record providing field at type
T, without naming the record.
clamp : (^compare : a -> a -> Int 1) => a -> a -> a -> a
clamp = \lo hi x -> case ((^compare) x lo) < 0 of Bool.True {} -> lo; Bool.False {} -> case ((^compare) x hi) > 0 of Bool.True {} -> hi; Bool.False {} -> x
clamp is elaborated to take a hidden leading dictionary parameter (a record
{compare : a -> a -> Int 1}); each (^compare) in the body reads the
compare field of that record. At a full-arity callsite the compiler
searches the lexical scope for a record supplying compare at the required
type and splices it in as the leading argument:
intOrd = {compare (\a b -> case a > b of Bool.True {} -> 1; Bool.False {} -> case a < b of Bool.True {} -> (0 - 1); Bool.False {} -> 0)}
textOrd = {compare (\a b -> case a > b of Bool.True {} -> 1; Bool.False {} -> case a < b of Bool.True {} -> (0 - 1); Bool.False {} -> 0)}
intOrdDesc = {compare (\a b -> case a < b of Bool.True {} -> 1; Bool.False {} -> case a > b of Bool.True {} -> (0 - 1); Bool.False {} -> 0)}
clamp 0 10 42 -- resolves to intOrd → 10
clamp "a" "m" "z" -- resolves to textOrd → "m"
with intOrdDesc (clamp 0 10 42) -- `with` shadows outer → 0
Resolution is per-callsite (the dictionary is chosen by the instantiation —
a becomes Int vs Text) and lexical (the innermost scope wins; a with
frame binding compare shadows outer records). If no in-scope record supplies
the field, the callsite is a compile error
(no in-scope record supplies an implicit dictionary field 'compare').
Current limitation: only full-arity callsites resolve a dictionary. Passing
a constrained function partially applied (e.g. map (clamp lo hi) xs) does not
yet thread the dictionary — it must be applied to all its explicit arguments at
once.
Full Hindley-Milner style inference extended with row polymorphism (record fields, effect rows, and unit variables) and implicit-dictionary constraints. Type signatures are always optional — the compiler infers everything from usage.
data Priority = Low {} | Medium {} | High {} | Critical {}
data Status
= Open {}
| InProgress {assignee: Text}
| Resolved {resolution: Text}
*todos : [{title: Text, owner: Text, priority: Priority, status: Status}]
route Api where
GET /todos/{user: Text} -> [{title: Text, priority: Priority}] = GetTodos
POST {title: Text, owner: Text, priority: Priority}
/todos -> {ok: Bool} = AddTodo
PUT {owner: Text, person: Text} /todos/{title: Text}/assign -> {ok: Bool} = AssignTodo
PUT {owner: Text, msg: Text} /todos/{title: Text}/resolve -> {ok: Bool} = ResolveTodo
GET /workload -> [{owner: Text, count: Int 1}] = GetWorkload
formatTitle = \title -> toUpper (take 1 title) ++ drop 1 title
pendingFor = \user -> do
todos <- *todos
with {result do
t <- todos
where t.owner == user
Open {} <- t.status
yield {t.title, t.priority}}
(do
yield result)
add = \title owner priority -> do
todos <- *todos
*todos = union todos [{title formatTitle title owner owner priority priority status Open {}}]
assign = \title owner person -> do
todos <- *todos
*todos = do
t <- todos
yield (if t.title == title && t.owner == owner
then {t | status (InProgress {assignee person})}
else t)
resolve = \title owner msg -> do
todos <- *todos
*todos = do
t <- todos
yield (if t.title == title && t.owner == owner
then {t | status (Resolved {resolution msg})}
else t)
&workload = do
todos <- *todos
with {result do
t <- todos
Open {} <- t.status
groupBy {t.owner}
yield {owner t.owner count count t}}
(do
yield result)
api = serve Api where
GetTodos = \{user} -> do
todos <- pendingFor user
yield Ok {value todos}
AddTodo = \{title, owner, priority} -> do
atomic (add title owner priority)
yield Ok {value {ok True {}}}
AssignTodo = \{title, owner, person} -> do
atomic (assign title owner person)
yield Ok {value {ok True {}}}
ResolveTodo = \{title, owner, msg} -> do
atomic (resolve title owner msg)
yield Ok {value {ok True {}}}
GetWorkload = \{} -> do
w <- &workload
yield Ok {value: w}
main = listen 8080 api