Skip to content

Bytes

Build, borrow, and decode binary buffers with the Bytes and Buffer traits.

Updated View as Markdown

Binary data uses the ordinary sequence types. There is no nominal bytes or Buffer struct.

Spelling Meaning
[]byte owned, growable List[byte]
[..]byte borrowed, fixed-length Slice[byte]
Bytes the read-only byte protocol both implement
Buffer the grow/append protocol only []byte implements

byte is a spelling alias for u8, so byte literals carry the u8 suffix.

fn main(): void {
    mut greeting: []byte = []
    greeting.append_string("hello")
    greeting.append(33u8)          // '!'
    println(greeting.decode_utf8() ?? "")
}

Building a buffer

A mut []byte is the string builder. append adds one byte, append_string adds a string’s UTF-8 bytes, append_all copies any Bytes source, and append_int/append_float write decimal ASCII straight into the reserved tail with no intermediate string allocation.

fn status_line(code: int, reason: string): []byte {
    mut out: []byte = List.with_capacity[byte](64)
    out.append_string("HTTP/1.1 ")
    out.append_int(code)
    out.append(32u8)               // space
    out.append_string(reason)
    out.append_string("\r\n")
    return out
}

fn main(): void {
    println(status_line(404, "Not Found").decode_utf8() ?? "")
}

List.with_capacity[byte](n) reserves storage up front; reserve(additional) grows an existing buffer without changing its logical length. Neither changes what byte_len() reports.

clear() resets the length to zero but keeps the capacity, which makes a single buffer reusable across a loop instead of allocating per iteration:

fn render_rows(count: int): []string {
    mut buf: []byte = List.with_capacity[byte](64)
    mut rows: []string = []
    for i in 0..count {
        buf.clear()
        buf.append_string("row ")
        buf.append_int(i.to_int())
        rows.add(buf.decode_utf8() ?? "")
    }
    return rows
}

fn main(): void {
    for row in render_rows(3) {
        println(row)
    }
}

append_all accepts anything implementing Bytes, so an owned buffer can copy from another list, a borrowed slice, or a string’s byte view:

fn concat(header: string, payload: []byte, trailer: [..]byte): []byte {
    mut packet: []byte = []
    packet.append_all(header.bytes())
    packet.append_all(payload)
    packet.append_all(trailer)
    return packet
}

Borrowed views

A [..]byte slice borrows the parent allocation. Creating one copies nothing, which makes it the right parameter type for anything that only reads.

fn checksum(data: [..]byte): int {
    mut sum := 0
    mut i := 0
    for i < data.byte_len() {
        sum = (sum + data.byte_at(i).unwrap_or(0u8).to_int()) % 65_536
        i = i + 1
    }
    return sum
}

fn main(): void {
    frame := "payload".to_bytes()
    println("${checksum(frame.slice(0..4) ?? "".bytes())}")
}

slice(range) on a []byte returns Slice[byte]?. Ranges are half-open, and an out-of-bounds end or a start past the end gives None — carve the view once and handle absence explicitly:

error FrameError { Short }

fn body(frame: []byte, header_len: int): [..]byte ! FrameError {
    match frame.slice(header_len..frame.len()) {
        Some(view) => return view
        None => error Short
    }
}

A slice cannot grow — Buffer is physically impossible on a borrow, and the compiler says so:

fn grow(mut view: [..]byte): void {
    view.append(1u8)
}

Keep the parent alive for the view’s lifetime, and copy out with to_list() when the bytes must outlive the parent.

The Bytes protocol

Both []byte and [..]byte implement Bytes, and so does anything else you implement it for. The trait has two primitives and four derived operations.

Method Meaning
byte_len() number of bytes
byte_ptr() arena-relative pointer to byte 0
byte_is_empty() whether the length is zero
byte_at(index) bounds-checked byte?
is_valid_utf8() strict UTF-8 validation
decode_utf8() string? — validate and copy out

byte_at returns None for negative and out-of-range indexes, so a scan never needs its own bounds check.

Writing a function against impl Bytes lets one implementation serve owned buffers, borrowed views, and string byte views alike:

const HEX: string = "0123456789abcdef"

fn hex_digit(nibble: int): byte {
    return HEX.to_bytes().get(nibble.to_u32()) ?? 48u8
}

fn hexdump(data: impl Bytes): string {
    mut out: []byte = List.with_capacity[byte](data.byte_len() * 3)
    mut i := 0
    for i < data.byte_len() {
        if i > 0 { out.append(32u8) }
        b := (data.byte_at(i) ?? 0u8).to_int()
        out.append(hex_digit((b >> 4) & 0xF))
        out.append(hex_digit(b & 0xF))
        i = i + 1
    }
    return out.decode_utf8() ?? ""
}

fn main(): void {
    owned := "AB".to_bytes()
    println(hexdump(owned))
    println(hexdump("AB".bytes()))
}

Note the shape of that loop: the output buffer is the accumulator. There is no string.join to reach for — it is declared in the prelude but has no lowering path yet (see Availability below), and a []byte you append to is faster anyway.

byte_ptr() is a low-level arena-relative pointer intended for the prelude and host wrappers. Do not retain it across buffer mutation, scope exit, or a suspension point unless a lower-level contract pins the allocation.

Text conversion

Text and bytes never convert implicitly. The compiler rejects the confusion:

fn as_text(data: []byte): string {
    return data
}

Four explicit conversions cover the traffic in both directions:

fn conversions(text: string, data: []byte): int {
    owned: []byte = text.to_bytes()      // copies
    borrowed: [..]byte = text.bytes()    // borrows, no copy
    decoded: string? = data.decode_utf8()
    valid: bool = data.is_valid_utf8()
    return owned.len() + borrowed.len() + (decoded ?? "").len() + (if valid { 1 } else { 0 })
}

decode_utf8 performs strict Unicode validation before copying into a fresh string: overlong encodings, surrogate code points, truncated sequences, stray continuation bytes, and values above U+10FFFF are all rejected. It returns string?, and forgetting that is a type error, not a silent lossy decode:

fn decode(data: []byte): string {
    return data.decode_utf8()
}

If your protocol wants replacement characters instead of rejection, implement that policy yourself — the standard decoder is strict, never lossy.

error WireError { NotUtf8 }

fn strict(payload: []byte): string ! WireError {
    match payload.decode_utf8() {
        Some(text) => return text
        None => error NotUtf8
    }
}

fn lenient(payload: []byte): string {
    return payload.decode_utf8() ?? "<binary>"
}

Empty buffers

An empty []byte or [..]byte reports byte_len() == 0 and byte_is_empty() == true. Its byte_ptr() value is an implementation detail and may be null-like, so never dereference it without checking the length first. The safe protocol methods already enforce that rule, which is another reason to prefer byte_at over pointer arithmetic.

fn first_byte(data: impl Bytes): byte? {
    if data.byte_is_empty() { return None }
    return data.byte_at(0)
}

A worked example

A length-prefixed frame codec: build with Buffer, parse with Bytes and slice, and decode text only at the boundary.

error CodecError { Short, BadLength, NotUtf8 }

const HEADER_LEN: int = 5

fn encode(kind: byte, payload: string): []byte {
    body := payload.to_bytes()
    mut frame: []byte = List.with_capacity[byte](HEADER_LEN + body.len())
    frame.append(kind)
    // 4-byte big-endian length.
    n := body.len()
    frame.append(((n >> 24) & 0xFF).to_u8())
    frame.append(((n >> 16) & 0xFF).to_u8())
    frame.append(((n >> 8) & 0xFF).to_u8())
    frame.append((n & 0xFF).to_u8())
    frame.append_all(body)
    return frame
}

fn read_length(frame: impl Bytes): int {
    mut n := 0
    mut i := 1
    for i < HEADER_LEN {
        n = (n << 8) | (frame.byte_at(i) ?? 0u8).to_int()
        i = i + 1
    }
    return n
}

fn decode(frame: []byte): string ! CodecError {
    if frame.byte_len() < HEADER_LEN { error Short }
    declared := read_length(frame)
    if declared != frame.byte_len() - HEADER_LEN { error BadLength }
    match frame.slice(HEADER_LEN..frame.len()) {
        None => error Short
        Some(body) => match body.decode_utf8() {
            Some(text) => return text
            None => error NotUtf8
        }
    }
}

fn main(): void {
    frame := encode(7u8, "ping")
    match decode(frame) {
        Ok(text) => println("kind=${frame.byte_at(0) ?? 0u8} payload=${text}")
        Err(e) => println("bad frame")
    }
}

Availability

Everything on this page compiles. Two neighbouring signatures that a byte-handling program reaches for do not, and atoll check will not warn you — the failure arrives at atoll build as ATOLL2004: builtin method ... has no lowering path.

Signature Status
string.join(items, sep) declared in the prelude, no lowering path yet
int.to_radix(base) declared in the prelude, no lowering path yet

Accumulate into a mut []byte instead of joining, and format hex or binary from a digit table as hexdump above does.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close