Violet
Design decisions designed originally for Violet
Explicit *Io Parameter — No async fn
Decision: Functions that may suspend take an explicit *Io scheduler parameter. There is no async fn keyword. Suspension is visible at every call site.
Reason: Rust’s async fn colors the call graph — you cannot call an async function from a sync context without wrapping in an executor. Go’s goroutines hide suspension entirely. Both create versions of the “what color is your function” problem. Violet’s explicit *Io parameter makes suspension visible at the call site — critical for reasoning about lock safety and task scheduling.
-- provably synchronous: no *Io, cannot suspend
fn hashPassword(password: []const u8): [32]u8 {
return sha256(password);
}
-- may suspend: *Io is explicit
fn fetchUser(io: *Io, alloc: Allocator, id: u64): !User {
var conn = try db.connect(io, "postgres://...");
defer conn.deinit();
return try conn.queryOne(io, alloc, "SELECT * FROM users WHERE id = $1", id);
}
-- lock + suspension: danger is visible
fn updateUser(io: *Io, lock: *Mutex[Cache], id: u64): !void {
var guard = lock.lock();
-- reader sees: guard is held, fetchUser takes *io, it can suspend
-- this is a potential deadlock if another task needs this lock
var user = try fetchUser(io, alloc, id); -- *io visible: may suspend
guard.*.update(user);
}
ctx Parameter Modifier — Implicit Allocator Threading
Decision: Parameters marked ctx can be supplied implicitly from a using declaration in the calling scope. Only non-control-flow dependencies (allocators, loggers) can be ctx. Schedulers cannot.
Reason: Explicit allocators are correct but noisy. A function that creates three lists and a map passes alloc seven times. ctx allows threading the allocator implicitly — declared once with using, visible in the function signature, overridable at the call site.
Schedulers are explicitly excluded because suspension affects correctness (lock ordering, real-time guarantees). Hiding the scheduler would make the lock-suspension bug invisible.
-- declaration: ctx marks implicitly-suppliable params
fn tokenize(input: []const u8, ctx alloc: Allocator): ![]Token { ... }
fn buildAst(tokens: []Token, ctx alloc: Allocator): !Ast { ... }
-- usage: using establishes the implicit allocator
fn compile(alloc: Allocator, source: []const u8): !Binary {
using alloc; -- all ctx alloc params below use this
var tokens = try tokenize(source); -- alloc implicit
errdefer tokens.deinit();
var ast = try buildAst(tokens.asSlice()); -- alloc implicit
errdefer ast.deinit();
return try codegen(ast); -- alloc implicit
}
-- override at call site
var tokens = try tokenize(source, .alloc = scratch_arena);
-- scheduler is NEVER ctx: always explicit
fn fetch(io: *Io, url: []const u8, ctx alloc: Allocator): ![]u8 { ... }
-- ^^^^^^^^ explicit, never ctx
Linear Types — Destroy Interface
Decision: Types implementing Destroy are linear — they must be explicitly consumed. The compiler errors if a linear value exits scope without being consumed.
Reason: Rust’s Drop is automatic and cannot take parameters. Violet’s Destroy is explicit (defer x.deinit(alloc)) and takes typed arguments. This eliminates the need for types to store their own allocator (fat types), allows the same type to be cleaned up by different allocators, and keeps the cleanup code visible rather than hidden in a destructor.
interface Destroy {
type Args;
fn deinit(*self, args: Self::Args): void;
}
type Database = struct {
conn: *anyopaque,
host: []const u8,
};
provide Destroy for Database {
type Args = void; -- no args: close is self-contained
fn deinit(*self, _: void) {
db_close(self.conn);
}
}
type QueryResult = struct {
rows: []Row,
};
provide Destroy for QueryResult {
type Args = Allocator; -- needs allocator to free rows
fn deinit(*self, alloc: Allocator) {
for row in self.rows { row.deinit(alloc); }
alloc.free(self.rows);
}
}
fn query(db: *Database, alloc: Allocator, sql: []const u8): !QueryResult {
var result = try db_query(db, alloc, sql);
-- result is linear: compiler ensures it's consumed
return result; -- caller must deinit
}
fn example(db: *Database, alloc: Allocator): !void {
var rows = try query(db, alloc, "SELECT * FROM users");
defer rows.deinit(alloc); -- satisfies the linear type checker
process(rows);
}
Two-Tier Cleanup: Destroy + AsyncClose
Decision: Destroy.deinit is always synchronous and may be lossy. AsyncClose.close is graceful and requires a scheduler. Resources that need graceful shutdown implement both.
Reason: When a task is cancelled, the scheduler calls deinit synchronously — it cannot wait for an async graceful shutdown because the scheduler is shutting down. The two-tier model resolves the circular dependency: synchronous cleanup is always available as the fallback, asynchronous graceful shutdown is opt-in when a scheduler is present.
interface Destroy {
type Args;
fn deinit(*self, args: Self::Args): void; -- sync, always available
}
interface AsyncClose {
type Args;
fn close(*self, io: *Io, args: Self::Args): !void; -- async, graceful
}
type TcpStream = struct { fd: i32 };
-- sync: force-close (may lose buffered data)
provide Destroy for TcpStream {
type Args = void;
fn deinit(*self, _: void) {
os.close(self.fd); -- immediate, no drain
}
}
-- async: graceful shutdown (flush, FIN handshake)
provide AsyncClose for TcpStream {
type Args = void;
fn close(*self, io: *Io, _: void): !void {
try self.flush(io); -- drain send buffer
try self.sendFin(io); -- TCP FIN
try self.awaitFinAck(io);
os.close(self.fd);
}
}
fn handleRequest(io: *Io, stream: TcpStream): !void {
defer stream.deinit(); -- safety net: always closes
try sendResponse(io, &stream);
try stream.close(io, .{}); -- graceful: flush + FIN
-- defer runs but fd already closed: no-op
}
Scope-Tagged Pointers
Decision: Pointers can carry a scope tag *s T indicating they point into memory managed by scope s. Scope-tagged pointers cannot escape their scope.
Reason: Lifetimes (Rust) are the complete solution to pointer safety but carry enormous cognitive cost. Scope tags are a simpler subset: they only track whether a pointer outlives its allocation scope, not aliasing or borrowing. They eliminate dangling pointers for stack and arena memory without requiring lifetime annotations on every function signature.
scope arena_scope {
var arena = ArenaAllocator::new(alloc, 4096);
defer arena.deinit();
-- pointer tagged with arena_scope
var data: *arena_scope []ParseResult = try parse(arena.allocator(), input);
process(data); -- valid: data is in scope
-- compile error: cannot return data, it escapes arena_scope
return data;
}
-- arena freed, all *arena_scope pointers dead
-- functions can accept tagged pointers with scope bounds
fn processInScope(data: *s []ParseResult) requires s: 'static {
-- data must be in a scope that outlives the function
store(data);
}
-> Binding In Conditions — Optional Unwrap And Flow Typing
Decision: expr -> binding in a condition means “if expr has a value, bind it to binding and enter the block.” Works for optionals, results, and while loops.
Reason: Rust uses if let Some(x) = expr which is readable but verbose. Violet’s -> is consistent across if, while, and pattern guards. The binding creates a refined type inside the block — ?T becomes T, !T becomes T — which is flow typing rather than shadowing (the outer binding ceases to exist inside the block).
-- optional unwrap
if findUser(id) -> user {
debug.print("found: {}\n", .{user.name});
}
-- result unwrap
if tryConnect(host) -> conn {
defer conn.deinit();
try send(&conn, data);
}
-- while loop: continue while value present
while queue.dequeue() -> item {
process(item);
}
-- nested: outer ?T becomes T, inner access is typed
var outer: ?Config = loadConfig();
if outer -> cfg {
-- cfg: Config (not ?Config)
debug.print("host: {}\n", .{cfg.host});
}
-- chained
var result: ?i32 = getValue();
var text = if result -> n {
if n > 0 then "positive" else "non-positive"
} else {
"absent"
};
unassigned — Compile-Time Initialization Tracking
Decision: unassigned marks a variable as not yet set. The compiler tracks which branches assign it and errors if a read is possible before assignment. Distinct from #undefined (programmer asserts it will be set) and .None (a valid runtime value).
Reason: The pattern “accumulate into an optional that starts unset” is common in loop-based code. .None works but conflates “not yet computed” (a programming state) with “no result” (a domain state). unassigned makes the compile-time tracking explicit, enabling the compiler to verify the assignment happens on all paths before return.
fn firstPositive(items: []const i32): ?i32 {
var result: ?i32 = unassigned; -- not yet set
for item in items {
if item.* > 0 {
result = item.*;
break;
}
}
-- compiler verifies: result is assigned on the break path
-- or still unassigned (which maps to .None for ?i32)
return result;
}
-- accumulation pattern
fn sumIfAny(items: []const i32): ?i32 {
var total: ?i32 = unassigned;
for item in items {
total = if total -> t { t + item.* } else { item.* };
}
return total; -- .None if items was empty
}
Memory Domain Tags — Topology-Aware Allocation
Decision: Allocators carry a memory domain type parameter. Pointers inherit the domain of their allocator. Cross-domain access requires explicit DMA transfer.
Reason: Modern hardware has multiple memory regions with different performance characteristics: CPU DRAM, GPU HBM, NPU scratchpad, CXL-attached memory, persistent NVM. Current allocator models are topology-blind. Domain tags make memory placement explicit at the type level, allowing the compiler to catch accidental cross-domain access and enabling accurate performance reasoning.
type MemoryDomain = enum {
CpuLocal,
GpuVisible,
GpuLocal,
NpuScratchpad,
Persistent,
};
-- allocators carry their domain
interface TopologyAllocator {
type Domain;
fn alloc(*self, size: usize, align: usize): !*[Self::Domain]u8;
}
-- pointers carry their domain
var cpu_buf: *[CpuLocal]f32 = try cpu_alloc.alloc(f32, 1024);
var gpu_buf: *[GpuLocal]f32 = try gpu_alloc.alloc(f32, 1024);
-- GPU kernel: must receive GpuLocal memory
fn matmul(
a: *[GpuLocal]f32,
b: *[GpuLocal]f32,
c: *[GpuLocal]f32,
n: usize,
): void { ... }
matmul(cpu_buf, gpu_buf, gpu_buf, 1024); -- compile error:
-- a: expected *[GpuLocal]f32
-- got *[CpuLocal]f32
-- explicit DMA transfer at domain boundaries
try dma.transfer(cpu_buf, gpu_buf, 1024 * 4, io); -- required, explicit
matmul(gpu_buf, gpu_buf, gpu_output, 1024); -- now valid
expand fn / @expand — Comptime Code Generation Via Emit
Decision: Interface implementations can be generated by expand fn generate(comptime T: type) functions that emit[] provide blocks. @expand(Interface) on a type declaration calls Interface::generate(T).
Reason: Rust’s proc macros are a separate compilation phase, a separate crate dependency, and a different programming model. Zig proves that comptime is sufficient for structural code generation. Violet’s expand fn is comptime code that emits interface implementations — no separate language, no token streams, just the same language operating on type information and emitting declarations.
interface Debug {
fn format(*const self, writer: *Writer): void;
expand fn generate(comptime T: type) {
const info = #typeInfo(T);
case info.repr {
.Struct { fields } => {
emit[fields, T, info] {
provide Debug for T {
fn format(*const self, writer: *Writer): void {
writer.writeStr(info.ident);
writer.writeStr(" { ");
inline for field, i in fields {
inline if i > 0 { writer.writeStr(", "); }
writer.writeStr(field.name);
writer.writeStr(": ");
inline if #implements(field.type.*, Debug) {
Debug.format(&#fieldGet(self.*, field.name), writer);
} else {
writer.writeStr("?");
}
}
writer.writeStr(" }");
}
}
}
},
_ => #compileError("Debug::generate requires a struct"),
}
}
}
-- derive at the type declaration
type Point = struct { x: f32, y: f32 } @expand(Debug);
-- generated: provide Debug for Point { fn format... }
var p = Point { .x = 1.0, .y = 2.0 };
var writer = Writer::new();
p.format(&writer); -- Point { x: 1, y: 2 }
Deliberately Rejected
Garbage Collection
Reason: GC introduces unpredictable pauses, hidden memory overhead, and prevents use in environments without a runtime (embedded, kernels, real-time systems). Violet’s target domains all require explicit memory control.
Borrow Checker
Reason: The borrow checker is correct and powerful but has high learning cost and prevents valid programs that humans can verify are safe. Violet uses a simpler subset: linear types (you must clean up) and scope tags (pointers can’t outlive their allocation). These catch the most common bug classes without tracking aliasing.
async fn / Coloured Functions
Reason: Async function coloring splits the ecosystem and makes reasoning about lock safety harder. The explicit *Io parameter solves both problems: suspension is visible at every call site, and libraries are naturally scheduler-agnostic.
Implicit this In Methods
Reason: Pascal and Zig both require explicit self-reference. Violet’s *self, *const self, self receivers are explicit about mutability and ownership. This prevents the confusion of “does this method mutate?” that implicit this creates.
== For Equality
Reason: The = vs == confusion in C is a known footgun. Violet uses := for reassignment, freeing = for equality. No == exists, no confusion is possible.
String As A Primitive
Reason: Strings are not primitive — they’re a sequence of bytes with encoding rules. Treating them as primitive hides encoding questions. Violet has []const u8 (bytes), char (Unicode scalar), String (owned UTF-8), and StringView (borrowed bytes) as distinct types that make encoding explicit.
Implicit Deref Coercions
Reason: Rust’s Deref coercions make method chains work across smart pointer boundaries (box.method() silently becomes (*box).method()). This is ergonomic but opaque. Violet requires explicit box.*.method(). The extra two characters are worth the clarity.
Global Allocator
Reason: A global allocator is a hidden dependency. Any function could allocate. Any function could fail due to OOM. Testing with a different allocator requires patching the global. Violet’s explicit allocator parameters make allocation visible and substitutable.
is not As A Two-Word Operator
Reason: not (x is T) is clearer and uses not consistently. is not introduces a two-word operator that creates parsing edge cases and has no benefit over the explicit form.
Variadic Functions
Reason: C’s variadics are type-unsafe — printf format string mismatches cause crashes. Violet uses .{args} struct literals for variadic-like calls, which are comptime-typed and fully checked. The .{} convention is used consistently everywhere.
Reference Captures In Closures
Reason: Reference captures require lifetime tracking to prevent dangling references. Without a borrow checker, Violet cannot verify that a captured reference outlives the closure. Move captures and Arc for shared ownership cover all practical use cases safely.
These decisions form a coherent whole: explicit over implicit, visible over hidden, simple invariants over complex proofs. Every choice traces back to one of Violet’s core commitments: you should be able to read any piece of Violet code and understand exactly what it does, how much it costs, and where the memory comes from.