4. Built-in types
| Type | Purpose | |
|---|---|---|
bool | true / false | |
string | UTF-8 string | |
bytes | immutable contiguous byte buffer - binary I/O and codecs | |
() | unit - both the type and the value | |
List<T> | persistent ordered collection | |
Map<K, V> | persistent key-value | |
Set<T> | persistent set of Hash elements | |
Option<T> | `Some(T) \ | None` |
Result<T, E> | `Ok(T) \ | Err(E)` - success or typed failure (§7) |
File | open OS file - a runtime-managed native resource, not sendable (§15) | |
TcpStream | open TCP connection - a runtime-managed native resource, not sendable (§15) | |
TcpListener | bound TCP listening socket - a runtime-managed native resource, not sendable (§15) | |
Never | bottom type - the result of crash! (§6). Uninhabited; coerces to any type |
Never is the bottom type: no value ever has it. It is produced by the diverging crash! primitive (§6) and by the ??? typed hole (§6, Typed holes), and an expression of type Never is accepted wherever any type is expected - so crash!(...) or ??? fits any branch or match arm without inventing a placeholder value. You may write it in a signature (def boom!() -> Never [Crash]) to mark a function that never returns normally.
bytes is an immutable contiguous byte buffer - the raw counterpart to string, for binary I/O and codecs. Like string it is a value (its operations return new buffers; nothing mutates in place), and it carries no UTF-8 guarantee. There are no bytes literals: construct one by UTF-8-encoding a string (s.to_bytes()) or from a codec. Decode back with b.to_string() -> Result<string, Utf8Error> (the Err carries the first invalid byte offset). The buffer ops are byte-offset based: length, slice(start, stop), concat, get(i) -> Option<u8>, is_empty, and hash_u64. Equality is byte-wise. To assemble a bytes incrementally, reach for a BytesBuilder (below): appending to the immutable bytes is O(n²), a builder is O(n).
Resources. A native resource type is a handle to live native state whose lifetime the per-actor memory manager owns (released the moment the last handle drops - reclamation is reference counting, §12): File (an open file descriptor), TcpStream (an open TCP connection), TcpListener (a bound listening socket), BytesBuilder (a pure-memory growable byte buffer), BytesReader (a pure-memory read cursor over a bytes), and Module<T> (a handle to a dynamically loaded module, from module.load!, released by module.unload!). Unlike every other value, a resource is stateful, single-owner, and not copyable - it can be neither duplicated nor shared. Two rules follow:
- The runtime reclaims it - deterministically. Memory is reference-counted (per actor, no cycle collector - values are acyclic, §12), so the moment the last handle to a resource is dropped, the runtime releases the underlying state - closing a descriptor, or freeing a buffer - right there, not at some later collection. A handle-owning resource may also be closed eagerly with
close!; doing so is an optimisation, not a requirement, and closing twice is harmless. Operating on a closed resource returns an error, never undefined behaviour. - It is not copied - it is moved. A resource cannot be duplicated across an actor message boundary (the mailbox transfer is a deep copy, and an OS handle can't be copied). It can be moved into a receiving actor: a directly resource-typed handler parameter is a move-in, and the sender hands ownership with
move xat the call site (§15). The move transfers the handle and consumes the sender's binding - using it afterwards is a compile error within the same body (a best-effort check), and otherwise fails the actor safely at runtime (the moved-out handle is a husk no operation accepts; never undefined behaviour). Returns andthrowstypes still reject resources (they would copy back to the sender), as does a resource nested inside a struct/sum/collection (only a top-level resource is moved). An actor may also keep a resource in its ownstate.
Open a file with fs.open_file! -> Result<File, FsError>, read it with f.read_all!() -> Result<bytes, FsError>, and release it with f.close!() (§17). Sockets work the same way through extra/net: net.connect! -> Result<TcpStream, NetError> / net.listen! -> Result<TcpListener, NetError>, then read! / write! / accept! / close! (§17).
BytesBuilder is the pure-memory resource: a growable byte buffer for assembling a bytes in O(n). Create one with BytesBuilder.new!(), append with push!(b: u8) and extend!(b: bytes), and read the accumulated buffer with finish!() -> bytes - non-consuming, so the builder keeps growing after a read. Every operation is an action with an empty effect row: it mutates runtime-managed state but commits no OS capability, so the ! is here marking the referential-transparency boundary (a mutation is not substitutable, and new! mints a fresh mutable identity), not a tracked effect (§5–6). The mutation stays sealed behind these actions; no value-level mutability leaks past them. Having no OS handle, it needs no close!; its buffer is freed the moment the last handle drops. It runs on both tiers, AOT included.
BytesReader is its read counterpart: a forward cursor over an immutable bytes, so a decoder reads through it instead of threading a byte offset by hand. Create one with BytesReader.new!(input: bytes), then take!(n: int) -> bytes (up to n bytes, advancing past them - clamped to what remains, so a short read yields fewer bytes rather than failing and the primitive stays total), peek!() -> Option<u8> (the next byte without advancing - None at the end, so a self-describing decoder branches before committing a read; Option<u8>, not a -1 sentinel), remaining!() -> int, and position!() -> int (the offset to report in a decode error). Same model as BytesBuilder: empty-effect-row actions (reads of the advancing cursor aren't substitutable, so they carry !), runtime-managed, no close!, both tiers.
Naming convention: lowercase = ubiquitous primitive or default arbitrary-precision flavor; PascalCase = abstract, generic, or specialized. The lowercase tier is bool, string, bytes, the fixed-width primitives (i32, u64, f64, …), and the everyday arbitrary-precision numerics (int, decimal, rational). The PascalCase tier is abstract containers (List, Map, Set, Option), domain-specialized numerics (Complex<T>), and all user-defined types. Type parameters (T, K, V, Item) are PascalCase. The everyday numerics get the unadorned names because arbitrary precision is the right default under our correctness > performance order - code that doesn't care about width should reach for the type that just works.