Interfaces (Traits)

Decision: Interfaces are nominal — a type satisfies an interface only when explicitly stated, either through structural satisfaction (having the right methods) or an explicit provide block.

Reason: Structural typing (Go’s model) causes accidental interface satisfaction. If any type with a .close() method satisfies io.Closer, a DatabaseConnection might accidentally satisfy NetworkCloser. Nominal interfaces prevent this class of bug and make intent explicit.

  interface Serialize {
    fn serialize(*const self, writer: *Writer): !void;
}

interface Deserialize {
    static fn deserialize(reader: *Reader): !Self;
}

type Point = struct { x: f32, y: f32 };

provide Serialize for Point {
    fn serialize(*const self, writer: *Writer): !void {
        try writer.writeF32(self.x);
        try writer.writeF32(self.y);
    }
}

provide Deserialize for Point {
    static fn deserialize(reader: *Reader): !Self {
        return .{
            .x = try reader.readF32(),
            .y = try reader.readF32(),
        };
    }
}

-- generic over any serializable type
fn writeAll[T: Serialize](items: []const T, writer: *Writer): !void {
    for item in items {
        try item.serialize(writer);
    }
}
  

Generics With Bounds

Decision: Generic parameters use square brackets with explicit interface bounds: [T: Bound].

Reason: Angle brackets (<T>) cause the infamous a < b > c parsing ambiguity and require complex parser heuristics. Square brackets are unambiguous. Zig’s comptime-duck-typing has poor error messages — bounds checked at definition site produce errors at the generic declaration, not scattered across every instantiation site.

  -- bounds checked at definition, errors are clear
fn sum[T: Add[T] + Default](items: []T): T {
    var result = T::default();
    for item in items {
        result := result + item;
    }
    return result;
}

-- requires clause for complex bounds
fn process[K, V](map: *HashMap[K, V]): !Report
requires K: Hash + Eq,
         V: Serialize + Clone,
{
    -- K is guaranteed Hash + Eq here
    -- V is guaranteed Serialize + Clone here
}

-- error caught at definition:
fn broken[T](x: T): T {
    return x + x;    -- compile error: T has no Add bound
                     -- not scattered across every call site
}
  

Affine Types — Move Semantics

Decision: Non-Copy types move on assignment. Using a moved value is a compile error. Explicit Clone for deep copies.

Reason: Move semantics prevent double-free and use-after-free without a garbage collector. The linear type checker enforces resource lifecycle. Unlike Rust’s borrow checker, Violet’s version doesn’t track aliasing — it only tracks ownership, which is simpler to implement and understand.

  type File = struct { fd: i32 };

provide Destroy for File {
    type Args = void;
    fn deinit(*self, _: void) { os.close(self.fd); }
}

fn example(): !void {
    var f = try File::open("data.txt");

    var g = f;       -- f is MOVED into g
    g.deinit();      -- g is consumed here

    f.deinit();      -- compile error: f was moved
}

-- explicit copy for Copy types
type Point = struct { x: f32, y: f32 } derive [Copy];

var p1 = Point { .x = 1.0, .y = 2.0 };
var p2 = p1;         -- copied, p1 is still valid
debug.print("{}\n", .{p1.x});   -- valid
  

!T Error Union Syntax

Decision: !T means “either an error or T”. ParseError!T names the specific error type. try propagates errors. catch handles them.

Reason: Rust’s Result<T, E> is verbose at every return site. Go’s (T, error) cannot be composed and requires the if err != nil pattern everywhere. !T as a type-level annotation is compact, composable, and makes error-handling intent immediately visible in function signatures.

  -- ! in the return type signals: this function can fail
fn parsePort(s: []const u8): ParseError!u16 {
    if s.len = 0 { return error.Empty; }
    var n = try parseInt(s);
    if n > 65535 { return error.OutOfRange { .value = n }; }
    return n as u16;
}

-- compose: try propagates up if error types match
fn parseAddress(s: []const u8): ParseError!Address {
    var parts = splitOnce(s, ':') orelse return error.InvalidFormat;
    return .{
        .host = parts[0],
        .port = try parsePort(parts[1]),
    };
}

-- chain errors across types with wrap
type AppError = error {
    wrap ParseError,
    Network { code: i32 },
};

fn connect(addr: []const u8): AppError!Conn {
    var parsed = parseAddress(addr) catch wrap;   -- ParseError -> AppError
    return openConn(parsed) catch |e| return error.Network { .code = e.code };
}
  

unsafe Blocks

Decision: Operations that the compiler cannot verify safe require explicit unsafe blocks. unsafe does not disable checks — it enables specific opt-outs via named intrinsics.

Reason: Rust proved that localising unsafety makes it auditable. “Search for unsafe in the codebase” finds all places where correctness depends on programmer discipline rather than the type system. Violet follows this: the unsafe surface is small and visible.

  -- safe: compiler verifies
fn safeIndex(arr: []const i32, i: usize): !i32 {
    return try arr[i];   -- bounds checked, returns error on out-of-bounds
}

-- unsafe: programmer verifies
fn fastIndex(arr: []const i32, i: usize): i32 {
    unsafe {
        return arr.getIndexUnchecked(i).*;   -- no bounds check
    }
}

-- the unsafe surface in Violet:
unsafe {
    var p = raw as *MyStruct;           -- pointer cast
    var x = #ptrAdd(base, offset).*;   -- pointer arithmetic
    var y = arr.getIndexUnchecked(i);  -- unchecked indexing
    c.someExternFunction();             -- calling C
    #asm("nop");                        -- inline assembly
}
  

Operator Overloading Via Fixed Symbol Table

Decision: A fixed set of symbols can be overloaded by implementing the corresponding interface. Users cannot define new operator symbols.

Reason: Rust allows arbitrary operator overloading but limits it to existing operators. C++ allows user-defined operators and the ecosystem produced / for path concatenation, << for stream output, and * for smart pointer dereference — all semantically confusing. The fixed table prevents pathological overloading while enabling natural mathematical code.

  operator (+)  = Add;
operator (-)  = Sub;
operator (*)  = Mul;
operator (/)  = Div;
operator ([]) = Index;
operator (=)  = Eq;

type Matrix = struct { data: [4][4]f32 };

provide Add[Matrix] for Matrix {
    fn add(a: Matrix, b: Matrix): Matrix {
        var result: Matrix = #undefined;
        inline for i in 0..4 {
            inline for j in 0..4 {
                result.data[i][j] := a.data[i][j] + b.data[i][j];
            }
        }
        return result;
    }
}

var a = Matrix { ... };
var b = Matrix { ... };
var c = a + b;    -- desugars to Add[Matrix].add(a, b)
                  -- no surprise: + means add, always
  

Rc[T] and Arc[T] — Reference Counting As A Library

Decision: Reference-counted pointers are library types, not language primitives. Arc[T] is used for shared ownership across threads, Rc[T] for single-threaded sharing.

Reason: Rust proved that reference counting doesn’t need language support when you have move semantics and a Drop-equivalent. Making it a library type means it can be replaced, extended, or avoided. The language doesn’t bake in a specific ownership strategy.

  -- shared ownership without a garbage collector
fn shareConfig(alloc: Allocator): !void {
    var config = try loadConfig(alloc);
    var shared = Arc[Config]::new(alloc, config);

    -- clone the Arc, not the Config (cheap: atomic increment)
    var copy = shared.clone(alloc);

    try io.scope([](s: *IoScope) {
        try s.spawn([move shared](_: *IoScope) {
            useConfig(shared.*);
        });
        try s.spawn([move copy](_: *IoScope) {
            useConfig(copy.*);
        });
    });
    -- Config freed when last Arc drops (refcount hits 0)
}
  

dyn — Dynamic Dispatch With Explicit Fat Pointers

Decision: Dynamic dispatch uses *dyn Interface — an explicit fat pointer (data pointer + vtable). No automatic polymorphism, no hidden vtable.

Reason: C++ virtual functions make every object carry a vtable pointer implicitly. Rust’s dyn Trait is explicit but the syntax is scattered. Violet’s *dyn Interface makes the indirection visible at every use site — you know you’re paying for dynamic dispatch because you wrote *dyn.

  dyn interface Drawable {
    fn draw(*self, ctx: *Context): void;
    fn bounds(*const self): Rect;
}

-- static dispatch (zero overhead, monomorphized)
fn renderStatic[D: Drawable](drawable: *D, ctx: *Context) {
    drawable.draw(ctx);
}

-- dynamic dispatch (vtable lookup, heterogeneous collection)
fn renderAll(drawables: [](*dyn Drawable), ctx: *Context) {
    for d in drawables {
        d.*.draw(ctx);    -- .* makes the dereference visible
    }
}

var circle = Circle { .radius = 5.0 };
var rect   = Rect { .w = 10.0, .h = 5.0 };

-- static: no overhead
renderStatic(&circle, ctx);

-- dynamic: fat pointer, vtable lookup
var items = []*dyn Drawable{ &circle, &rect };
renderAll(&items, ctx);
  

Deref[T] — Explicit Dereferencing Without Coercions

Decision: Smart pointers implement Deref[T], making .* available. There are no implicit coercions — Rc[T] never silently becomes T.

Reason: Rust’s Deref coercions make code shorter but harder to read — box.method() silently becomes (*box).method() after arbitrary deref chain traversal. Violet requires explicit rc.*.method(). The extra two characters cost nothing and eliminate an entire category of “where did this method come from?” confusion.

  interface Deref[T] {
    fn deref(*const self): *const T;
    fn derefMut(*self): *T;
}

provide Deref[Config] for Arc[Config] {
    fn deref(*const self): *const Config { return &self.inner.value; }
    fn derefMut(*self): *Config { return &self.inner.value; }
}

var arc: Arc[Config] = Arc[Config]::new(alloc, cfg);

-- explicit: you see the dereference
arc.*.host = "example.com";

-- NOT: arc.host = "example.com"  (implicit coercion, rejected)
-- NOT: (*arc).host                (prefix deref, rejected)
  

Blanket Implementations

Decision: provide[T: Bound] Interface for T implements an interface for all types satisfying a bound. specialize provide overrides a blanket for a specific type.

Reason: Blanket implementations enable “if T is X, then T is automatically Y” patterns that reduce boilerplate. specialize provide provides an explicit override path without the ambiguity of C++ partial template specialization.

  -- blanket: anything Serialize is also Encodable
provide[T: Serialize] Encodable for T {
    fn encode(*const self, buf: *Buffer): !void {
        var writer = buf.writer();
        try self.serialize(&writer);
    }
}

-- specific types use the blanket automatically
-- no explicit `provide Encodable for Point` needed
-- (Point already provides Serialize)

-- override the blanket for a specific type
specialize provide Encodable for BigInt {
    fn encode(*const self, buf: *Buffer): !void {
        -- BigInt needs special encoding, not generic Serialize path
        try encodeBigInt(self, buf);
    }
}