Tutorial: Protocols
Protocols are Verum's interfaces — equivalent to Rust's traits or Haskell's type classes. This tutorial builds a small serialisation framework from scratch: define the protocol, implement it for core types, derive it for user types, and see how generics + protocol bounds compose.
Time: 45 minutes.
Prerequisites: generics (from the language tour), basic pattern matching.
Step 1 — Define the protocol
// src/serialize.vr
pub type Serialize is protocol {
fn write(&self, out: &mut SerializeBuf);
}
pub type SerializeBuf is { bytes: List<Byte> };
impl SerializeBuf {
pub fn new() -> Self {
Self { bytes: List.new() }
}
pub fn push_byte(&mut self, b: Byte) {
self.bytes.push(b);
}
pub fn push_bytes(&mut self, b: &[Byte]) {
self.bytes.extend(b);
}
pub fn into_bytes(self) -> List<Byte> {
self.bytes
}
}
A Serialize implementation's only job is to write its bytes into
the buffer. The buffer API is concrete (no generic over output) for
simplicity; a real framework would use a Write protocol. We'll get
there.
Step 2 — Implement for primitives
// src/impls.vr
mount .self.serialize.*;
implement Serialize for Int {
fn write(&self, out: &mut SerializeBuf) {
let bytes = self.to_le_bytes(); // little-endian, 8 bytes
out.push_bytes(&bytes);
}
}
implement Serialize for Bool {
fn write(&self, out: &mut SerializeBuf) {
out.push_byte(if *self { 1 } else { 0 });
}
}
implement Serialize for Text {
fn write(&self, out: &mut SerializeBuf) {
let bytes = self.as_bytes();
let len = bytes.len();
(len as Int).write(out); // length prefix
out.push_bytes(bytes);
}
}
Each implementation is a recipe. Note the recursion in Text —
(len as Int).write(out) calls the Int impl.
Step 3 — Implement for a tuple
Protocols compose with generics:
implement<A: Serialize, B: Serialize> Serialize for (A, B) {
fn write(&self, out: &mut SerializeBuf) {
self.0.write(out);
self.1.write(out);
}
}
Now (42, "hello"), (true, false), and any pair whose components
are themselves Serialize work automatically.
Step 4 — Use the protocol
// src/main.vr
mount .self.serialize.*;
mount .self.impls.*;
fn main() using [IO] {
let mut out = SerializeBuf.new();
42.write(&mut out);
"hello".write(&mut out);
(true, 100).write(&mut out);
let bytes = out.into_bytes();
print(f"wrote {bytes.len()} bytes: {bytes:?}");
}
$ verum run
wrote 26 bytes: [42, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 104, 101, 108, 108, 111, 1, 100, 0, 0, 0, 0, 0, 0, 0]
Step 5 — Implement for List<T>
implement<T: Serialize> Serialize for List<T> {
fn write(&self, out: &mut SerializeBuf) {
(self.len() as Int).write(out); // length prefix
for item in self.iter() {
item.write(out);
}
}
}
With this, List<Int>, List<Text>, and List<(Bool, Int)> all
serialize — the compiler builds the right implementation chain.
Step 6 — Derive for user types
Writing Serialize for every record would be tedious. Use
@derive(Serialize) to have the compiler generate it:
@derive(Serialize)
type User is {
id: Int,
name: Text,
admin: Bool,
};
fn main() using [IO] {
let u = User { id: 42, name: "Alice", admin: true };
let mut out = SerializeBuf.new();
u.write(&mut out);
print(f"{out.into_bytes().len()} bytes");
}
@derive(Serialize) synthesises the obvious impl: write each field
in declaration order. For variants, the derive also writes a tag
byte for the discriminant.
See cookbook/write-a-derive for how to write your own derive macro — it's the same machinery.
Step 7 — Add a second protocol (Deserialize)
pub type Deserialize is protocol {
fn read(input: &mut DeserializeBuf) -> Result<Self, DeserializeError>;
}
pub type DeserializeBuf is {
bytes: List<Byte>,
pos: Int,
};
impl DeserializeBuf {
pub fn new(bytes: List<Byte>) -> Self {
Self { bytes, pos: 0 }
}
pub fn read_byte(&mut self) -> Result<Byte, DeserializeError> {
if self.pos >= self.bytes.len() {
return Result.Err(DeserializeError.Eof);
}
let b = self.bytes[self.pos];
self.pos += 1;
Result.Ok(b)
}
pub fn read_bytes(&mut self, n: Int) -> Result<&[Byte], DeserializeError> {
if self.pos + n > self.bytes.len() {
return Result.Err(DeserializeError.Eof);
}
let slice = &self.bytes[self.pos..self.pos + n];
self.pos += n;
Result.Ok(slice)
}
}
type DeserializeError is Eof | InvalidData(Text);
And the round-trip:
implement Deserialize for Int {
fn read(input: &mut DeserializeBuf) -> Result<Self, DeserializeError> {
let bytes = input.read_bytes(8)?;
Result.Ok(Int.from_le_bytes(bytes.try_into().unwrap()))
}
}
Step 8 — Protocol extension (extends)
Combine protocols with extends:
pub type Serde is protocol extends Serialize + Deserialize {
// Any type that implements both Serialize and Deserialize
// automatically implements Serde.
}
fn roundtrip<T: Serde>(value: &T) -> Result<T, DeserializeError> {
let mut out = SerializeBuf.new();
value.write(&mut out);
let mut input = DeserializeBuf.new(out.into_bytes());
T.read(&mut input)
}
Serde is a marker protocol — it adds no methods, but any type
implementing both Serialize and Deserialize gets it for free.
Useful for demanding the whole round-trip at a boundary.
Step 9 — Associated types
For a protocol that produces a specific type:
pub type Parser is protocol {
type Output;
fn parse(&self, input: &Text) -> Result<Self.Output, ParseError>;
}
type IntParser is ();
implement Parser for IntParser {
type Output = Int;
fn parse(&self, input: &Text) -> Result<Int, ParseError> {
input.trim().parse_int().ok_or(ParseError.InvalidInt)
}
}
Self.Output in the signature references the implementer's chosen
type. Using IntParser.parse("42") gives you Result<Int, ParseError>
— the type system propagates the choice.
Step 10 — Generic associated types (GATs)
An associated type can itself take parameters:
pub type Iterable is protocol {
type Iter<'a>;
fn iter<'a>(&'a self) -> Self.Iter<'a>;
}
GATs are essential for lending iterators and for higher-kinded abstractions. See language/protocols.
Step 11 — Specialisation
For types where a more specific implementation is faster, use
@specialize:
@specialize
implement Serialize for List<Byte> {
fn write(&self, out: &mut SerializeBuf) {
(self.len() as Int).write(out);
out.push_bytes(self.as_slice()); // single memcpy, no per-item call
}
}
The generic implement<T: Serialize> for List<T> still works for
List<Int> or List<Text>; the compiler uses the specialised
version only for List<Byte>.
What you built
A minimal but real serialisation framework:
Serialize/Deserializeprotocols.- Implementations for primitives, tuples,
List<T>. @derive(Serialize)on user types.Serdeas a marker protocol extending both.- Associated types (
Parser.Output). - Specialisation for
List<Byte>.
And along the way, you've seen:
- Protocol definitions and implementations.
- Generic parameters bounded by protocols.
- Protocol extension with
extends. - Recursive protocol calls (
Textcalls theIntimpl). - Derive machinery via
@derive(...).
What to read next
- language/protocols — GATs, negative bounds, specialisation, coherence.
- language/generics — type parameters, bounds, HKTs.
- cookbook/write-a-derive — how
to write your own
@derive(...)macro. - language/metaprogramming —
the
meta fn/quotefoundation of derive. stdlib/base— the protocols the standard library ships (Eq, Ord, Hash, Clone, Display, Debug, Default, etc.).