hanki

4. Built-in types

TypePurpose
booltrue / false
stringUTF-8 string
bytesimmutable contiguous byte buffer for binary I/O and codecs
()unit, both the type and the value
List<T>persistent ordered collection
Array<T>fixed-size contiguous mutable numeric value (f64, f32, i64, or i32)
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), a success or a typed failure (§7)
Secret<T>material the renderers, the derives and the mailbox refuse: keys, tokens, passwords (below)
Fileopen OS file; a runtime-managed native resource, never sendable (§15)
Childlive piped or pseudoterminal subprocess; a runtime-managed native resource, never sendable (§15)
TcpStreamopen TCP connection; a runtime-managed native resource, never sendable (§15)
TcpReadHalf / TcpWriteHalfindependently movable read and write ownership for one split TCP connection (§15)
TcpListenerbound TCP listening socket; a runtime-managed native resource, never sendable (§15)
UnixStreamopen pathname Unix-domain connection; a runtime-managed native resource, never sendable (§15)
UnixReadHalf / UnixWriteHalfindependently movable read and write ownership for one split Unix-domain connection (§15)
UnixListenerbound pathname Unix-domain listener; leaves its path for explicit cleanup, never sendable (§15)
TlsStreamencrypted TCP connection owning one jointly mutable record layer; a runtime-managed native resource, never sendable (§15)
TlsReadHalf / TlsWriteHalfindependently movable application read and write capabilities over one split TLS record layer (§15)
TlsListenerbound TLS listening socket owning its compiled server configuration; a runtime-managed native resource, never sendable (§15)
Neverbottom type, the result of crash! (§6). Uninhabited; coerces to any type

Never is the bottom type: no value ever has it. The diverging crash! primitive (§6) and the ??? typed hole (§6, Typed holes) produce it, and an expression of type Never is accepted wherever any type is expected. crash!(...) and ??? therefore fit any branch or match arm without a placeholder value. Write it in a signature, def boom!() -> Never [Crash], to mark a function that never returns normally.

Calls to a -> Never function have the same coercion. An ordinary value cannot initialize a Never binding, fill a Never parameter, or return from a -> Never body. A callback may return Never where its slot expects another result type; its parameters must accept every argument the slot permits. A callback requiring a Never input therefore cannot occupy an (i32) -> i32 slot. Bottom-type coercion does not widen named type arguments: a Sink<Never> containing a (Never) -> () callback cannot become Sink<i32>.

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, and nothing mutates in place. It has no UTF-8 guarantee. There are no bytes literals. Construct one by UTF-8-encoding a string with s.to_bytes(), or from a codec. Decode back with b.to_string() -> Result<string, Utf8Error>, whose Err reports the first invalid byte offset. The buffer operations are byte-offset based: length, slice(start, stop), view(start, stop), join(parts), get(i) -> Option<u8>, empty? and hash_u64, plus the scan combinators any? / all? / position / find / fold / each!. Those are named as List's are, and they are Hanki over get and length with no new intrinsics. position is usually the one wanted: find hands back a u8 the predicate already characterized, while the offset is what a caller slices at. Transforms (map, filter) are absent. A byte-to-byte map and a byte-to-List<T> map are different operations, and BytesBuilder owns construction. Equality is byte-wise. To assemble a bytes incrementally, use a BytesBuilder (below): appending to the immutable bytes is O(n²) and a builder is O(n). string has the same pair, and StringBuilder (below) is the O(n) way to accumulate text piece by piece.

Array<T> is the fixed-size, unboxed collection for hot numeric loops. T is limited to f64, f32, i64, or i32; other element types, including u8, are rejected. Array.filled(n: int, x) and Array.from_list(xs) construct one contiguous buffer, and copy_resize(n: int, fill) is the only growth operation: there is no hidden-capacity push. length -> i64, get(i: i64) -> Option<T>, and get_or(i: i64, fallback) -> T expose a fixed-width index, allowing an AOT counted loop to remain native and vectorizable; a negative or past-the-end read gives None or the fallback. set(i: i64, x) is total too: an out-of-range index returns the receiver unchanged. In range it follows value semantics with FBIP: rebinding a uniquely-held array (xs = xs.set(i, x)) updates its buffer in place, while a shared array copies before the write, preserving an alias's old value. The buffer is a reference-counted leaf with copy-on-write backing across actor messages. It is charged by --max-bytes; sending it charges the receiving actor's logical payload too. hanki check --explain-copies reports statically visible copy sites and --profile-allocs counts the copies performed. The in-place window exists only under handle-counting memory management; tracing mode copies every write. The immutable bytes type has its own representation and binary-buffer API.

slice and view return the same value type with different ownership costs. slice(start, stop) copies the visible range into independent backing, the detach operation to use before retaining a small part of a large packet. view(start, stop) is zero-copy: it shares the immutable backing with a stored range. Both clamp the bounds identically. Every operation, equality, hashing, rendering, serialization and native I/O included, observes only the visible bytes. A view derived from another view flattens to the ultimate backing and one absolute range. An empty view retains no backing, and a full-range view may reuse its receiver. A non-empty view retains its whole backing until the last view drops. The byte ceiling conservatively charges the full pinned backing for each holder; the visible range does not reduce that charge. Across an actor send or reply, a subview detaches and copies its visible bytes on both tiers. This prevents a tiny message from pinning a sender's large packet in another actor. The representation and rules are identical on bytecode and AOT. AOT uses a bytes-specific owner/view layout; the inline string layout remains exclusive to strings.

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 being reference counting (§12). The list is File (an open file descriptor), Child (a live piped or pseudoterminal subprocess), TcpStream (an open TCP connection), TcpReadHalf and TcpWriteHalf (the directional ownership of a split TCP connection), TcpListener (a bound listening socket), UnixStream (an open pathname Unix-domain connection), UnixReadHalf and UnixWriteHalf (the directional ownership of a split Unix-domain connection), UnixListener (a bound pathname Unix-domain listener), BytesBuilder (a pure-memory growable byte buffer), BytesReader (a pure-memory read cursor over a bytes), ReadCheckpoint (a one-use reader checkpoint without input ownership), StringBuilder (a pure-memory growable text buffer) and Module<T> (a handle to a dynamically loaded module from module.load!, released by module.unload!), plus SqliteConn, TlsStream, TlsReadHalf, TlsWriteHalf and TlsListener from the extra tier. Each one reads bare or qualified under the module whose API hands it to you: fs.File, process.Child, net.TcpStream, net.TcpReadHalf, net.TcpWriteHalf, net.TcpListener, net.UnixStream, net.UnixReadHalf, net.UnixWriteHalf, net.UnixListener, bytes.BytesBuilder, bytes.BytesReader, bytes.ReadCheckpoint, str.StringBuilder, sqlite.SqliteConn, tls.TlsStream, tls.TlsReadHalf, tls.TlsWriteHalf, tls.TlsListener. The qualified spelling of an extra-tier one needs that module imported, as any other member of it does. The two spellings identify the same type in annotations and impl heads, with the same methods, mutability and actor-transfer rules. The module is the one you reach the handle through, and not the one whose .hk defines its methods: File's impl is in core/file.hk, and a program meets it as fs.File. A resource is stateful, single-owner and not copyable; it can be neither duplicated nor shared. Two rules follow:

Open a file with fs.open_file! -> Result<File, FsFailure>, read it with f.read_all!() -> Result<bytes, FsFailure>, and release it with f.close!() (§17). TCP sockets work the same way through extra/net: net.connect! -> Result<TcpStream, NetError> and net.listen! -> Result<TcpListener, NetError>. Pathname Unix-domain sockets use net.connect_unix!(path) -> Result<UnixStream, NetError> [net] and net.listen_unix!(path) -> Result<UnixListener, NetError> [net, fs_write]. A Unix bind never unlinks an existing path first, and close! or final drop closes only the descriptor. The pathname remains until the program explicitly calls fs.delete!(path, false) after its shutdown protocol excludes concurrent replacement; an automatic metadata-check-then-unlink cannot prove ownership atomically. Abstract-namespace addresses are outside this string-path API, a non-Unix host returns NetError.Other, and a UnixListener intentionally has no local_address!.

TcpStream.split!() -> Pair<TcpReadHalf, TcpWriteHalf> and UnixStream.split!() -> Pair<UnixReadHalf, UnixWriteHalf> invalidate the full handle and mint two distinct single-owner resources over one shared descriptor. Extract the fields before sending them, since a resource nested in the returned Pair cannot cross a mailbox boundary. Each half may then move to its own actor, making one actor per I/O direction the full-duplex shape: a reader parked in read! does not hold the writer's mailbox hostage. Dropping or closing a read half performs SHUT_RD, and later reads through that local half return empty bytes. Dropping or closing a write half performs SHUT_WR, later local writes report BrokenPipe, and the peer observes EOF. Either close is idempotent, the sibling direction remains usable, and the descriptor is released after the final half drops. No portable promise is made about the class or timing of a peer's reaction to a read-half close.

TlsStream.split!() -> Pair<TlsReadHalf, TlsWriteHalf> has the same ownership shape and invalidates the full handle, but the returned resources are application capabilities over one shared TLS record layer. The mapping is one record layer to two application-operation faces. TLS record state is jointly mutable: an application read may have to write a handshake or alert record, and an application write may have to read peer records. Either half may service the underlying socket in either transport direction while exposing only its own application operation. The shared state is held only for a short non-blocking rustls/socket step and is released before an actor parks; a reader waiting for a peer cannot lock out the writer whose request makes that peer answer. Closing the read half rejects later local reads and discards later application plaintext while leaving transport reads available to the writer. Closing the write half rejects later local writes and queues close_notify best-effort; the read half remains able to receive and may flush pending TLS control bytes. Either close is idempotent, and the connection is released after the final half drops.

Full TcpStream, UnixStream and TlsStream values implement the directional ReadStream and WriteStream capability traits as well as the separate duplex-and-close Stream contract. TCP, Unix and TLS read halves implement only ReadStream; their write halves implement only WriteStream. The directional traits retain their fixed sys.NetError and [net] contracts. Duplex Stream instead declares type Error, and its methods carry a variable row [e]: TCP and Unix bind sys.NetError, TLS binds sys.TlsFailure, and an in-memory implementation may bind its own error and perform no effects. A generic names that row as <S: Stream[e]> and reaches the error as S.Error (§10). net.write_all! takes S: WriteStream, http.read_request! and http.read_response! take S: ReadStream, and http.write_response! takes S: WriteStream. Code that needs duplex I/O and close!, including http.Client<S>, uses the separate Stream contract; Hanki cannot express it as formal composition of the directional traits. Listeners have accept! / close! (§17).

http.Client<S> contains a stream resource and follows the nested-resource rule above. Its operations require <S: Stream[e], S.Error: Into<HttpError>>: framing pays the transport's row; an in-memory stream needs no [net], and each transport error crosses the explicit conversion bound without erasure. A nonempty Stream.write! must report a count from 1 through the offered byte length; another count is HttpError.InvalidWriteCount(count), which prevents a nonprogressing implementation from spinning the framing loop. It is confined to the actor that constructs it; hand a connection to a worker by moving the bare stream first and constructing the client there. Dropping the client, including on actor death, drops its held stream normally.

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), or with push_be_u16! / push_be_u32! / push_be_u64!, which append a fixed-width integer big-endian, high byte first, in network byte order; a wire format needs no hand-rolled shift-and-mask. The push_le_* mates are there too, low byte first, for the formats that are not wire formats: a GPU vertex buffer and every mainstream CPU are little-endian. Floats have both. push_be_f32! / push_le_f32! / push_be_f64! / push_le_f64! write the IEEE-754 bits through to_bits, and a NaN retains its payload and its signalling bit unnormalised. Where the final size is known up front, a pixel buffer or a frame whose header states its length, reserve!(n: int) makes room for n more bytes and the run of pushes grows the buffer once, and spare_capacity!() -> int reports the room already there. reserve! is a hint: it changes no byte the builder has accumulated and none that finish! returns, only when the allocation happens, and reserving inside room already available does nothing. A request too large to satisfy is declined and never fatal. The buffer is left as it was, the pushes that follow grow it the ordinary way, and asking for room is never how a program dies. The figure spare_capacity! reports past a reserve is the allocator's business, and the only promise is >= n: compare against it, never pin it. For the buffers a caller writes out of order, a pixel row or a frame header patched once its length is known, length!() -> int reports how many bytes are written, fill!(b: u8, n: int) appends n copies of b, and set!(i: int, b: u8) -> bool writes one byte at an index. set! answers whether the write landed: false means i was past the last written byte and nothing changed, the buffer never growing to meet an index, since the bytes between the old end and i would then hold what the caller never chose. fill! is how a buffer reaches a size first. There is no range fill, and a run is a fill! or a loop of set!. Read the accumulated buffer with finish!() -> bytes, which is non-consuming, and the builder goes on growing after a read. Every operation is an action with an empty effect row. It mutates runtime-managed state and commits no OS capability, and the ! here marks the referential-transparency boundary, a mutation being non-substitutable and new! minting a fresh mutable identity, and no tracked effect (§5-6). The mutation is sealed behind these actions, and no value-level mutability escapes 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 that saves a decoder from threading a byte offset by hand. Create one with BytesReader.new!(input: bytes), then:

Same model as BytesBuilder: empty-effect-row actions, reads of the advancing cursor being non-substitutable and therefore marked !, runtime-managed, no close!, both tiers.

StringBuilder is the text mirror of BytesBuilder, a growable text buffer for accumulating a string in O(n) where the pieces arrive one at a time and there is no list of parts to join. Create one with StringBuilder.new!(), append with push!(s: string) or push_display!(v), which renders v through its Display impl first, and read the accumulated text with finish!() -> string, non-consuming like the builder's. Same model again: empty-effect-row actions, runtime-managed, no close!, both tiers. finish! is total and returns no Result: every pushed piece is a string and therefore valid UTF-8, and the join of valid UTF-8 is valid UTF-8. That is the difference from assembling text through a BytesBuilder, whose bytes.to_string can fail. Use it over rebinding acc = "#{acc}#{x}" in a loop. The buffer is O(n) on both tiers and says so at the call site, where the rebinding is O(n) only where the runtime can prove the left operand uniquely owned, and O(n^2) otherwise.

The three text-assembly shapes each have their own tool, and each is a single pass. A fixed number of pieces is interpolation: "#{a} and #{b}" lowers to one build operation over its parts, summing their lengths and allocating once. It is no concat fold and gets no more expensive per hole than the bytes it copies. A list of pieces is sep.join(parts), the same shape as a method call. Pieces arriving one at a time are a StringBuilder. None of these needs a string.concat, and string has none: it was removed once interpolation lowered in a single pass, and a call to it is H0406, which names the tool for the assembly at hand in place of a nearest-spelling guess. bytes lost its concat the same way and for the same reason, a chain copying everything to the left of each link again: sep.join(parts) fuses a known list in a single pass, a BytesBuilder takes pieces as they arrive, and a call to bytes.concat is H0406 too. List retains its concat; concatenating two lists copies spines and no element.

Secret<T> wraps material that must not be rendered, serialised or compared by accident: a private key, a session token, a password. Secret.hide(v) wraps and s.reveal() reads the material back. reveal is the only way in, and every call site is a place the secret exists in the clear. What the type refuses:

Three limits are stated here. A closure's captures are a hole in the Sendable rule. They are no part of a closure's type, and a handler taking () -> string accepts one that closes over a secret and returns reveal(), and the send copies the material with it. No static rule can close that half, the capture being invisible to the type. The half a type can show is closed: a closure type that names a secret anywhere in its signature, () -> Secret<T> or (Secret<T>) -> i32, is H0635 like any other mention, at the cost of refusing a factory that would mint a fresh secret in the receiver, a shape nothing writes. Resources and Futures work the same way; a captured resource has the runtime deep-copy guard behind it, and a secret has no equivalent. Nothing wipes the material. A value has no destructor, and the heap pool clears a cell when it hands the cell out and not when the value dies. The bytes outlive the last handle by an unbounded window. A Secret narrows who reads the material and never how long it exists. The guarantees are about the wrapper. reveal() yields a plain value with none of them, the stated trade for a type you can put anything into.

The naming convention puts ubiquitous primitives and the default arbitrary-precision flavours in lowercase; PascalCase names are abstract, generic or specialized. The lowercase tier is bool, string, bytes, the fixed-width primitives (i32, u64, f64 and the rest), 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 have the unadorned names because arbitrary precision is the right default under the correctness-over-performance order: code with no width requirement should reach for the type that works.

Client connection deadlines

TCP and TLS clients carry one absolute wall-clock deadline. net.connect! and tls.connect! start the 60-second default before name resolution and retain it through TCP connect, the TLS handshake where present, and every later read or write. connect_with_deadline!(host, port, timeout_ms) in either module chooses another initial budget. TcpStream.set_deadline!, TlsStream.set_deadline!, and http.Client.set_deadline! when S: DeadlineStream reset it from now for a later exchange. An HTTP request's write and response read share that one budget; progress never refreshes it. A negative budget clamps to an immediate poll; an already-ready operation can still complete without waiting. Accepted server connections remain unbounded.

DeadlineStream is independent of the duplex Stream trait; custom and in-memory streams need not invent a clock. A timeout is sys.NetError.TimedOut for TCP and sys.TlsError.TlsTimedOut for TLS. HTTP normalizes both paths to one HttpError.TimedOut case while retaining Net and Tls detail for every other failure.