Vela Future Directions

A design notebook of weak points, safe improvements, and bold ideas for where Vela could go next.

Vela is intentionally small today, but every small language has a horizon. This document collects honest weak points, practical improvements, and deeper design questions — especially around memory safety — that could shape the next version of Vela. Two shorter lists sit beside it: Safety, the concerns about one name seeing a change made through another, and May Waste, what Vela has that may not earn its place.

Current Weak Points

These are not failures; they are the natural limits of a first compiler. Naming them is the first step toward fixing them.

Poor error locations Done

Fixed: every syntax node now carries a source span, and errors are reported as file:line:col with the offending line and a caret.

Manual memory risks Done

Fixed: new/free allowed leaks and use-after-free, just like C. Regions replaced them: memory is freed a region at a time, and pointer types name their region, so no pointer outlives its memory. Details below, and the full guide in Vela Regions.

No standard library High

Started: Vela's library comes with the compiler, brought in with include std/files, and its first module reads, writes, renames, and deletes files, each opened in a region that closes it. Networking, math, and text search are still to come. Before it, only show and scan (which reads a line of input, or a number from it) exist, with text's own operations (parts, joining, and values as text). Numbers convert with float(n) and int(x), which read text too; beyond files there is no networking or math module, and text has no search or trimming.

No module system Done

All code had to live in one file. Now a file includes another with include; there is still no way to share packages between programs. Details below.

Limited types Medium

Tasks and shapes now take types (task first<T>(xs: [T]@r), shape Box<T>, details below); a kind can't yet. Tuples ((text, int), whose items never change, Tuples), arrays ([int#4]), and slices ([int]@r, which grow with xs.add(v) and shrink with xs.del(i)) are all done (details below), and missing values and errors have types: maybe T and T ~ E, and a pointer can't be none.

No methods Done

Behavior lived in free tasks such as area(s): all tasks in a file share one namespace, so two shapes of one file couldn't both have an area, and a task could only change a shape through a ^ pointer. Now a shape or a kind has methods, c.total(), and one marked alters changes the value it is called on. Details below.

No concurrency Medium

No threads, channels, async, or actors. Vela is strictly single-threaded today.

Text is a C string Done

text was a C string, const char*, in which a \0 ended the text early. It is now its bytes and how many there are: t.len reads the length without counting, and a \0 is a byte like any other. Parts, t[a..b], are views of the same bytes, and < orders text by them. Joining, a + b at r, makes new text in a region. A value becomes text with n.str() at r or text(n) at r, and text holds values written inside it, "{name} is {age}" at r (see Text holding values, and Real text type). Text has a lifetime in its type: text lasts as long as the program, text@r is in a region (Text in a region), which reading input (scan) uses, and building text will need.

Undefined integer edge cases Done

Integer overflow and division by zero behaved as in C: undefined. They now stop the program with a message, the same under every C compiler (the language guide).

One error at a time Done

The compiler stopped at the first error, so fixing a file took one compile per mistake, and Vela Studio showed one squiggle. It now reports every mistake a file has, in the order they are written: each declaration and each line is checked whatever the ones before it found, a mistake leaves the type it could not work out standing as no type at all, and nothing about that type is a mistake again, so one mistake never turns into a page of them. The vela command shows at most 20 at a time and counts the rest; Studio marks them all (see Current Limits).

C backend dependency Low

Generating C is simple but requires a C compiler. An LLVM or native backend would be cleaner long-term.

Safe & High-Impact Improvements

These changes improve Vela without radically changing its character. They are the lowest-risk next steps.

Source spans Done

Attach line/column info to every AST node so errors point to the real problem.

A complete checker Done

Block scopes, declaration order, unknown types, none, nothing, and a send on every path are all checked by Vela, so mistakes no longer surface as C compiler errors.

Portable C output Done

Generated C is ISO C11, tested warning-free with gcc and clang on every test program and example, and any name that means something in C still works in Vela. (Code the C compiler finds suspicious, such as x == x, or text longer than C's 4,095-byte limit for a literal, can still draw a warning in its strictest modes.)

Vela Studio Done

A small editor for Vela with highlighting, completion, live errors, one-key run, and a terminal, built on the compiler itself. See Vela Studio.

Multiple results Done

A task sends several values, -> int, int and <- a / b, a % b, received with fixed q, r = divmod(17, 5). The language guide describes it; the reasoning is below.

Named results Done

Results with names, -> total: int, set along the way with total <- value; a bare <- or the end of the task sends them. The language guide describes it; the reasoning is below.

Built-in arrays & slices Done

Arrays [int#4] held in place, and slices [int]@r that view items in a region, with every index checked. The language guide describes them; the reasoning is below.

Methods Done

Methods written inside a shape or a kind, below its fields: .count and .cap() for the value's members, .this for the value, @this for its region, and alters for a method that changes it. The language guide describes them; the reasoning is below.

Real text type Done

Replace C strings with a (ptr, len) struct and safe operations. The struct is in: t.len is a field, and a \0 no longer ends text. So are parts, t[a..b], which never cut a character, < on text, and joining, a + b at r, values as text, n.str() at r or text(n) at r, and text holding values, "{name} is {age}" at r. A shape or kind writes its values with a str of its own, which show(card) calls in a region it makes for the line. The language guide describes them.

Regions Done

region r, new T(v) at r, and pointer types ^T@r that can't outlive their region, instead of free. A block, or a task through r: region default, can name a default region, which the allocations that name none use. Details below; ideas for making them easier to use are in Vela Regions.

Maybe & errors Done

maybe T and T ~ E, read with match, is, or a plan B (x { 0.0 }); pointers can no longer be none. Details below.

Pattern match expressions Done

A match, and a when, can be the value itself: a case gives it with case p = value or on the last line of its block, as a plan B's block does. The language guide describes it.

Module system Done

A file is a module: include shapes/geometry, then geometry.area(…), or include shapes/geometry (area), or only the names with braces; => renames a module or a name; everything is public unless marked priv. The language guide describes it; the reasoning is below.

Built-in testing Idea

A lightweight test block or assertion syntax for confidence.

Move-only pointers Idea

An own ^T@r that moves instead of being copied, so a single value can be dropped from its region early. Details.

Multiple and Named Results Done

A task used to send at most one value. A task that has two things to report, such as a quotient and a remainder, or a value and whether it was found, had to declare a shape only to carry them, or be split into two tasks that repeat the same work. Two small, related features fix that, and they came first among the language ideas because they are the cheapest: neither needs a new type, a runtime, or a change to existing programs. -> int and <- value keep their meaning.

Step 1: Multiple Results Done

Implemented; Several Results in the language guide is the reference, and what follows is the design it was built from. The return type after -> is a list of types, and <- sends one value per type:

task divmod(a: int, b: int) -> int, int
    <- a / b, a % b
fi

task main()
    fixed q, r = divmod(17, 5)
    show(q)                      ; 3
    show(r)                      ; 2

    fixed whole, _ = divmod(9, 4)  ; _ skips a result
    show(whole)                  ; 2
fi

Step 2: Named Results Done

Implemented; Named Results in the language guide is the reference, and what follows is the design it was built from. Each result can have a name, written like a parameter: name: type. A named result is a variable of the task, and it is set with the same arrow that sends results. What stands before the arrow says what happens:

Header Line Meaning
-> int <- value Send the value and end the task (today's Vela)
-> int, int <- a, b Send both values and end the task
-> total: int total <- value Set the result and keep going
-> total: int <- value Send that value and end the task
-> total: int <- Send the result as it is and end the task
-> total: int the closing fi Reaching the end sends the result

So an empty left side means "send and stop", and a name on the left means "this is the result so far":

task score(hits: int, misses: int) -> total: int
    total <- hits * 10
    when misses == 0
        <- total + 50            ; a perfect round: send the bonus score and end here
    fi
    total <- total - misses * 5  ; otherwise keep going
    when total < 0
        total <- 0
    fi
fi                              ; reaching the end sends total

When misses isn't zero, score runs to its last line, updating total each time it reaches a total <- line, and the end sends whatever total holds. A perfect round stops at the inner <- instead, and total + 50 is sent. Setting a named result never ends the task. With several names:

task collatz(start: int) -> steps: int, peak: int
    flex n = start
    steps, peak <- 0, start
    loop n != 1
        when n % 2 == 0
            n = n / 2
        else
            n = 3 * n + 1
        fi
        steps <- steps + 1
        when n > peak
            peak <- n
        fi
        when steps == 1000
            <-                  ; give up and send what we have
        fi
    fi
fi

task main()
    fixed steps, peak = collatz(27)
    show(steps)                 ; 111
    show(peak)                  ; 9232
fi

Named results follow Go's, with Vela's arrows and Vela's rule that every variable has a value:

Go Vela
Declare func score(…) (total int) task score(…) -> total: int
Set and keep going total = x total <- x
Set and end return x <- x
End with the current values return <-
Reach the end Not allowed: a return is required Sends the named results
A result never set Sends the zero value Compile error

The rules:

💡 Why the arrow, and what it costs

Setting a result with = would work too, but total <- value keeps one symbol for "this is what the task sends", with the name telling whether it stops there. It also costs the parser almost nothing:

  • Parser: the lexer already reads <- as one token. A line that starts with a name, or names separated by commas, followed by <- is a result update; an <- anywhere else in a line is still an error. That is a few tokens of lookahead in parse_stmt, the same as telling x = 1 from x + 1. The header's -> list reads names the way parameter lists already do.
  • Checker: result names join the task's scope; "set on every path" reuses the existing send-on-every-path analysis; the mid-line error for a <-1 keeps its hint to write < - when a isn't a result.
  • Generated C: named results are locals declared at the top of the function; every <- that ends the task, and the end itself, returns them (in the result shape when there are several).
  • Vela Studio: hover on a result name shows result total: int, completion at the start of a line offers total <- , and the outline shows the names in the signature. (All three are in place.)

Multiple results came first, since named results reuse their header list, send list, and C result struct; names went on top of them. What step 1 settled: a call that sends several values can only be the whole right side of a list (fixed a, b = f(), a, b = f(), <- f()), and those values are taken as they are, since there is no single expression to wrap; every value of a list is computed before the first is stored, so a, b = b, a swaps; and all the tasks whose results have the same types share one C struct, whose members are r0, r1, …, so sending them on is a plain return.

What step 2 settled: a line-start name(, name)* <- is a result update, and the parser reads it without knowing the task's results, so the checker gives the message ('count' isn't a result of this task; assign it with 'count = …'); a <- anywhere else in a line is still an error with its < - hint. "Set on every path" is a walk over the body that keeps the results not yet set: a branch that leaves the task contributes nothing to the join, a loop's body sets nothing for certain since it might not run, and the same state answers reads, bare <-s, and the end of the task. A named result is a C local, zero-initialized so that a gap in that analysis would still read a defined value, and reaching the end of the task returns them. A nothing ~ E task succeeds by reaching its end, so its result has no value to name.

Modules Done

A program used to be one file. Now each file is a module, and a file includes another with include (see Modules in the language guide):

include shapes/geometry           ; shapes/geometry.vela, named geometry
include shapes/palette (Color, describe)
include fuel/prices => fuel        ; another prices.vela, under another name
include shop/prices {Item => Product, price_of}   ; only these names

task main()
    show(geometry.area(geometry.Square(3.0)))
    show(describe(Color.Red))         ; a variant through its kind
    show(price_of(Product.Apple))
fi

; in shapes/geometry.vela
priv task pi() -> float           ; only this file can call it
    <- 3.14159
fi

What building it settled. Names. Every task, shape, kind, and variant gets one name in the program: its own in the main file, and its module's path from the main file's folder with the name in any other (shapes/geometry.Point), so two util modules in different folders are two modules. (At first a module was named by the path's last part, and two utils were an error; aliases made that rule unnecessary, and the path made it wrong.) A message is spelled for the file it is in: each of the program's names in it is written as that file writes it (expected type Product, got int), or by its path when the file can't write it. In C, the . becomes __0__, each / __1__, and each .. __2__ (vela_shapes__1__geometry__0__area), none of which a Vela name can contain. Case forms. A case names a variant alone even for another module's kind, since the matched value's type says whose it is, or through its kind (case Size.Large extra), which must be the value's; case geometry.Square (a module, then a variant) is an error that says so. A kind listed in include m (…) doesn't bring its variants; they are listed on their own or written through the kind. Because Size.Small reads like a field, a variable can't be named like a kind, as it can't be named like a variant or a module. Visibility. A module sees only what it declares and includes, never the main file's names. What other files see of a public item (a task's parameters and results, a shape's public fields, a kind's values) can't mention a private type, since they couldn't name it. Errors. Include cycles, a missing file, a module included twice, two modules under one name, a module or alias named like a builtin, an alias of a variant, and a name that clashes with a name the file declares, brings in, or binds are all errors at the include or the name; a name hidden by an alias or by braces is an error that says what the file writes instead; an error in another module names that file. Studio. Vela Studio follows include lines as you type: the page gives the analysis the text of the files a program includes (an open file as edited), and the analysis says which it still needs; an error in a module is shown at the include that leads to it. The server builds a program's modules from the workspace only, so an include can't read a file outside it. Hover, completion, and highlighting follow aliases, braces, and Kind.Variant, and name things as the file does. Packages shared between programs are left for later.

Arrays and Slices Done

The first step out of the limited types; tuples came next (Tuples), and then tasks that take types (below), each a step of its own. An array is a set number of values held in place, and a slice is a view of values in a region (see Arrays and Slices in the language guide):

fixed scores: [int#4] = [90, 72, 85, 60]
each score in scores
    show(score)
fi
region r
fixed all = new [int]([0#n]) at r     ; [int]@r: n zeros, n known as the program runs
fixed part = all[1..3]                 ; the same memory, not a copy
part[0] = scores.len
flex names = new [text]() at r        ; no items yet
names.add("b")                        ; last
names.add(0, "a")                     ; before item 0
show(names.del(1))                    ; takes item 1 out: b

What building it settled. Values. Arrays are copied whole, like shapes, and each goes through an array as it was when the loop started (a slice's items as they are, up to the count it had then). A literal's values join into one type: none and a value make a maybe, fail e and a value a T ~ E, and a literal of only none takes its type from where it goes (fixed slots: [maybe int#4] = [none#4]). Checks. Every index and slice is checked as the program runs and stops it with a message saying where, as integer overflow does (vela: index 4 is out of range 0..4 at 7:12 in task 'main'). A constant index or bound out of range, a count below 1, and a type whose values would take more than 1 TiB are errors when compiling, the last so that every program the compiler accepts also compiles as C. Places. An array's item can be assigned where a field could be (in a flex, or behind a pointer reached from one); a slice's items can be written where the slice is reached from a flex name, as through a pointer; neither's len can. An array behind a pointer, or an item of a slice, is in a region, so it can be sliced. C. An array is a struct holding a C array, which C can copy and return; a slice is a struct of a pointer, an int64_t length, and its room. Reading a slice's item, slicing, copying, and filling go through small generated functions, so each array or slice is evaluated once. Studio. Hover shows the types ([int#4], [int]@r, and an each item's), and completion offers len after a . (and add and del on a slice) and types inside a type's brackets, as the file is typed.

What growing settled. Where. A change is written to the slice where it is kept, so the slice must be in a place that can be assigned (a flex variable, a field of one, anything behind a pointer reached from one, an item of such a slice), and add needs the slice's region itself (declared, or an r: region parameter), as new does. Room. A slice carries a pointer to its room: a block of its region holding its items and free space after them. A full block is replaced by one twice as large, or grows in place when it ends where the region hands out memory next, so adding is amortized constant time, and a slice of n items has taken at most about 4n items of memory in its region. Only a slice whose items end where the room's free space begins adds in place, so adding to a copy or a part of a slice doesn't write over items another one counts; taking out the last item gives its space back, to the next add of that slice, even when an older copy still counts that item (as moving items in the middle is seen by the copies). Order. A change inside an expression happens in order with the rest, left to right, since xs.del() - xs.del() must mean one thing. Speed. In a test of 20 million adds (gcc -O2), one into room took about 2 nanoseconds, close to a hand-written C array; the whole run took about 5 times as long as C with realloc, because doubling copies the items into new memory that must be touched, where realloc of a large block can move pages instead. Hand-written C that doubles by copying, as regions must, took about as long as Vela.

Left for later: comparing arrays with ==, showing them whole, slices with a bound left out (xs[1..]), and making a slice with room reserved ahead for a count known in advance. A slice that never reuses the space del gives back is a lower-priority idea. Shapes take types now (below), so a collection of anything can be written as one; only a slice grows and shrinks with add and del, which stay built in. A shape has methods now, but one can't yet grow the slice it holds, since a method isn't given its value's region (an idea below).

Types a Declaration Takes Done

The step after tuples: a task or a shape can take types as well as values, so one first works for a slice of anything and one Box<T> holds anything (see Types a declaration takes in the language guide). The types go in < > after the name, and a word after a type's : says what the body may do with its values:

task first<T>(xs: [T]@r) -> maybe T
    each v in xs
        <- v
    fi
    <- none
fi

task biggest<T: ordered>(a: T, b: T) -> T
    when a > b
        <- a
    fi
    <- b
fi

shape Box<T>
    value: T
fi

show(first(ns))                 ; 4 — the argument says T is int
show(first<text@r>(names))       ; Ada — or the call writes it out
fixed blank: maybe int = empty()   ; or the place the value goes says it
fixed count = Box { value: 1 }   ; Box<int>: the value given says which type
fixed kept: Box<text>@r = Box { value: name }   ; its text is in r
show(biggest(true, false))      ; error: task 'biggest' takes a type that is 'ordered' …

What building it settled. Inference. A call's types come from its arguments, from the place its value goes (a written type, a parameter, a field, a <-, or a plan B's value), or written out; when nothing says which type it is, the compiler asks for it rather than guessing. Checking once. Checking the body abstractly, with the words as the only thing known about a type, means an error in a generic task is reported once, at the task, rather than once per use — and that a use fails only on what the words promise. Studio. The types a task takes are colored as types, hover explains what their word allows, the outline lists them under the task, and completion offers them as types inside the task, the five words after a : or &, and types inside a call's < >. Limits. A call that gives a type built from its own (f<(T, int)> inside f<T>) would never stop, so a type more than 24 types deep, or a task used with more than 500 sets of types, is an error.

Shapes came next, in the same brackets and with the same words: shape Box<T>, shape Pair<A: frozen, B: frozen>, and shape Node<T> whose field names the shape again behind a pointer. The types given name no region, as a shape's fields don't (Box<text>@r, one shape for every region), and a literal works its types out from the values it gives (Box { value: 1 }) or names them (Box<int> { value: none }). One shape is generated per set of types, so Box<int> is a struct holding an int64_t.

Left for later (types for kinds are done): a word for arithmetic, which would let one task add numbers of either type; and working out the region a shape given types lives in, so that a task returning Box<T> needn't take a region for the uses whose type holds nothing in one.

Maybe, Plan B, and Errors Done

A value that might be missing, and a task that might fail, are the two most common sources of bugs that a type checker can rule out. C spells both with conventions (NULL, -1, errno) that nothing enforces. Vela now spells them with types, and gives three ways to get a value out, from the most explicit to the most compact. This is implemented; the language guide describes it in Maybe and Errors. This section keeps the reasoning behind the design. How an error result is written changes next: ~ in place of or, and the noun failure beside the verb fail (see Writing errors).

maybe

maybe T holds a T or nothing. A plain value becomes one wherever a maybe is expected (a typed binding, an assignment, an argument, a field, a send), and none is the empty one. There is no Some(…) to write.

task find(names: ^Names@r, wanted: text) -> maybe int
    …
    <- index                  ; found: the int becomes a maybe int
    …
    <- none                   ; not found
fi

fixed x: maybe float = 2.1
flex y: maybe float = none

No more null pointers

maybe exists to remove null mistakes, so none no longer fits a pointer type. A ^T always points to something: new already stops the program when memory runs out, every binding needs a value, and every shape literal gives every field. A pointer that may be empty is a maybe ^T, and like any maybe it must be checked before use: p^ on a maybe ^T is a compile error.

shape Item
    title: text
    next: maybe ^Item        ; the last item has no next
fi

Getting the value out

1. match, when both outcomes need real work:

match find(names, "vela")
case {index}
    show(index)
case none
    show("not found")
fi

{index} matches a value and names it; none matches nothing; _ matches whatever the earlier cases didn't, and works for kinds too. Cases stay exhaustive.

2. is, for "only when there is one", and for walking linked structures:

when find(names, "vela") is {index}
    show(index)
fi

flex current = first
loop current is {flex item}
    show(item^.title)
    current = item^.next
fi

is takes the same case forms as case, including a kind's variants (when light is Dimmed level), and its names are visible only in the block it guards and in the parts joined after it with and (when b is {inside} and inside < 0, built 2026-09-29; see the guide). Joining it with or, or writing it after not, would bind its names on one path only, so those are errors. Without is, removing null pointers would make every list walk check twice.

3. Plan B, the compact form: a block after a value runs only when there is no value, and its last line is the value to use instead.

fixed y = x { 0.0 }                 ; x's value, or 0.0
fixed z: float = y * 5.2

fixed port = config_port() {
    show("no port set, using 8080")
    8080
}

fixed scaled = x { 1.0 } * 2.2      ; works inside expressions

fixed first = head {
    <-                             ; nothing to do: leave the task
}

Errors: T ~ E

A task that can fail sends T ~ E: a T when it works, an E when it doesn't. The name Result was avoided because "result" already means what a task sends.

kind ParseError
    Empty
    BadDigit(position: int)
fi

task parse(digits: text) -> int ~ ParseError
    when digits == ""
        <- fail Empty
    fi
    …
    <- value
fi

task main()
    fixed n = parse("42") { 0 }            ; the error is ignored: use 0

    fixed m = parse("4x") {
        failure e                         ; names the failure for this block
        match e
        case Empty
            show("empty")
        case BadDigit position
            show(position)
        fi
        -1
    }

    match parse("7")
    case {value}
        show(value)
    case failure e
        show("failed")
    fi
fi

How it fits the grammar

💡 Next steps on top of this

  • Forwarding errors: a plan B that only sends the error on, failure e then <- fail e, will be common; a shorthand can come once real code shows its shape.
  • Match expressions can reuse plan B's rule, "the last line is the value", instead of a separate = value form per case. Done, with both: a block's last line gives the value, and case p = value writes a short one on the case's own line (A match or a when as a value).
  • Named results work inside plan B unchanged: total <- v updates, a bare <- ends.
  • Calling C: pointers coming from C functions should arrive as maybe ^T.
  • Dangling pointers and double free: the remaining pointer bugs, for the memory model. Done: regions.

Memory Management: The Big Question Decided

Vela's biggest open question was memory safety. There are several credible paths, each with trade-offs. Below are the main candidates.

Decided and implemented: Option B, regions, with named regions in types (^Node@r), the design sketched in the second notebook. free is gone: region r declares an arena freed when its block closes, new T(v) at r (or new@r T(v)) allocates in it, a task allocates only in a r: region parameter and can name any region its parameters' types show, and a shape holding pointers lives in one region (Node@r). The checker keeps every pointer from outliving its region. The reference is in Memory and Regions, and the long guide, with the model's limitations, workarounds, and ideas, in Vela Regions. The other options stay below, each with a note on how it could still grow the region model.

Option A: Rust-Style Borrow Checker

Track ownership, lifetimes, and borrows at compile time. Values have one owner; references are temporary and cannot outlive the data they point to.

Pros
  • Eliminates use-after-free, double-free, and data races
  • Zero runtime overhead
  • Proven at scale (Rust)
Cons
  • Massive implementation complexity
  • Steep learning curve for users
  • Fights against Vela's minimalism
Verdict: Probably too heavy for Vela. A full borrow checker would double or triple the compiler's size and change the language's feel.
With regions: a small part of it still fits. Read-only borrowed references would let younger data point at data in an older region, which regions alone can't allow (writing through such a pointer would not be safe), and let a task take a view it can't change. See Lifting today's limitations.

Option B: Region / Arena Allocation

Allocate everything into a region (arena) and free the whole region at once. Objects never outlive their region. The compiler checks that references never escape their region.

Pros
  • Very fast allocation and deallocation
  • Simpler than borrowing: just region scoping
  • Excellent for compilers, games, and request handlers
Cons
  • Objects cannot live longer than their region
  • Requires discipline about region boundaries
  • Long-lived data needs special treatment
Verdict: A strong fit for Vela. It is simpler than full borrowing and aligns with the language's explicit, minimal philosophy. Chosen and implemented; see Memory and Regions.

Option C: Automatic Reference Counting (ARC)

Every shared object carries a reference count. The compiler inserts retain/release calls, or the runtime does it. Swift uses this model successfully.

Pros
  • Easier to reason about than borrowing
  • Handles shared ownership naturally
  • Deterministic destruction
Cons
  • Runtime overhead from counting
  • Reference cycles need weak references
  • Not as zero-cost as borrowing or arenas
Verdict: Viable, but it adds runtime complexity. Less "systems-y" than arenas or ownership.
With regions: counting could be kept for the few values shared across a whole program, in a region of their own, so ordinary pointers never pay for it.

Option D: Linear / Move-Only Ownership

Every value has exactly one owner. You can move it, but you cannot copy or alias it unless you explicitly clone. There are no borrows at all — only moves.

Pros
  • Much simpler than a borrow checker
  • No aliasing means no use-after-free or double-free
  • Easy to implement and explain
Cons
  • Can be awkward: reading a field consumes the whole value
  • Need explicit clone for shared read access
  • Limited compared to full borrowing
Verdict: An excellent stepping stone. Start here, then add limited borrowing later if needed.
With regions: the most natural next step. A move-only own ^T@r can be dropped early, which is the one thing regions can't do, and could move between threads. See Freeing one value early.

💡 Recommended Path for Vela

The first step, regions, is done. Growing it into a hybrid of linear ownership and regions remains the direction, where real programs show the need:

  • Owned pointers ^T could be move-only, letting a single value be freed early without dangling copies.
  • Borrowed references &T are temporary and cannot be stored. They are valid only within the current scope/region.
  • Regions (explicit arenas) group allocations and free them in bulk. References cannot escape their region. (Done, and text has regions too: text@r.) Next for them, from Vela Regions: copies into another region (done: value.copy()), a default region per block (done: region r default), moving a whole region into another, easier region passing (push[nodes](…), with inferred region arguments), shapes with several regions (and text fields of their own region), tasks that take text of any region without naming it, and fixes for the cases the rules reject today.
  • No shared mutable state by default: either you own it mutably, or you borrow it immutably.

This gives Vela a memory model that is simpler than Rust, safer than C, and more flexible than pure arenas. It also keeps the compiler small and the syntax minimal.

Methods: Behavior for Shapes and Kinds Done

Vela keeps data and behavior apart: shapes and kinds hold data, and tasks act on it. That separation is a good default, but today it has real costs:

Whatever design Vela picks has to answer the same questions: where methods are declared, how they are called, how a method changes its receiver, how names are scoped, whether kinds get methods too, and how this leaves room for interfaces later. Below are the main candidates, each shown with the same example.

Option A: Go-Style Receiver Tasks

A task names its receiver before its own name. Methods can be declared anywhere, next to any other task. A pointer receiver lets the method change the value.

task (p: Point) length_squared() -> float
    <- p.x * p.x + p.y * p.y
fi

task (p: ^Point@r) shift(dx: float)
    p^.x = p^.x + dx
fi

fixed size = p.length_squared()
Pros
  • No new block or keyword: methods are still just tasks
  • Methods can live anywhere, and later in other modules
  • Proven and easy to explain (Go)
Cons
  • The receiver syntax task (p: Point) length_squared() is the most punctuation-heavy option
  • Pointer receivers need ^ values, or a new address-of operator that Vela avoids
  • A type's methods can be scattered across a file
Verdict: The right idea (methods are ordinary tasks attached to a type) with a syntax and a receiver model that fit Go better than Vela.

Option B: Rust-Style Implementation Blocks

A separate block gathers a type's tasks, with an explicit self whose mode says whether the method can change it. In Vela's words-first style the block could be extend Point.

extend Point
    task origin() -> Point
        <- Point { x: 0.0, y: 0.0 }
    fi

    task length_squared(self) -> float
        <- self.x * self.x + self.y * self.y
    fi

    task shift(flex self, dx: float)
        self.x = self.x + dx
    fi
fi
Pros
  • All of a type's behavior is in one place
  • A natural home for tasks without self, such as constructors (Point.origin())
  • Extends cleanly to interfaces later (extend Point as Printable)
Cons
  • A new keyword and block kind, and one more level of fi nesting
  • A type is now described in two places: its shape and its extend
  • More machinery than a small language strictly needs
Verdict: Well organized and future-proof, but heavier than Vela needs today. Its self / flex self receiver modes are worth keeping whichever syntax wins.

Option C: Methods Inside the Shape

Tasks are declared in the shape's own block, as in Swift structs, Kotlin, or C++ classes. Fields are in scope inside them.

shape Point
    x: float
    y: float

    task length_squared() -> float
        <- x * x + y * y
    fi
fi
Pros
  • Maximum discoverability: data and behavior side by side
  • Short method bodies, with fields in scope
  • Familiar to most programmers
Cons
  • Shapes become classes, against Vela's "no classes and structs" philosophy
  • Implicit field scope makes it unclear whether x is a field or a variable
  • Types can't gain methods from elsewhere, such as another module
Verdict: This is the one Vela took, with the second objection answered: a member is written with a dot in front of it, .x, so nothing is in scope implicitly and a plain x still means a variable or a task. See what Vela does below.

Option D: Uniform Call Syntax

No new declarations at all: p.length_squared() is simply another way to write length_squared(p), as in D and Nim. Every existing task becomes callable with a dot.

task length_squared(p: Point) -> float
    <- p.x * p.x + p.y * p.y
fi

fixed a = length_squared(p)
fixed b = p.length_squared()     ; the same call
Pros
  • The smallest change: only the checker learns to rewrite the call
  • Existing programs gain dot calls for free
  • Chains read left to right: list.first().title
Cons
  • Doesn't solve the shared namespace: there is still only one area
  • Solving that requires overloading by first parameter, which complicates name lookup
  • Doesn't solve changing a value in place
Verdict: Nice sugar, but it fixes the least important of the three problems.

Option E: Qualified Tasks

A task declared with its type's name in front, task Point.length_squared, belongs to that type. It reads exactly like the call, needs no new keyword or block, and takes Rust's explicit receiver modes.

task Point.origin() -> Point
    <- Point { x: 0.0, y: 0.0 }
fi

task Point.length_squared(self) -> float
    <- self.x * self.x + self.y * self.y
fi

task Point.shift(flex self, dx: float)
    self.x = self.x + dx
fi

flex p = Point.origin()
p.shift(2.5)
show(p.length_squared())
Pros
  • Methods stay ordinary tasks; the only new syntax is Type. before the name
  • Each type gets its own namespace: Circle.area and Rect.area coexist
  • flex self changes a flex variable in place without an address-of operator
Cons
  • Like Go, a type's methods can be spread across a file (a formatter or outline can group them)
  • No block to hang interface implementations on; those need their own design
  • Needs a rule for a method and a field with the same name
Verdict: The best fit. It keeps Go's "methods are just tasks", borrows Rust's receiver modes, and reads the way it is called.
Option New syntax Per-type names Change in place Fits Vela
A. Receiver tasks Receiver before the name Yes Only through ^ Partly
B. extend blocks Keyword + block + self Yes flex self Heavy
C. Inside the shape Tasks in shapes Yes alters Chosen
D. Uniform calls None No Only through ^ Partly
E. Qualified tasks Type. + self Yes flex self Best

💡 What Vela Does

Methods go inside the shape or the kind (option C), with every member written after a dot. The dot is what makes the option work: it answers the "is x a field or a variable?" objection outright, and it needs no new receiver syntax, no second block, and no word before the name.

shape Counter
    label: text
    count: int

    total() -> int
        <- .count
    fi

    alters step()
        .count = .count + 1
        .cap()
    fi

    priv alters cap()
        when .count > 100
            .count = 100
        fi
    fi
fi

flex c: Counter = Counter { label: "clicks", count: 0 }
c.step()
show(c.total())
  • Fields first, then methods. A shape's or a kind's block reads as its data and then its behavior; a field after a method is refused.
  • No task before a method. Inside the block, a name followed by ( is a method and a name followed by : is a field, so the word would say nothing.
  • A member is written with a dot: .count is the field and .cap() the method, while count and cap() still mean a variable and a task. Nothing is in scope implicitly, so no rule is needed for a field and a variable of one name.
  • alters marks a method that changes the value it is called on. A method without it that assigns a field is refused, with the fix named. Only a changeable place can call one (a flex variable, a field of one, an item of a slice, or p^): a fixed value never changes, and neither does a parameter, so c.step() on either is refused. The compiler passes the place's address in the generated C, and Vela code never sees a pointer.
  • .this is the whole value. A kind has no fields, so its methods read it with .this and take it apart with match .this; an alters method of a kind moves the value to another variant with .this = Other. A shape's methods have it too.
  • @this is the region the value lives in. A method may send what the value holds there, nameOf() -> text@this, and take more of it, alters keep(p: ^Node@this); each call works the region out from the value, so one method serves every region. A method is given the value but not its region, so it can't allocate there (see below); one that makes something to send back takes a region parameter, as a task does.
  • Names: a type's fields and methods share its namespace, so a method can't reuse a field's name; method names clash with nothing outside the type. A method can't be called add, del, copy, or len, which every value or slice already answers to.
  • No automatic dereference, as with fields: a method of a pointer's target is called p^.total().
  • priv marks a method only its own file may call, as it marks a field.
  • Shapes that take types: a method of shape Box<T> names no types of its own, and T in it stands for the type the value is given, as it does in the fields. One method is made for each shape the compiler makes (Box<int>, Box<text>), as a task that takes types is today, and a word the shape asks for (shape Shown<T: shown>) holds inside every method of it, so a body may rely on it without saying so again.
  • Generated C stays collision-free: vela_Counter__on__total(vela_Counter vela__this) and vela_Counter__on__step(vela_Counter* vela__this), names no Vela program can write.

What option E offered and this design does not: a task of the type itself, Point.origin(), and methods spread across a file. Both are below, with the rest of the room this leaves. Settle any region shortcut that uses a dot, such as nodes.push(list, 1) (see Easier region passing), against this design, since both give meaning to value.name(…); a slice's built-in changes, xs.add(…) and xs.del(…), already do, and methods keep them.

Room to grow

Other Design Directions

Memory is not the only place Vela can grow. Here are ideas across the whole language.

Syntax & Ergonomics

Defined Behavior

Type System

Macros that take values

A macro names one value today, #SIZE = 4 (Macros). C's #define MAX(a, b) takes values too, and so could Vela's, written as a task whose name has the macro's # before it, task #NAME(…), with no types, sending one expression:

task #SQUARE(x)
    <- x * x
fi

task #MIDDLE(a, b)
    <- (a + b) / 2
fi

#AREA = SQUARE(SIDE)             ; a constant, computed when compiling

task main()
    show(SQUARE(3), " ", SQUARE(1.5), " ", MIDDLE(low, high))
fi

A use is written as a call, without the #, as a plain macro's is, and the compiler reads the expression with each argument in place of its name, then checks it as it checks the expression written out, so SQUARE takes whatever * takes. Where C pastes text, this would keep the mistakes C's macros are known for out:

Beside it, a macro could be an array's count where a type is written, as C's int items[SIZE] is: [int#SIZE], which today takes only a number (a value's count, [0#SIZE], already takes a macro). Open questions: whether a macro that takes values may hold a when or match as a value, whether its body may hold more than one line before the <-, whether its parameters may be written with types (task #SQUARE(x: float)), which would make it a task by another name, and how Studio hovers a use, with the expression it stands for.

Type conversions (casting)

Done for text: text(x) at r turns a number, a bool, or text into text, and so do x.str() at r and x.str(r); a shape or kind gives its own with a method str(r: region) -> text@r (see Values as text).

Done: float(n) and int(x) are calls named by the type they give, as text(x) is, and they make no memory, so they name no region (see Turning a value into a number). Text is read too, and may be no number, so int(t) sends int ~ ParseError (see Reading a number from text).

fixed x = float(n)                  ; an int as a float, which never fails (done)
fixed whole = int(2.9)              ; a float as an int: 2, truncated toward zero (done)
fixed counted = int(n > 3)          ; a bool as an int: 1 or 0 (done)
fixed shown = text(3.5) at r        ; a number to text makes text, in a region (done)
fixed age = int("42")               ; text to a number can fail: int ~ ParseError (done)

Done: the typed form scan("Age: ", int) reads a number from input in one step, and needs no region, since a number needs no memory. Its question was how the end of input is told apart from a line that is no number; the answer is a second builtin kind, InputError, whose Ended says the input is over and whose NoNumber holds the ParseError reading the line gave (see Reading a number from input). Two kinds rather than one keep each type exact: the error from int(t) can only be the two reasons text has, and no variant name means two things. What the answers were:

Writing errors: ~, and fail beside failure

Chosen and written 2026-09-23. How a failing task is written changed in two ways. A result used to be int or ParseError, and the error was named with fail in every position, which read badly in two places: or is also the boolean operator, and A or B suggests a union of two alike types although the left is the value and the right is the error; and the first line of a plan B, fail e, looked like a declaration although it is a case form.

task age(line: text) -> int ~ ParseError
    fixed years = int(line) {
        failure e                      ; names the failure: a case form, like a case
        <- fail e                     ; makes one: the verb
    }
    when years > 200
        <- fail ParseError.TooLarge
    fi
    <- years
fi

match age(line)
case {years}
    show(years)
case failure e
    show("no age")
fi

What it cost: 72 type positions and 65 case forms rewritten in the programs here, plus the lexer, the parser, every message that quotes a type or a case form, Vela Studio, and every page of these docs. Each old spelling is an error that teaches the new one, since every program written before used or in a type and fail in a case form. Four other spellings were weighed and dropped: / (division, and the path separator in include shapes/circle), | (the bitwise or), ! (the lexer answers it with Vela negates with 'not', and is !e reads as is not e), and $err ($ means a variable in shell and PHP, which is backwards for a mark that means this is the error).

Bitwise operators

Vela has no bitwise operators at all: an int can be added, compared, and divided, but its bits can't be read, set, or moved. A systems language needs them — flags packed into an integer, masks over hardware registers, hashes, checksums, and dividing or multiplying by powers of two — and today a Vela program has no way to write any of that, not even through a task. This needs implementing. The symbols are chosen: &, |, << and >>, as C writes them (decided 2026-09-23). These questions come with them:

Tooling & Ecosystem

Concurrency

Compilation Targets

A global region Idea

Every region ends before the program does, so today nothing that lives in a region outlasts main's blocks: a shared field holds only values of no region, and a value worked out where the program starts (a decorator's) can't be text built then, or a slice. A global region would be one that lasts as long as the program, like an assembly program's data segment, declared once, with a size the program can't go past:

region global(64)                 ; 64 bytes, for the whole program
    fixed greeting = "hello"       ; a constant, written into the program, taking no room
    flex visits: int = 0          ; a variable that lasts as long as the program
fi

As noted so far (the user's design): it is written in the main file only, not in a module, as a block closed by fi; a fixed in it is a constant, written into the program's code, and a flex a variable; nothing in it ever grows, so it holds no slice, and a pointer in it points only at a value of known size; it is never freed; a program that puts more in it than its size stops; and the language would have a limit on how much a program allocates in all.

What it would make possible:

Refinements suggested:

Concerns to weigh:

Lower priority Low

Ideas kept for later, each waiting for a decision on whether Vela wants it at all, and on its syntax.

Tasks as Values, Lambdas, and Decorators

Where it stands. Done — tasks are values (Tasks as values): a task's type is written (int, text) -> int, a task named without () is its value, and a variable, field, item, or result holding one is called like a task. In C a task value is a pointer to a function, so it costs nothing. Built since, in this order: lambdas that take nothing from the task around them, startup and wrapper decorators, and type groups, then lambdas that keep what they use (below).

Lambdas Done

A task without a name, written where a value goes: its header without the word task or a name, then its body and fi, on lines of its own or on one line.

fixed double = (x: int) -> int <- x * 2 fi
show(apply((n: int) -> int
    show("got ", n)
    <- n + 1
fi, 5))

A plain lambda takes nothing from the task around it: its body sees its own parameters, the program's tasks, macros, and shared fields, and no variable of the task it is written in (naming one is an error that says how to keep it). The compiler makes each one a task of its own, so a lambda is exactly as cheap as a named task. Built: see Lambdas.

Lambdas that take values from around them Done

A lambda that uses a variable of the task around it has to keep that value somewhere, for as long as the lambda can be called, which can be after the task has ended. Other languages keep it on a garbage-collected heap (Go, Python, JavaScript) or let the programmer choose between moving and borrowing (Rust's move). Vela has regions, and a region already answers "how long does this memory live". So a lambda that keeps what it uses names the one region it keeps it in, and its type names it too:

task counter(r: region, start: int) -> [flex @r]() -> int
    flex n = start
    <- [flex @r]() -> int
        n = n + 1                   ; its own copy of n, kept in r
        <- n
    fi
fi

task main()
    region r
    flex next = counter(r, 10)
    show(next(), " ", next())       ; 11 12
fi

Places kept by name. Done A variable named in the list beside the region is kept as the variable itself: [flex& count, @r] changes it, and [fixed& big, @r] reads a large value where it is, with no copy. It is safe only where the lambda can't outlive the variable, so only where r is declared after the variable, in its block or one inside it, and ends first; a region given as a parameter, or one declared before the variable, is refused. Built: see Lambdas that keep what they use.

Held in a field. Done A field holds a lambda that keeps what it uses as f: [@this](int) -> int, kept in the region of the value holding it, which a value in no region fills with a plain task only. The iterators of std/collections hold their makers' lambdas so. Built: see In a field.

Changing lambdas in a field. Done Of the three ways weighed (a [bind @r] that keeps only twofold copies, a shape holding a changing lambda held alone, and copying the lambda's block with the value), the author chose the first two together, 2026-11-11. A [bind @r] lambda now changes only what its copies share, a slice's items, a map, what a pointer points to, and only reads its own copy of anything else, so it is copied freely and a field holds it as [bind @this]; one that changes a shape or array copy of its own is a [flex @r] lambda. A shape with a [flex @this] field, or a field of such a shape, is held as the lambda is: in a flex variable, given as a place, never copied, and its lambda is called where it is, through a place that changes. A variant holds none, since a match copies values out. Nothing is paid at runtime. Built: see A field that changes things. The third way, copying the block, stays open for a held value that should become a plain one, such as an iterator of std/collections made with a counting lambda, which today holds only [@this] ones.

Later, maybe. Fields and array items of a variable kept as places, [flex& p.x, @r]; compared, or copied into another region with what it keeps; a rule for add and del on a slice the lambda keeps a copy of, which today it refuses (a slice kept as a place grows), since its copy shares the items and not the length; and data of another region used in place where Vela can see that the region outlives r. Weighed and dropped: marks for each region a lambda takes from (variables live on the stack, and a region holds only what they point to), and a region kept alive for as long as a lambda in another lives (it frees memory early when the kept region ends first, and needs counting while the program runs).

Decorators Done

A decorator is a task whose first parameter is a task. It is called like any task, and it can also be written before another task's header, in one of two ways:

$register("/home")                ; startup: runs register(home, "/home") once, before main
$(timed, logged("home"))          ; wrappers: home(n) runs timed, which runs logged, which runs home
task home(n: int) -> text
    …
fi

Values worked out where the program starts Problem

A startup decorator's values, and a wrapper's, are worked out where the program starts, before main, when no region exists yet. So they can't be anything that lives in one: text joined from other text ($route("/" + name)), a slice, or a value holding a pointer. Values of no region (numbers, literals' text, tasks, and shapes of them) work, a call giving one of those too: a wrapper's is kept in a hidden variable for the whole run (built 2026-10-04, see Decorators).

Ways to solve the rest. The global region solves it if it can be allocated in (not only declared in): what is made at start goes there, within its size, and lasts the whole run, which is what such values need. Without it, a hidden region could be opened before the first decorator and kept to the end, the static region in another form, with no size to bound it. A third way is for the decorator to copy what it is given into a region of its own registry, which needs a global region anyway. So the global region, with allocation in it, is the answer to look for; declared variables alone wouldn't help.

What these make possible Ideas

Ideas We Haven't Decided On

Ideas that may or may not become part of Vela. Unlike Lower priority, which holds things Vela probably wants later, these are kept here to be decided soon: each says what it would add and what it would cost, and the decision is to build it or drop it.

Suggested Roadmap

Now — Foundations Low Risk

Later — Ambition High Risk

  • Limited borrow checking on top of regions
  • Concurrency model (actors or channels)
  • LLVM or native backend
  • WebAssembly target
  • Self-hosting compiler

Conclusion

Vela's strength is its smallness. The best next steps preserve that smallness while filling the obvious gaps: a real string type and a standard library, now that arrays, slices, tuples, and the types a task or shape takes are in place. The memory model's first step, scoped regions, is in place (Vela Regions); move-only ownership and optional temporary borrows can grow it while keeping Vela fast, simple, and far safer than C without becoming Rust.

Beyond memory, Vela should grow carefully: packages built on its modules, and the room its methods leave (above), before concurrency or a self-hosted compiler. The goal is not to be everything; it is to be a small, beautiful, fast language that proves you do not need a massive toolchain to write safe systems code.