Type System¶
Always-Available Types¶
Certain types are built into the language and available without explicit import:
| Category | Types | Description |
|---|---|---|
| Primitives | Integer, Decimal, Float, Boolean, String, Character, Byte, Unit | Core types with optional modifiers |
| Containers | List<Value>, Option<Value>, Result<Value, Error>, Table<Value> | Generic collection types |
| Arrays | Array<T> | Fixed-size contiguous array; requires import from Array module |
| Structural | Struct | Row-polymorphic type accepting any record; constrained with with clauses |
| Special | Value, Error, Source | Used by stdlib for dynamic values, errors, and sources |
These are implicitly available in every module. No import statement needed.
Row Polymorphism (Struct)¶
Struct is a builtin type that accepts any record. Combined with with clauses, it enables row-polymorphic functions — functions that work on any record with the required fields, regardless of the record's concrete name.
This function accepts any record type that has a name field of type String:
type User is
name String
age Integer
type Company is
name String
founded Integer
// Both calls are valid — User and Company both have `name String`
greeting(User("Alice", 30))
greeting(Company("Acme", 1990))
with Constraints¶
Each with clause declares one required field on a Struct parameter:
transforms display(obj Struct) String
with obj.name String
with obj.age Integer
from
obj.name + " (" + Text.from_integer(obj.age) + ")"
Rules:
withmust reference a parameter typedStruct(E431)- The parameter must exist (E430)
- No duplicate fields on the same parameter (E432)
- Field access on a
Structparameter is only allowed for fields declared viawith(E433)
Monomorphisation¶
At the C level, Struct is erased. Each call site with a concrete record type produces a specialised copy of the function. Name mangling includes the concrete type, so greeting(User(...)) and greeting(Company(...)) compile to separate C functions.
Type Modifiers¶
Type modifiers describe how a value is stored and represented — size, signedness, encoding, precision. They use bracket syntax after the type name: Type:[Modifier ...]. Value constraints (what values are valid) belong in refinement types with where, not modifiers.
// Modifier: how it's stored (16-bit, unsigned)
raw_port as Integer:[16 Unsigned] = 8080
// Refinement: what values are valid
type Port is Integer where 1..65535
// Combined: storage + value constraint
type Port is Integer:[16 Unsigned] where 1..65535
Modifier Axes¶
Each modifier occupies a distinct axis (size, signedness, encoding, etc.). Rules:
- Order-independent —
Integer:[Signed 64]andInteger:[64 Signed]are identical. The compiler normalizes internally. - One per axis — two modifiers on the same axis is a compile error:
Integer:[32 64]→ conflicting size modifiers. - Positional when the kind is unambiguous. Named (
Key:Value) when a bare value could be confused:Decimal:[128 Scale:2]. - Bare type uses defaults —
IntegermeansInteger:[64 Signed],StringmeansString:[UTF8],DecimalmeansDecimal:[64].
Primitive Type Modifiers¶
| Type | Modifier Axes | Default | Examples |
|---|---|---|---|
Integer | size (8/16/32/64/128), signedness (Signed/Unsigned) | Integer:[64 Signed] | Integer:[32 Unsigned], Integer:[8] |
Decimal | precision (32/64/128), scale (Scale:N) | Decimal:[64] | Decimal:[128 Scale:2] |
Float | precision (32/64) | Float:[64] | Float:[32] |
String | encoding (UTF8/ASCII/UTF16), max length | String:[UTF8] | String:[UTF8 15], String:[ASCII 255] |
Character | encoding (UTF8/UTF16/ASCII) | Character:[UTF8] | Character:[ASCII] |
Boolean | — | — | — |
Byte | — | — | — |
Float is opt-in — Decimal is the default for fractional numbers. Float:[64] uses IEEE 754 hardware floats for performance-critical domains (scientific computing, graphics, signal processing) where speed matters more than exact precision. Mixing Float and Decimal requires explicit conversion.
Decimal precision mappings: Decimal:[32] compiles to C float, Decimal:[64] (default) compiles to double, and Decimal:[128] compiles to long double. Scale:N constrains decimal places: literals exceeding the scale are rejected at compile time (E407), mismatched scales produce E408, and runtime arithmetic results are rounded to the declared scale.
count as Integer = 42 // Integer:[64 Signed]
flags as Integer:[8 Unsigned] = 0xFF
price as Decimal:[128 Scale:2] = 19.99 // financial precision
gravity as Float = 9.8f // Float (IEEE 754, note 'f' suffix)
name as String = "Alice" // String:[UTF8]
code as String:[ASCII 4] = "US01" // ASCII, max 4 characters
letter as Character = 'A' // Character:[UTF8]
Storage Modifiers¶
Beyond representation, modifiers also express storage concerns like mutability and ownership. These use the same bracket syntax — storage is a property of the type, not a separate keyword.
Mutable — variables are immutable by default. Mutable enables reassignment:
On Array<T>, Mutable switches from copy-on-write to in-place mutation. This is the primary use case — allocating a fixed-size working buffer that is modified in a loop without heap allocation per update:
Sequence creates array
Sequence reads get
Sequence transforms set
sieve as Array<Boolean>:[Mutable] = array(1000001, false)
sieve = set(sieve, 0, true) // in-place, no copy
sieve = set(sieve, 1, true)
Own — linear ownership. The value is consumed on use. See Ownership Lite below.
inputs process(file File:[Own]) Data!
from
content as String = read(file)
close(file)
// file consumed here
Refinement Types¶
Types carry constraints, not just shapes. The compiler validates values against constraints via runtime checks inserted at assignment boundaries. Static rejection of provably-invalid literals at compile time is implemented — literal values that violate refinement constraints are caught at compile time (E355).
type Port is Integer:[16 Unsigned] where 1 .. 65535
type Email is String where r"^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$"
type NonEmpty<Value> is List<Value> where len > 0
transforms head(xs NonEmpty<Value>) Value // no Option needed, emptiness is impossible
The compiler rejects head([]) at the type level — [] is not a NonEmpty.
Lookup Types (Bidirectional Maps)¶
A [Lookup] type is a bidirectional map combining an algebraic type with its value representations in a single declaration — normally you'd need three separate declarations (the type, variant→value map, and value→variant map).
Single-Type Lookup¶
type TokenKind:[Lookup] is String where
Main | "main"
NotMain | "not_main"
TrueLit | "true"
| "false"
Access is bidirectional:
- Reverse lookup —
TokenKind:"main"→ returns theTokenKindvariant (Main) - Forward lookup —
TokenKind:Main→ returns theStringvalue ("main")
A variant can have multiple values (stacked entries like TrueLit), but reverse lookup on such variants is ambiguous and produces an error at compile time.
Lookup tables must be exhaustive — every variant needs at least one value, and all values must be unique across the table.
Multi-Type Lookup¶
A lookup can map to multiple primitive types simultaneously:
The lookup is contextual — the result type depends on the context it's used in:
Rules: - No overlapping types — each type can only appear once: String | Integer | String is invalid (unless using named columns, see below) - Native types only — only primitive types are supported (String, Integer, Decimal, Float, Boolean) - Compile-time only — this is not a runtime type; the lookup type is resolved at compile time based on usage context
Named Columns¶
When a lookup needs two or more columns of the same type, use named columns to disambiguate:
type Prediction:[Lookup] is probability:Float | String | confidence:Float where
Cat | 0.9 | "cat" | 0.95
Dog | 0.8 | "dog" | 0.85
Named and unnamed columns can be mixed. Access named columns with dot syntax:
Without named columns, duplicate types produce warning W350 and ambiguous access produces error E399.
For large lookup tables (more than 16 entries), reverse lookups automatically use binary search instead of linear scan for better performance.
Store-Backed Lookup (Runtime)¶
A [Lookup] type with runtime instead of where gets its data from a Store at runtime instead of compiled-in entries. The type definition declares the column schema — pipe-separated types — and the data is populated dynamically.
The variable is typed as the lookup type, but backed by a StoreTable at the C level:
Store outputs store inputs table validates store table
types Store StoreTable
db as Store = store("/tmp/my_store")!
colors as Color = table(db, "colors")!
Rows are constructed with the type name and added to the table:
Runtime lookup uses variable:"key" syntax (lowercase variable, not uppercase type name):
The column indices are resolved at compile time from the schema. The lookup key column is determined by the operand type (String → column 0), and the value column is determined by the expected return type (Integer → column 1).
Rules: - Schema from type — the pipe-separated types define column count and types - No where entries — data is runtime-only, not compiled in - Variable lookup — variable:"key" instead of TypeName:"key" - Type compatibility — a store-backed lookup type is interchangeable with StoreTable in function arguments
Option and Result¶
Prove has no null. Instead, two algebraic types handle absence and failure:
Option<Value>— a value that might not exist:Some(value)orNoneResult<Value, Error>— a computation that might fail:Ok(value)orErr(error)
Both are always-available types (no import needed). Use pattern matching to extract values:
match Table.get("key", config)
Some(value) => use(value)
None => default_value()
match Parse.json(raw)
Ok(data) => process(data)
Err(msg) => report(msg)
The Types stdlib module provides utilities like unwrap for common patterns.
Algebraic Types with Exhaustive Matching¶
Compiler errors if you forget a variant.
type Result<Value, Error> is Ok(Value) | Err(Error)
type Shape is Circle(radius Decimal) | Rect(w Decimal, h Decimal)
// compiler error if you forget a variant
transforms area(s Shape) Decimal
from
match s
Circle(r) => pi * r * r
Rect(w, h) => w * h
Pattern Matching¶
Prove has no if/else. All branching is done through match — and good Prove code rarely matches on booleans at all.
match route
Get("/health") => ok("healthy")
Get("/users") => users()!
_ => not_found()
match discount
FlatOff(off) => max(0, amount - off)
PercentOff(rate) => amount * (1 - rate)
MAX_CONNECTIONS as Integer = comptime
match cfg.target
"embedded" => 16
_ => 1024
This is not an omission. It is a deliberate design choice.
Why No if¶
1. Types replace booleans.
When you reach for if connected then send(data) else retry(), the real question is: what kind of connection state are you in? Model it as a type and the branching becomes meaningful:
type Connection is Active(socket Socket) | Disconnected(reason String)
match connection
Active(socket) => send(socket, data)
Disconnected(reason) => retry(reason)
Each arm names what it handles. No true/false to mentally decode. The compiler enforces that every variant is covered — add a Reconnecting state later and the compiler tells you everywhere you need to handle it.
2. if hides missing cases.
An if without else silently does nothing on the false branch. The programmer may have forgotten it. With types, there's no such escape — every variant must be handled.
3. One construct is simpler than two.
if/else adds no expressive power over match. It only adds surface area to the language, the parser, the type checker, the emitter, and every tool that processes Prove code. One construct means less to learn and fewer ways to express the same thing.
4. Contracts replace conditional logic.
Where other languages use if to decide whether to run code, Prove uses validation. Consider:
transforms calculate_total(items List<OrderItem>, discount Discount, tax TaxRule) Price
ensures result >= 0
requires len(items) > 0
from
sub as Price = subtotal(items)
discounted as Price = apply_discount(discount, sub)
apply_tax(tax, discounted)
There is no if discount > 0 then apply_discount(...). The requires clause on apply_discount ensures the compiler has already proven the discount is valid before the call happens. The "branching" lives in the type system and contracts, not in boolean conditions.
5. Boolean matching is a code smell.
match x > 0 / true => ... / false => ... is technically valid but signals that you should model your domain better. Instead of branching on amount > 0, define type Positive is Integer where > 0 and let the type system handle it. The branching disappears into the type — where the compiler can prove things about it.
The Rule¶
Branch on what something is, not on whether something is true. Types and contracts handle the rest — requires guards preconditions, ensures guarantees postconditions, and explain documents the reasoning. No boolean branch needed.
Error Propagation¶
! marks fallibility — on declarations it means "this function can fail", at call sites it propagates the error upward. Only IO verbs (inputs, outputs) and main can use !. Pure verbs cannot be failable (E361). There is one Error type — errors are program-ending, not flow control. ! errors propagate up the call chain until they reach main, which exits with an error message. There is no try/catch.
Pure functions that need to represent expected failure cases use Result<Value, Error> and handle them with match — these are values, not errors.
main()!
from
config as Config = load("app.yaml")!
db as Store = connect(config.db_url)!
serve(config.port, db)!
See Functions & Verbs — IO and Fallibility for how ! relates to verb families.
Effect Types¶
Effects are encoded in the verb, not in type annotations. The compiler tracks three effect families:
| Family | Verbs | Effect |
|---|---|---|
| Pure | transforms, validates, reads, creates, matches | No IO, no concurrency. Automatically memoizable and parallelizable |
| IO | inputs, outputs | Reads from or writes to the external world. ! marks additional fallibility |
| Async | detached, attached, listens | Concurrent execution via cooperative coroutines (prove_coro). detached and attached may call IO freely (own coroutine stacks); listens may not (cooperative yield cycle) |
inputs read_config(path Path) String! // IO inherent, ! = can fail
transforms parse(s String) Result<Config, Error> // pure — failure in return type
transforms rewrite(c Config) Config // pure, infallible, parallelizable
detached log(event Event) // async, fire-and-forget
from
console(event.message)
attached fetch(url String) String // async, caller awaits result
from
request(url)&
listens dispatcher(cmd Command) // async, cooperative loop
from
Exit => cmd
Process(data) => handle(data)&
The compiler enforces effect boundaries: - Pure verbs cannot call IO or async functions - listens cannot call blocking IO (inputs/outputs) — it runs cooperatively and blocking would stall the yield cycle - attached may call blocking IO — it has its own coroutine stack. IO-bearing attached must be called from listens or another attached body (E398) - detached may call IO freely — it runs independently and blocking only affects its own coroutine, not the caller - listens dispatches on the first parameter's algebraic type — the from block is an implicit match with a mandatory Exit arm - The & marker at a call site signals async dispatch, analogous to ! for error propagation
Function Types (Verb)¶
Verb<P1, P2, ..., R> describes a function that takes parameters of types P1, P2, ... and returns R. The last type argument is always the return type; all preceding arguments are parameter types. Verb<R> with a single argument describes a zero-parameter function returning R.
This allows stdlib functions to accept callbacks without hardcoded special-casing:
// A function that takes a Conflict and returns a Resolution
resolver as Verb<Conflict, Resolution>
// Used as a parameter type in function signatures
transforms merge(base StoreTable, local TableDiff, remote TableDiff,
resolver Verb<Conflict, Resolution>) MergeResult
// Called with a lambda
Store.merge(base, local, remote, |c| KeepRemote)
// Or with a named function reference
Store.merge(base, local, remote, my_resolver)
Convention: Verb mirrors the language's verb-based function declarations. The type resolves internally to a C function pointer with the correct parameter and return types.
Attached — Worker Reference¶
Attached is a reference to an attached verb function. It is used in List<Attached> as the worker parameter for listens event dispatchers. The type resolves to a coroutine function pointer (Prove_CoroFn) in the C runtime.
Ownership Lite (Linear Types with Compiler-Inferred Borrows)¶
Linear types for resources, but without Rust's lifetime annotation burden. The compiler infers borrows or asks you. Ownership is a type modifier, consistent with mutability and other storage concerns.
No Null¶
No null. Prove replaces null's two roles with distinct types:
Option<Value>— for values that might not exist (Some(value)orNone)Unit— for functions that return nothing (the "void" equivalent)
Both are enforced by the compiler. There is no null pointer, no nil, no sentinel value.
Keyword Reference¶
Every keyword in Prove has exactly one purpose. No keyword is overloaded across different contexts.
Intent Verbs¶
| Keyword | What it does |
|---|---|
transforms | Declares a pure function — no side effects. See Functions & Verbs |
validates | Declares a function that returns true or false. Return type is implicitly Boolean. See Functions & Verbs |
reads | Declares a pure function that extracts or queries data. See Functions & Verbs |
creates | Declares a pure function that constructs a new value. See Functions & Verbs |
inputs | Declares a function that reads from the outside world. See Functions & Verbs |
outputs | Declares a function that writes to the outside world. See Functions & Verbs |
streams | Declares a blocking IO loop over a source. See Async & Streams |
detached | Declares a fire-and-forget async function. See Functions & Verbs |
attached | Declares an awaited async function. See Functions & Verbs |
listens | Declares an event dispatcher. See Functions & Verbs |
matches | Declares a pure match dispatch on algebraic type. See Functions & Verbs |
Declarations & Types¶
| Keyword | What it does |
|---|---|
main | The program's entry point — can freely mix reading and writing |
from | Marks where the function body starts. See Functions |
where | Adds a value constraint to a type. See Refinement Types |
as | Declares a variable — port as Port = 8080 |
is | Defines a type — type Port is Integer |
type | Starts a type definition — type Port is Integer where 1..65535 |
match | Branches on a value. See Pattern Matching |
module | Starts a module block containing types, constants, and metadata |
narrative | Documents the module's purpose in the module block |
invariant_network | Declares a network of invariants in the module block |
Contracts & Verification¶
| Keyword | What it does |
|---|---|
ensures | States what a function guarantees about its result. See Contracts |
requires | States what must be true before calling a function. See Contracts |
explain | Documents from block steps using controlled natural language. See Contracts |
terminates | Required for recursive functions. See Contracts |
trusted | Marks a function as unverified. See Contracts |
believe | States an unverified assumption. See Contracts |
assume | States a runtime assumption. See Contracts |
near_miss | Defines approximate contracts for fuzzy matching. See Contracts |
Special Keywords¶
| Keyword | What it does |
|---|---|
valid | References a validates function as a predicate. Used in boolean contexts or as function references |
comptime | Marks code for compile-time evaluation. See Compiler |
event_type | Declares the algebraic type for a listens dispatcher. See Functions & Verbs |
foreign | Declares a C FFI block inside a module. See Syntax Reference |
todo | Marks incomplete implementation. Generates I601 diagnostic |
with | Declares required fields on a Struct parameter. See Row Polymorphism |