vyrn

Coming from another language

What carries over from Rust, TypeScript or Go, what is deliberately different, and what Vyrn does not have at all.

Validation

The thing that is most likely to be new is where a rule lives. In the languages beside it a rule is code somebody has to call: a constructor, a schema object, a validating function. The type says nothing about whether it ever ran. In Vyrn the rule is part of the type, so Age is a number that has passed it.

The compiler proves what it can while the program compiles. So Age(30) costs nothing at run time, and Age(5) is a compile error rather than a failed check. Input from outside the program is not a constant, so it takes the total form. Age?(n) answers Option<Age>, and no path produces an unchecked Age.

type Age = Int64 where value >= 18

let ok = Age(30)        // no runtime check is emitted
let maybe = Age?(input) // Option<Age>, total over any input
pub struct Age(i64);

// The rule lives in a constructor beside the type, so a caller can still build
// the newtype the long way, and no constant is checked while it compiles.
impl TryFrom<i64> for Age {
    type Error = ();
    fn try_from(v: i64) -> Result<Self, ()> {
        if v >= 18 { Ok(Age(v)) } else { Err(()) }
    }
}
import { z } from "zod";

// The rule lives in a schema object beside the type. The type is erased before
// the program runs, so the check is a library call and never free.
const Age = z.number().int().min(18);
type Age = z.infer<typeof Age>;
const maybe = Age.safeParse(input);
type Age int64

// The rule lives in a function the caller must remember to call. Nothing in the
// type system says an Age was ever validated.
func NewAge(v int64) (Age, error) {
    if v < 18 {
        return 0, errors.New("below 18")
    }
    return Age(v), nil
}

Ownership

Rust says this with borrows and lifetimes, TypeScript cannot say it at all, and Go says it with a pointer and a comment. Vyrn writes the intent on the parameter as a word: read, modify, consume.

There is no lifetime grammar to learn, because the word is the whole declaration — the compiler decides where the value is released from what the words say. Ownership, consume, regions is the chapter that teaches it.

fn seatCount(t: read Ticket) -> Int64 { return t.seats }
fn addSeat(t: modify Ticket) { t.seats = t.seats + 1 }
fn redeem(t: consume Ticket) -> Int64 { return t.id }

// No lifetime, no borrow annotation, no collector. The word is the contract.
fn seat_count(t: &Ticket) -> i64 { t.seats }
fn add_seat(t: &mut Ticket) { t.seats += 1 }
fn redeem(t: Ticket) -> i64 { t.id }

// The same three intents, spelled as reference kinds. A lifetime appears the
// moment a reference is stored or returned.
function seatCount(t: Readonly<Ticket>): number { return t.seats; }
function addSeat(t: Ticket): void { t.seats += 1; }
function redeem(t: Ticket): number { return t.id; }

// Readonly is erased before the program runs, so nothing enforces it then. The
// collector decides when the object goes.
func seatCount(t Ticket) int64 { return t.seats }
func addSeat(t *Ticket) { t.seats++ }
func redeem(t Ticket) int64 { return t.id }

// A pointer says "may change it" and "may escape to the heap" with one symbol.
// The collector decides the rest.

Errors

There is no null and no exception. An absent value is Option, a failure is Result, and ? hands a failure to the caller unchanged — the same three shapes Rust uses, without the conversion traits.

A panic is for a case that cannot happen. Nothing unwinds: there is no catch anywhere in the language, so a panic ends the program.

fn seat(t: read Ticket) -> Option<Int64>

match seat(t) {
    Some(n) => n,
    None => 0,  // there is no null to forget, and no arm to leave out
}
match seat(&t) {
    Some(n) => n,
    None => 0,
}

// Exhaustive too, and the closest of the three. `unwrap()` is one call away,
// and it panics.
const n = seat(t);
if (n === undefined) { return 0; }
return n;

// null and undefined are two different absences, and whether the compiler
// checks either is a flag in a config file.
n, err := seat(t)
if err != nil {
    return 0
}
return n

// Nothing in the type forces the check. Skip it and read n anyway, and the
// zero value is a plausible answer.

Records and enums

A record is a struct with named fields and no inheritance. An enum is a closed choice with payloads, and match covers every case. The compiler counts the arms, so a new variant is a compile error at every site that reads it.

Generics are monomorphised, protocols are how a type says what it can do, and a protocol bound is checked at the call rather than at run time. Records are structural, as in TypeScript: a record with the same fields, or more, fits where a record type is wanted.

What Vyrn does not have

No garbage collector. Memory is released where the ownership words say it is, and the editor's hover shows that decision per binding.

No exceptions, no unwinding, no async colour. A program is a program; the runtime that serves HTTP is a library written in the language.

No macros. A generator is an ordinary Vyrn function that runs while you compile and emits Vyrn — vyrn emit-gen prints exactly what it wrote. gen fns and generators is the chapter.

No inheritance and no null. Both are covered above, and neither has a workaround to learn.

Next steps

If you write Rust: read Ownership first — it is the chapter where this language differs from yours the most, and it is shorter than you expect.

If you write TypeScript: read Values and types and then Errors. Numbers state their width here, and validation is a type rather than a library.

If you write Go: read Records, enums, match — the closed enum and exhaustive match are what replace the interface-and-type-switch pattern.

The benchmarks answer the other question a reader arrives with, and every reading there is published.