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
-
Sending:
<- a, bmust list exactly as many values as the header has types, each of its type. A bare<-stayed reserved for tasks with no results, until step 2 gave it a second meaning: sending named results as they are. -
Receiving:
fixed,flex, and assignment take a list of names:flex q, r = divmod(17, 5)orq, r = divmod(20, 6). Types can be written per name,fixed q: int, r: int = …._receives a result and drops it; no Vela name can start with_, so it's free for this. -
Results aren't one value.
show(divmod(17, 5))andfixed pair = divmod(17, 5)are errors: 'divmod' sends 2 values; receive them with 'fixed a, b = …'. A task with several results can only be called as the whole right side of a list. Tuples came later as a type of their own (-> (int, int)sends one tuple; see Tuples), and several results stay separate from them. -
Regions: each result type follows today's rule, so
-> ^Node@r, intneeds a parameter that namesr, and the results' regions are inferred at the call like a single result's. -
Plain lists too: the same form works with ordinary
values, and the right side is evaluated completely before anything
is assigned, so
a, b = b, aswaps two variables. -
Generated C: results become a struct returned by
value,
struct vela__results__2__int__int { int64_t r0; int64_t r1; }; the call site copies its members into the new variables. One struct per list of types, not per task, so a task that sends another's values on is a plainreturn. No pointers, no allocation.
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:
-
All or none. Either every result has a name or none
does:
-> q: int, intis an error. -
Names live in the task's scope. A result can't
share a parameter's name, and the body can't declare another
variable with it. After it is set, it reads like any variable:
total <- total - 1. -
Only
<-changes a result.total = 0is an error ('total' is a result; set it with 'total <- …'), and so iscount <- 1on an ordinary variable ('count' isn't a result of this task; assign it with 'count = …'). Every line that changes what a task sends starts with a result's name and an arrow, so those lines stand out when reading, and an editor can color them. -
No zero values. Vela has no uninitialized
variables, and results are no exception. The checker requires each
named result to be set before it is read, and before every bare
<-and the end of the task, on every path. This is the same path analysis that checks "a send on every path" today: 'peak' might not be set here. A<- valuesline sets them all, so it counts as a send on its path. -
Whole values only, at first.
best <- Point { x: 0.0, y: 0.0 }sets a shape result; setting one field,best.x <- 0.0, can come later if it's needed.
💡 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 inparse_stmt, the same as tellingx = 1fromx + 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 <-1keeps its hint to write< -whenaisn'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 offerstotal <-, 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
-
A file is a module. A folder of files as one module
(Go's packages) splits a big module across files, but then no file
alone says what it declares, and every name needs a search. A module
path names folders with
/and the file last, relative to the file with theinclude. -
The word
include. Vela compiles to C, andincludeis the word a C reader already knows for "this file needs that one". It isn't C's#include, which pastes text: the included file stays a module with names of its own, read once however many files include it. (The keyword wasuseat first;useandimportare now errors that nameinclude.) -
Both
m.nameandinclude m (name). Qualified names say at every use where a name comes from and never collide; a list of names keeps code that uses a few names often short. This is the one place where Vela gives two ways to write the same thing, on purpose: a file chooses per name, and the list at the top says which. Braces list names without the module's name, for a file that wants only those. -
Aliases with
=>, and the old name gone. An alias hides the name it replaces, in that file, bothItemandprices.Item, so each thing has one spelling per file and nothing needs a search to know which name is meant. The arrow points from the real name to the new one, and doesn't read as a type (:) or an assignment (=). A variant or a field can't be renamed: they are parts of a declaration, and renaming them would redefine it. Instead any variant can be written through its kind (Size.Small,shop.Size.Small), so two kinds with variants of the same name never clash, with no second kind of include. -
Public unless
priv. Most of what a file declares is meant to be used, so the rare private helper is the one marked, the reverse of Rust'spub. Fields follow the same rule, which is what lets a module keep an invariant: another file can't read, assign, or build a private field, so it goes through the module's tasks.
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
-
[type#count]and[type]. The item's type comes first, as the type is read (an array ofint, four of them), rather than Go's[4]intor C'sint a[4]; a slice is the same brackets without a count. -
A slice lives in a region. In C, an array is
another form of pointer, and a slice is that pointer with a length,
so it follows the pointer rules: its type names its region, it is
never
none, and it can't outlive its region or move to another. A slice of a local array would point at the stack, so that is an error, and copying into a region is written out (new [T](xs) at rorxs.copy() at r, both of which also copy what the items hold). -
new [T](…) at r. The same form asnew T(v) at r: the value in parentheses gives the items ([v#n], an array, or a slice, copied), and the result is the slice itself rather than a pointer to one. A count known only as the program runs is allowed only there, since an array's count is part of its type. -
xs.len, notlen(xs). The length reads as part of the value, costs nothing (a constant for an array, a stored count for a slice), and takes no name away from tasks. -
A slice's length changes; an array's doesn't. First
there were no growable lists: a list that grows copies itself into
bigger memory, and without
freeevery old copy stays in the region until it closes. Then (2026-09-17) the length that can change became what sets a slice apart from an array, so a slice grows and shrinks itself, with no separate list type:xs.add(v),xs.add(i, v),xs.del(i), andxs.del(), one word for each direction and the form chosen by how many arguments are given (rather thanpush,insert,remove, andpop).new [T]() at rmakes an empty one. They read like methods, but they are built-in changes, aslenis a built-in member:addtakes its region from the slice's type, so nothing is passed for it.
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' …
-
Angle brackets, as in the header.
<T>declares the types, and a call that writes them uses the same brackets,first<int>(xs). Brackets ([T]) would read as an index or a slice type, and braces as a plan B, a case form, or a shape literal. A<after a name starts types only when the matching>is followed by(, so comparisons still read as comparisons. -
Five words, not traits.
(Patterns came later, 2026-10-16: a pattern's name asks for
methods and fields the program declares,
Patterns; the five words stay as
they are.)
frozen,same,similar,shown, andorderedsay what a type must allow — be a tuple's item, be compared with==, be compared item by item with~=, be shown, be compared with<— and each is one thing, joined with&to ask for several (<T: same & shown>; decided 2026-09-28, whenshowncame to take arrays and shapes that==can't compare). Onlyorderedmeans another word,same, since an order compares. A closed set of words keeps the whole feature readable: no interfaces, noimplblocks, no resolution rules. Every word is written even where one set of types sits inside another today, because a later step may let a shape or kind say how it answers a word itself (how it is ordered, say), and then the sets need not nest. No word asks for arithmetic: a task that adds its values takes numbers. An idea for later, the author's (2026-10-09): words by the kind of value,<T: whole>or<T: twofold>(see Whole values, twofold values, and places).frozenis notwhole: it refuses arrays too, since a tuple's items never change and an array's may. -
One task per set of types. The body is checked
once, with
Tstanding for the type each use gives, and the compiler then makes one task for each set of types the program uses, so a call costs exactly what an ordinary call costs and the generated C holds no type tags or void pointers. The names of those tasks are the Vela name and the types (first__of__int), which no Vela name can be. -
Regions stay implicit. A type a task takes names no
region: what a
Tholds is in the region of the type given for it. A slice or pointer around it names one ([T]@r), and a call that would keep a value of one region inside a value of another is an error that says to copy it first.v.copy() at rworks on aTwhatever it turns out to be; since a copy lives where it is made, it is aTonly where the header keeps aTin that region (xs: [T]@r), and any other copy is the value itself, which every use must fit. That rule is local to the task, so it holds through a task that passes itsTto another.
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
-
The word is
maybe, neverOption: the type reads as what it is, a value that is maybe there. -
The type is a prefix, like
^:maybe int,maybe ^Node@r,^maybe int@r. A brace form (maybe{int}) was considered and rejected: braces would mean a type argument, a case form, a plan B, and a shape literal at once, and generics written with braces would collide with plan B in expressions. -
A
maybecan't hold anothermaybe, ornothing. -
A plain value never becomes a
maybein arithmetic or comparisons:m + 1is an error that says to extract it first, and==compares amaybeonly withnone. show(m)prints the value, ornone.
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
-
A
maybe ^Tcompiles to a plain C pointer, withNULLfornone, so the safety costs nothing. -
A shape that reaches itself only through
^pointers could never be created (the first one would need another to exist already), so the checker rejects it and suggestsmaybe ^T. -
This removes null dereferences. Freed memory and double frees were a
separate question, since answered by the region model below:
pointers there can't outlive their memory, and there is no
free.
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
}
-
The value before the block is evaluated once. A plan B binds like
.and^, sox { 0.0 } * 2.2and-x { 1 }mean what they look like. -
The last line gives the value, and must have the wrapped type. A
<-inside a plan B sends from the task, as anywhere else; only a line that isn't a send gives the value. When every path through the block ends in<-, no value line is needed. -
Names declared in a plan B stay inside it. Plan Bs nest:
a { b { 0 } }falls back to a secondmaybe. - An empty block is an error, except where there's no value to replace (below).
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
-
fail emakes an error value. It is always written, even where the types would tell, sotext or textand future generic code stay unambiguous; the error type must differ from the value type anyway. -
case failure eandis failure ematch a failure;case {v}a success. The noun names one; the verb makes one. -
In a plan B, a first line
failure enames the failure it handles. The form{ err: ErrorTypewas considered and rejected: after a variable,res { err: Problem }is already a shape literal, and the error's type is known anyway. -
nothing ~ Eis a task that sends nothing when it works: a bare<-, or reaching the end, succeeds. -
A call written as a statement can't drop an error by accident: when
its
~result is unused, that is an error (a variable holding one isn't checked for being used, yet);save(file) { }ignores the error on purpose.
How it fits the grammar
-
Braces:
Name {followed byfield:or}is a shape literal; any other brace after a value is a plan B. No statement starts withname:, so nothing is lost. If shape literals may later span lines, the check skips line breaks. -
Sends:
<-may follow{(x { <- 0 }), and}ends a bare<-. The hint fora <-1is unaffected, since{is not a value. -
New words:
maybe,is,fail, andfailureare reserved, and_is a symbol. In types,~separates the value type from the error type. -
Generated C: a
maybe Tis a small struct{ bool has; T value; }(a plain pointer formaybe ^T), andT ~ Eis{ bool ok; T value; E error; }. An expression containing a plan B is generated as statements: its operands are evaluated left to right into temporaries,and/orstill skip their right side, and aloopcondition runs its plan B on every pass.
💡 Next steps on top of this
-
Forwarding errors: a plan B that only sends the
error on,
failure ethen<- 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 separateDone, with both: a block's last line gives the value, and= valueform per case.case p = valuewrites 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 <- vupdates, a bare<-ends. -
Calling C: pointers coming from C functions
should arrive as
maybe ^T. -
Dangling pointers and doubleDone: regions.free: the remaining pointer bugs, for the memory model.
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.
^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.
- Eliminates use-after-free, double-free, and data races
- Zero runtime overhead
- Proven at scale (Rust)
- Massive implementation complexity
- Steep learning curve for users
- Fights against Vela's minimalism
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.
- Very fast allocation and deallocation
- Simpler than borrowing: just region scoping
- Excellent for compilers, games, and request handlers
- Objects cannot live longer than their region
- Requires discipline about region boundaries
- Long-lived data needs special treatment
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.
- Easier to reason about than borrowing
- Handles shared ownership naturally
- Deterministic destruction
- Runtime overhead from counting
- Reference cycles need weak references
- Not as zero-cost as borrowing or arenas
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.
- Much simpler than a borrow checker
- No aliasing means no use-after-free or double-free
- Easy to implement and explain
- Can be awkward: reading a field consumes the whole value
- Need explicit clone for shared read access
- Limited compared to full borrowing
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
^Tcould be move-only, letting a single value be freed early without dangling copies. -
Borrowed references
&Tare 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:
-
One namespace for a file's tasks. A
Circleand aRectdeclared in one file can't both have a task calledarea; programs end up withcircle_areaandrect_area, and the secondareais rejected with'area' is already defined. -
No way to change a value in place. Vela has no
address-of operator, so a task can only modify a shape that was
allocated with
newin a region and passed as^Point@r. Aflexvariable on the stack can't be updated by a helper task. -
Nothing to discover. After
p., an editor can offer fields but not the operations that belong toPoint.
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()
- 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)
-
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
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
- 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)
-
A new keyword and block kind, and one more level of
finesting -
A type is now described in two places: its
shapeand itsextend - More machinery than a small language strictly needs
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
- Maximum discoverability: data and behavior side by side
- Short method bodies, with fields in scope
- Familiar to most programmers
- Shapes become classes, against Vela's "no classes and structs" philosophy
-
Implicit field scope makes it unclear whether
xis a field or a variable - Types can't gain methods from elsewhere, such as another module
.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
- 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
-
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
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())
-
Methods stay ordinary tasks; the only new syntax is
Type.before the name -
Each type gets its own namespace:
Circle.areaandRect.areacoexist -
flex selfchanges aflexvariable in place without an address-of operator
- 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
| 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
taskbefore 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:
.countis the field and.cap()the method, whilecountandcap()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. -
altersmarks 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 (aflexvariable, a field of one, an item of a slice, orp^): afixedvalue never changes, and neither does a parameter, soc.step()on either is refused. The compiler passes the place's address in the generated C, and Vela code never sees a pointer. -
.thisis the whole value. A kind has no fields, so its methods read it with.thisand take it apart withmatch .this; analtersmethod of a kind moves the value to another variant with.this = Other. A shape's methods have it too. -
@thisis 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, orlen, 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(). -
privmarks 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, andTin 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)andvela_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
-
A method written for a pointer: Go lets a receiver
be
*T, so a type can have one behavior for a value and another for a pointer to it. Vela'saltersalready gives a method the place it is called on, so a pointer receiver is not needed for changing a value; what it would add is a method that only makes sense behind a pointer, such as one that walks a list, or one that hands the pointer on. Written asalters ^insert(next: ^Node@this), or in a second block,shape ^Node … fi. The question to answer first is which onep^.name()andp.name()each pick, and whether a value without a pointer can call the pointer's method at all. -
property: a member read without(). A method of no parameters that reads like a field,property area() -> floatread asc.area, so a field can become computed (or the other way) without touching the code that reads it. It needs a rule against work hiding behind a read: a property that allocates, reads input, or traps is a surprise where a field was. Perhaps a property may only read the value's own members, neveralters, and never take a region. -
Members that don't recompute. A value a method
works out from fields that have not changed could be worked out
once. Vela already does this where it counts:
xs.lenis a field of the slice, read in one machine instruction, and an array's length is part of its type, so it is a constant (see Slices) — neither ever counts the items. Done What is left is the general case: apropertywhose answer is kept until a field it reads is assigned, which needs the compiler to know which fields each property reads, and analtersmethod to clear what it invalidates. Text was the one place a length would still cost, as a C string, and it now keeps its length too:t.lenis a field (see Text is a C string above). -
Moving along a kind's variants: a kind's variants
are written in an order, so
.next()and.prev()could be there without writing them —altersmethods a kind gets for free, which move the value to the variant after or before this one. The questions: what the last variant's.next()does (wrap around, stay, or givemaybe), what happens when a variant carries values and the next one needs different ones (only variants without values could take part, or.next()could need them given), and whether a kind opts in, since for many kinds the order is an accident of writing rather than a meaning. A first cut: a kind whose variants all carry nothing gets.next()and.prev()givingmaybe, and a.first/.lastof the kind itself. -
A task of the type itself: option E's
Point.origin(), a member that makes a value rather than acting on one. Decided It becomes shared members (decided 2026-09-27; shared fields and shared methods are done, 2026-09-28): a shape'ssharedfield is one value for the whole program rather than one per value, and asharedmethod has no.this. Both are reached only through the shape's name,Point.origin()andPoint.made, never through a value. A shared field isfixedunless it isshared flex, may beprivorreadlike any field, and always names its first value, one the compiler works out without running the program. A kind gets shared methods only, no fields, and a shared name may not be one of its variants' names. Shared fields that live in a region, and shared members in a shape that takes types, wait for the two ideas below (a static region, shared members of a shape that takes types). Counting a shape's values exactly needs code that runs when a value is made or goes away, which is the events idea. -
Letting a shape answer
==(and fitT: shown, done):show(p)is done: a method namedstrsays how a shape or kind is written, andshowcalls it in a region it makes for the line. Fitting<T: shown>is done too (2026-09-28): the body is still checked once, and the C made for each set of types writes a value through its type's own writer, which calls the shape'sstr. One half is left:==and~=have no method behind them. Since 2026-09-28==compares a shape by where it is, cheaply, and~=item by item, every field but shared ones, so a method a shape gives would change what similarity means for it: asimilar(other: This) -> bool(with<>itsnot) could say which fields matter, or compare a list by its items rather than the address its pointers hold, asstropened printing. Similarity that follows pointers needs a way to stop on a list that points back to itself, which is why~=compares a pointer by its address today. It is also the smallest step towards interfaces, since it asks of one method: does this type have a member of this name and header? (Patterns now ask that, said where a shape is declared: Patterns.) -
Methods from another module: a shape's block holds
every method it has, so a module can't add one to a type it
includes. That keeps a type honest — everything it does is in one
place — at the cost of the "extension method" that makes a foreign
type fit local code. If Vela ever wants them, the rule to settle is
which module's method wins where two add the same name, and whether
a method added elsewhere may read a
privfield (it must not). -
A method that allocates in its value's region:
today a method is given the value it is called on, never the region
that value lives in, so
new … at thisand.items.add(v)are refused inside it, and a region parameter can't stand in, since the value can't point into another region. The caller has to grow the value itself,bag.items.add(v), which leaves a shape unable to keep its own list. The fix is for analtersmethod to be given@thisas well, a region argument the call passes along with the value's place. The questions: a value with no region (a shape holding only numbers) has nothing to pass, so either such a method is refused on it or the region is optional; a value reached through a pointer,p^.grow(), is in the regionpnames, which the call knows; and whether everyaltersmethod pays for the argument, or only one that allocates, which the compiler can tell from its body. -
A method that takes types of its own: a method
names no
< >today, so aBox<T>can't have amapped<U>() -> Box<U>. The types a call gives would have to say which list they belong to, since the value already gives the shape's: either the method's own come second,b.mapped<text>()with the shape's worked out fromb, or the two lists are written apart. Worth doing once the first list is proven. (done in part: Types of its own; a method's own types come after the shape's and are told by the call's values or the place its value goes, not yet written at a call)
Other Design Directions
Memory is not the only place Vela can grow. Here are ideas across the whole language.
Syntax & Ergonomics
-
Match expressions:
fixed area = match s case Circle r = 3.14 * r * r … fi(not->, which names a task's result type) (done: A match or a when as a value, forwhenas well, where blocks give the value and=is a case's short form) -
Guards in case forms:
case Circle r when r > 0.0 -
Else-if chains:
when ... else when ... else ... fi(done: Control Flow, where oneficloses the chain andelse ifis an error namingelse when) -
Inclusive ranges:
0..=10or0...10 -
Tuples:
(int, text)as values that can be stored and passed, beyond multiple results (done: Tuples, including case forms that take them apart,when found is {(name, age)}andwhen pair is (x, y), andflexnames in any case form) -
Named arguments:
resize(width: 2.0, height: 3.0)for task calls, as shape literals already name their fields (Point { x: 1, y: 2 }) -
Default arguments:
Done — see
Optional arguments.
=in a task's header gives a parameter the value a call may leave out, as intask pad(t: text, width: int = 8), andpad("a")reads aspad("a", 8), as a shape's field does for a literal. The rules that keep it small: the defaults come last, so a call leaves out a tail of the list and no argument is matched by place across a gap; the value is one the compiler works out, so a call costs no more than the same call written out; a place, a region, a type the task takes, a task taking a group, and a lambda's own parameters have none; and the values belong to the task's name, not its type.math.log(x, base: float = 10.0)was the first user in the library. Open: whether named arguments, above, should come next, so that a call can leave out a middle parameter by naming the rest. -
Statements end at a line break: today
show(1) show(2)on one line is accepted; requiring the break, and allowing one inside( )and{ }, would make long calls and literals splittable
Defined Behavior
-
Integer traps: overflow and division by zero stop the program with a message instead of being C's undefined behavior, identical under every C compiler(done: Numbers). Trapping won over wrapping and saturating, which would both turn the bug into a wrong number. The location in the message (vela: integer overflow at 7:15 in task 'main') costs one string per operation; the checks use the C compiler's overflow builtins where it has them. Two cases never reach the program: arithmetic written with literals only is computed by the compiler, which rejects a result that doesn't fit, and a division by a zero written that way is an error. The smallest int% -1is0, its exact value, rather than a trap. -
Left-to-right evaluation: arguments and operands
that call tasks are evaluated in order (codegen stores them in
temporaries first), so output never depends on the C compiler.
Expressions that contain a plan B already work
this way; the rest would follow. (A null check on
p^is no longer needed: a pointer can't benone.) -
Lints: warn about code that can't run (after a
<-in the same block), a value compared with itself, andx = x; these fit naturally with reporting many errors at once
Type System
-
Generics: tasks, shapes and kinds take types today
(
task first<T>(…),shape Box<T>, below, and kinds,kind Option<T>). Tasks are also generic over regions (list: maybe ^Node@r), which stay implicit rather than written in the same< >(see region brackets) - Type inference: let the compiler infer more types without annotations
- Sum-type exhaustiveness: already present; keep it mandatory
-
Compile-time constants:
Done — as macros,
#MAX_SIZE = 1024, a value the compiler computes and C holds as a#define(see Macros). Macros that take values, and a macro as an array's count in a type, are next: see Macros that take values. -
Compile-time use of tuples: a tuple's items never
change, so a tuple built from constants (
fixed limits = (0, 100)) is itself a constant: the compiler could computelimits.1 - limits.0when compiling, pass small tuples in registers, and share one copy of a constant tuple, with no check that it changed -
Type conversions (casting):
Done — a value becomes text (
text(n) at r, orn.str() at r), a number becomes another (float(n),int(2.9),int(ok)), and text is read as one (int(line)), from text and from input (scan("Age: ", int)). Rounding another way is what is left; see Type conversions below. -
Type aliases:
Done —
alias SampleTask => (int) -> int, another name for a type, which is that type wherever the name is written (see Type aliases). An alias that takes types, and a distinct type that only looks like another, so that a user's id is noint, are not built.
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:
-
Each argument once: an argument is worked out once,
before the expression, so
SQUARE(next())callsnextonce, as a task would. -
Whole expressions: the expression is one value
wherever it goes, so
SQUARE(a + 1)is(a + 1) * (a + 1)and2 * MIDDLE(a, b)doubles the middle. - Its own names: a name in the expression that isn't a parameter means what it means where the macro is declared, never a variable of the file using it.
-
Constants stay constants: with constant arguments,
the value is computed when compiling, so a plain macro can be made
of one (
#AREA = SQUARE(SIDE)). -
C: a use with constant arguments is its value; any
other is written as a
static inlinefunction for the types it is given, as a generic task's use is, rather than a#define, whose arguments C would paste twice.
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:
-
Which conversions.
int↔float,bool→int, numbers ↔text. Shapes, kinds, pointers, and slices don't convert; a task does that. A bool reachesfloatthroughint,float(int(ok)), so there is one way, not two. -
What can fail. A float to an int can fail
(
nan, or a float too large to hold), and stops the program with a message, as integer overflow does, since such a conversion is a bug when it fails. Text to a number can fail on input that is merely wrong ("4x2"), so it gives aT ~ E:int("42")is anint ~ ParseError, a kind withNotANumberandTooLarge, neither holding a value, read with a plan B ormatch. A program may send one itself, and the three names are the kind's own, as a builtin type's name is. -
Rounding. A float to an int truncates toward zero,
as C does. Rounding another way waits for conversions of its own
(
round,floor), which keeps each one explicit. -
Text needs memory. A number written as text is new
text, so it goes in a region (
text@r), written… at rasnewandscanare. Done this way. The conversions between numbers make no memory, so they name no region at all. -
Reading input.
Done — a second kind, not a third
variant of
ParseError: a program matching whatint(t)sent would otherwise have to list a case that cannot happen. The two kinds say each reason once becauseNoNumbercarries aParseErrorrather than repeating its variants under other names, which would have given the language two words for the same two things. The cost is one more level to read through, which Variants of a kind held in a kind would remove. -
Promotion. Done —
refused:
1 + 2.5is an error that asks forfloat(1) + 2.5, so nointbecomes afloatquietly, and arithmetic agrees with assignment, which already refusedfixed x: float = 3.
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.
-
~replacesorin a type. Done —int ~ ParseError, with or without spaces, and a type written withoris an error that teaches it.ornow has one meaning only, the boolean one.~was free (the lexer answered it with unexpected character), and the bitwise operators take& | << >>(see Bitwise operators), so nothing else wants it. Sigils already appear in Vela's types (^Node,text@r,[int#4]), which is where this one goes. -
failsplits by what it does. Done — the verbfailwherever a failure is made (<- fail e, and a failure as a plain value), and the nounfailurewherever one is matched and named (case failure e,when x is failure e, and a plan B's first line).failure enames the error value, not a type; the type is the one after~. Each word written where the other belongs is an error that names the right one.
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:
-
Which operations. And, or, and shifts in both
directions. Exclusive or and complement have no symbol yet:
^is a pointer, and~goes to the error side of a type (Writing errors), so each needs a word, a member-like form, or nothing at all for now. -
Spelling, and what each symbol already means.
&joins the words a type must allow (Types a Declaration Takes), so it would mean one thing in a type and another in an expression;|is an error today that says to writeor, and that message has to go; and>>lexes as two>, which closes nested types (Box<Box<int>>), so the lexer has to tell a shift from the end of two type arguments. Vela writesandandorfor logic, and these read differently, which is the point of choosing the C symbols. -
Which types.
intis signed and 64-bit, and Vela's only unsigned type is the byteunit, so a shift's meaning at the sign bit, and a right shift of a negative number, must be defined rather than left to C (where some of it is undefined). A shift count that is negative or at least 64 is undefined in C, so Vela has to stop the program or define the result, as it does for overflow and division by zero. - What it costs. Bitwise operations are single C operators, so this is a small feature to build once the spelling and the defined behavior are settled.
Tooling & Ecosystem
- Package manager: simple dependency fetching and project builds
- Formatter: consistent, opinionated code formatting
- LSP: Vela Studio's language layer already answers completion, hover, go-to-definition, and errors; a small language server built on it would bring the same support to VS Code, Neovim, Helix, and Zed
- Playground: the compiler's front end already runs in the browser as WebAssembly; with an interpreter or a WebAssembly back end, the docs could run every example in the page
- Docs generator: extract docs from comments automatically
- Test runner: built into the compiler
-
vela run file.vela: build into a temporary folder and run, with no-oand no stray executables -
Standalone C output:
--emit-c -o out.ccould writevela_rt.hbeside it, andVELA_CFLAGScould pass options such as-O0 -gfor debugging -
Source mapping:
#linedirectives in the generated C, so C compiler errors and debuggers point at Vela lines, and Vela Studio could show the C a line produced -
A stability promise: a short page saying which
syntax is settled (
<-,fi,fixed/flex) and which may still change before 1.0
Concurrency
- Message-passing actors: no shared mutable state by default
-
Channels: pass values between tasks (
<-already sends a task's result, so channel operations would need a spelling that can't be confused with it) - Async/await: only if it fits the minimal philosophy
- Data-race freedom: enforce through ownership rules from day one
- Regions per thread: a region belongs to the thread that declared it, and handing a whole region to another thread is safe, since every pointer into it names it
Compilation Targets
- LLVM IR: finer control over optimization and more targets, without a C compiler
- WebAssembly: run Vela in browsers and edge environments
- Embedded targets: bare-metal output with no runtime
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:
- A wrapper's or a startup decorator's values worked out where the program starts, when they live in a region: text joined then, a slice, a value holding a pointer (see Values worked out where the program starts). Values that live in no region (a number, a literal's text, a task) need no global region: they can be kept in hidden variables already.
- What a decorator records, kept for the whole run: a web module's routes, say, in a slice of text and tasks, rather than a set number of shared fields.
- Shared fields holding region values, which the static region idea asked for; the two are one idea, and this one is the more careful.
Refinements suggested:
- Check the size when compiling. The variables declared in the block have sizes the compiler knows, so too many is an error when compiling, not a crash when running. Only what is allocated there while running (below) needs a check then, one comparison per allocation, stopping the program with a message.
-
Let it be allocated in.
new T(v) at global, text joinedat global, and a slice there, each taking room until the size is used up and never freed. Without this, the global region only holds what is declared in it, and text built at start (the case above) still has nowhere to go, since its size is known only when the program runs. -
One direction for pointers. Today a value in region
rpoints only intor. Anything may safely point into the global region, since it outlives every other; the global region may point only into itself, since every other region ends first. That is the rule the note's "a pointer only at a value of known size" is after: what makes a pointer safe is where it points, not the size of what it points at. - A slice there is safe. Growing a slice takes new room and leaves the old where it was, and nothing in the global region is freed, so no copy ever dangles; the size bounds how far it grows. Refusing slices there isn't needed for safety, only for predictable memory, which the size already gives.
-
A
fixedthere is a macro. A constant written into the program is what#NAME = valuealready is. Afixedin the global region would add something only as a value worked out once where the program starts (from a call, say), which no macro can be: a constant of the run rather than of the source. - Modules need it too. "Only in the main file" leaves a module that records things (a web module's routes) with no place for them. Either a module declares its own global variables, counted against the size the main file gives, or the main file's block gives the size and modules only use it.
-
One concept for globals. Shared fields are already
the program's global variables, each named by its shape. Rather than
two kinds of global, a shared field could hold values of the global
region (
shared flex routes: [text]@global), and the block would only say how large the region is. - A limit for the whole program set by the language would be wrong for some program whatever it is; a size the program states, with a default, serves the same purpose.
Concerns to weigh:
-
The order things start in. The global variables get
their first values, then the shared fields, then the values wrappers
keep, then the startup decorators, then
main. A first value worked out from another global must read one already set, which the compiler can check when every first value is a constant or reads only those above it. -
Shared changing state. With
events or tasks running at once later, a
flexin the global region is state every part of the program can change at the same time. It would need one owner, or reads and writes that can't interleave, before Vela runs anything at once. - Memory held for the whole run. Everything allocated there stays until the program ends, which is the cost regions were made to avoid; the size keeps it bounded, and a growing slice wastes the room its old items took.
- A file opened in it stays open to the end, which is right for a log and wrong for most files.
- Performance is good: a global variable is read at a known address, and allocating there is a bump of a pointer and one comparison.
Lower priority Low
Ideas kept for later, each waiting for a decision on whether Vela wants it at all, and on its syntax.
-
Slices that don't reuse taken-out space: today,
taking the last item out of a slice with
delgives its space back, and the nextaddof that slice goes there, which keeps memory use low; a copy made before thedelsees the new item (see Adding and taking out items). A second kind of slice could never reuse that space and always grow into new room in its region, so no older copy ever sees another's items, at the cost of memory. That behavior stays as it is; this would be a separate kind of slice, with syntax still to be chosen. -
Turning an array into a slice: an array never
becomes a slice today:
[1, 2]where a slice is expected is an error, and the values are given again as a slice ([1, 2, ...] at r, see Written by its values) or copied,new [int](xs) at r. Ways that would help sometimes: a short word for the copy (xs.slice() at r;xs...at a call is taken, since it gives a group's values), a slice that views an array a pointer already holds in a region without copying it (p^[..], asp^[0..n]does now for a part), and letting a task that only reads its items take an array or a slice alike, as a read-only view (xs: [int]@view). The last is the most useful and the largest: a view that can't grow could point at an array on the stack, which a slice may not, since it can outlive it. Not decided whether Vela wants any of these, nor their syntax. -
A static region: a shared field can't hold anything
that lives in a region today (a slice,
text@r, a pointer, aFile), since every region ends before the program does. Astaticregion would be one that lives as long as the program: it doesn't exist unless the program asks for it (with a command or a line that turns it on), and once it does, shared fields may hold slices and other region values in it,shared flex names: [text]@static. The costs to weigh: nothing in it is freed before the program ends, so a shared slice that grows in a loop grows forever; a file opened in it stays open to the end; and every task that stores something there holds memory for the whole run, which is the global-variable problem regions were meant to avoid. A narrower relative is a region a shape keeps for its own shared fields,shared region pool, which names where the memory goes and lets only that shape's methods allocate there. Not decided whether Vela wants either (events would want the same thing for what an event keeps). The global region is the more careful form of this idea, with a size. -
A tuple of one item: a tuple holds two or more
items, and one type or value in parentheses is only grouped —
(int)isint, which is howmaybe (int ~ text)is written. A trailing comma could make a tuple of one,(int,)and(2,), which reads unambiguously beside the grouping. It would needt.0,==, case forms, unpacking and a C layout of its own, and nothing yet asks for it, so it waits for a use that does.
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
-
One region, written alone.
[@r]names where the lambda keeps its copies, and its type,[@r](int) -> int, says no more, so a task asking for one takes any lambda kept inr, and the lambda can't outliver, as a^T@rcan't. -
By copy, used implicitly. Each variable of the code
around it the body uses is copied into a block of
rwhen the lambda is made: numbers, shapes, arrays, and the handles of slices, pointers and text. What lives inris used where it is, through the copied handles, and a value of another region is copied intorfirst,xs.copy() at r, the rule aPerson@rfollows. -
[flex @r]changes its copies, as Rust'sFnMutdoes, and so is never copied: held in aflexvariable and given as a place,flex& f. -
Plain and keeping values mix one way. A plain task
value goes where a
[@r]type is expected, through a small function the compiler writes; a keeping one never goes where a plain type is expected, since that type names no region. - What it costs. Two words, the function and its block; a call passes the block as a hidden argument, and making one is a bump in its region. Code that never keeps anything pays nothing. Built: see Lambdas that keep what they use.
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
-
Startup decorators,
$dor$d(args), run once where the program starts, in the order the tasks are written (an included module's first, and of several on one task, the nearest first), as Python and FastAPI run theirs where a function is defined. The task stays an ordinary task, called as usual. This is how a future web module could let a program declare its routes by writing$get("/")above each handler. Built: see Decorators. -
Wrapper decorators,
$(dt1, dt2(a)), run each time the task is called, the leftmost outermost: each is given the rest of the chain as a task of the decorated task's type, then its own values, then the task's values, and decides when to call it, or whether to. The compiler writes each layer as a hidden task, so no lambda that captures is needed, and the C compiler can inline the whole chain. Built: see Decorators. A wrapper's own values are worked out once, where the program starts (built 2026-10-04): one the compiler works out is written into each call, and any other (a call, a shared field) is kept in a hidden variable each call reads, worked out after the values of every task it may call; values that live in a region wait (see Values worked out where the program starts). -
Type groups,
P..., let one wrapper fit a task of any parameters:
The compiler fills a group in for each use, as it fills in a type, so it costs nothing when running. Built: see Type groups.task traced<P..., R: shown>(next: (P...) -> R, args: P...) -> R show("calling") fixed result = next(args...) show("sent ", result) <- result 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
-
Pipes,
value |> task(args): the value goes to the task's first parameter, and|>>to its second, and so on, from the notes that started decorators. It is sugar over calls, and a pipe of tasks held as values (value |> steps[i]) works the same way. Built (see Call pipes): a step that fails stops the chain, which fails with it, and a task held as a value is called with its parentheses,value |> steps[i](). -
Calls the compiler can see through. A task value
that is a known task and never changes (
fixed f = square, a wrapper'snext) can be called directly rather than through the pointer, which lets C inline it: a higher-order task given a known task then costs what the loop written out costs. The same goes for a generic task given a task: making one copy per task given (as for types) removes the pointer entirely, at the cost of a larger program, so it would be chosen only for small tasks. -
A library of higher-order tasks: once lambdas and
generics meet,
stdcould givemap,keep(filter),fold,sort_by, andfindover slices, each taking the slice's region for what it makes, ascopy()does. -
A one-value lambda:
(x: int) -> int = x * 2, as acasegives its value with=, for the many lambdas that are one expression; and, where the place a lambda goes says its type, parameters without types:apply((x) = x * 2, 3). -
More tasks as values: a generic task given its
types,
first<int>; a method of every value named on its shape,Point.area, a(Point) -> float(a shared one,Point.new, is a value since 2026-10-04); and task types sending several values. Each is refused today with a reason. (Task types taking places and regions,(flex& int) -> nothingand(@r, int) -> [int]@r, are built since 2026-10-05.) - A table of startup decorators made when compiling: for a decorator that only records its tasks, the compiler could write the table itself, one entry per use with its values, into the program's data, with no call at start at all. The count of uses is known when compiling, so the table is an array of known size, and a decorator's registry needs no region that lasts.
-
Startup decorators that fail
Done: a decorator sending
nothing ~ E, whose failure stops the program beforemainwith its message, and$d?, whose failure is a notice on standard error while the program goes on;--no-noticesbuilds a program without such notices (see Decorators). -
Decorators on shared methods
Done (2026-10-04): startup
decorators and wrappers on shared methods, the only methods that
take startup decorators, since a member method has no value to be
called on where the program starts; a shared method named as a
value,
Point.new; and a shared method that is a decorator or a wrapper itself,$Registry.enroll(see Decorators). -
Wrappers on member methods
Done (2026-10-05): given the value
the method is called on first, as a place for an
altersmethod, and a method of the same type wrapping another,$(.checked), which alters the value exactly when the method it wraps does (see Wrappers on methods). -
Wrappers on methods of shapes taking types
Planned: a method of
shape Box<T>is copied for each set of types, as a task taking types is, and takes no wrappers yet. Each copy would get its own chain, the wrappers given the copy's types (a wrapper taking a group fits every copy, one written for one type fits only the copies of that type), which needs wrappers on copies of generic tasks first; decorators on generic tasks themselves are not wanted (the user's choice, 2026-10-01), so this stays to methods. -
Decorators on tasks taking places or regions
Done (2026-10-05): task types
taking
flex& Tandfixed& Tplaces and@rregions, task values, lambdas, wrappers and startup decorators of such tasks (see Places and regions). Nothing can keep a place or a region past the call, so passing them through a chain is as safe as passing them through calls. -
Type groups carrying places and regions
Done (2026-10-05): a group holds
places, read as copies and given on as places, and a region is
always named beside a group,
(@a, P...) -> R, with values named before and after a group in tasks and task types (see Places and regions in a group). A group holding regions is an idea in Regions in a group. - Decorators on shapes: a startup decorator on a shape could register the shape (for a serializer, say) once shapes can be named as values.
-
Type groups beyond wrappers
Done: groups asking their types
for something,
show(args...),args.len, a group's tuple of type(P...) ~ maybe P#0,each v in args, andt.#k(see Type groups). Still open: writing a group's tuple type,(P...), in a binding, and a loop over a group inside a loop over the same group (an idea in Working with a group's values, with matching a value by its type). A group's values given on to a named task,call(f, args...), is built: see Type groups. - Events: the events design names what runs when something happens; task values and lambdas are the pieces it would be built from.
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.
-
A region a shape given types may not need: whether
the values of a shape given types hold anything in a region depends
on the types given (
Box<text>@rdoes,Box<int>does not), and inside a declaration that takes types that cannot be told apart yet. SoBox<T>always names a region, and a task sending one back has to take one,task boxOf<T>(r: region, v: T) -> Box<T>@r, even where every use givesTa type that lives in no region: the region is dropped from the type made for that use, but the parameter stays, and the caller has to pass a region it has no use for. Two ways out: check the header again for each set of types and drop a region parameter nothing needs, which makes one declaration into headers that differ by their types; or let a header say a region is only needed for some types, which is a new rule to learn. Doing neither leaves the wart. The question to decide is whether real programs write enough of these tasks for the noise to be worth a rule (see Shapes that take types). -
Failing a loop at once: a loop that gives a value
fails with
LoopResult.Manual(m)only from the last line of its final pass (see Loops that give a value), and afail …on a line of its own does nothing. To stop it in the middle with a failure of the author's choosing, a program today gives thebreakthe value,break = "bad input", and reads it fromBroke. The idea (the author's, 2026-10-08) is a word that stops the loop there and fails with what follows, as<-leaves a task. The spellings suggested, and what each meets:-
<= fail m:<=is already "at most". Starting a line, as<-does, the compiler could tell them apart, but a reader sees the same two characters ina <= b. -
<@ fail m:@names a region everywhere else (text@r). -
<! fail m: no clash, but a new symbol where Vela prefers words. -
break = fail LoopResult.Manual(m): no new syntax, abreakwhose value is the failure itself, which the loop then fails with as it is, rather than asBroke.
What it would add. A failure that says why without folding it into the break's type. What it would cost. A second way to stop a loop with a value, beside
break = v, which already does the same with one morematch. To be decided once programs show how oftenbreak = vfalls short. -
-
Variants of a kind held in a kind: a kind whose
variant holds another kind is read through two matches today.
InputErroris the first in the language (Reading a number from input): itsNoNumberholds aParseError, so tellingNotANumberfromTooLargemeans matching the error, then matching what it holds. The idea is to let the inner variants be written directly, as if they were the outer kind's own, in acaseand in afail:; today match e case Ended reading = false case NoNumber why match why case NotANumber show("no number") case TooLarge show("too large") fi fi ; the idea match e case Ended reading = false case NotANumber ; InputError.NoNumber holding ParseError.NotANumber show("no number") case TooLarge show("too large") fi <- fail InputError.NotANumber ; short for fail InputError.NoNumber(ParseError.NotANumber)What it would add. The level of nesting costs nothing to read, so a kind can hold another kind wherever that is the honest shape without making every reader pay for it. It keeps two kinds from needing two words for one reason:
InputErrorholds aParseErrorexactly becauseNotANumberandTooLargeare already taken, and a flattenedcasemakes that free. It is not only for the builtin kinds — a program whose error kind wraps another module's error kind wants the same.What it would cost. Two spellings for one thing, which Vela has otherwise avoided:
case NoNumber whystill has to work, since a program may want the whole inner value to pass on, so the shortcut is a second way to write a case. Which name wins needs a rule when the outer kind and an inner one share a variant name, or two inner kinds do: refusing the ambiguous name is the safe answer, but it means adding a variant to one kind can break a program that matches another. Whether flattening goes one level deep or all the way needs deciding too, and a kind that holds itself (a tree) has no bottom, so the rule must stop somewhere. It reaches further than it looks: amatchcounts its cases against the variants of the kind, so the checker would count flattened ones,_would have to mean the rest of a flattened set, and Studio's case completion and itsmatchsnippet read variants from the file's own declarations. It also hides where a variant comes from: a reader ofcase TooLargecannot tell which kind it belongs to without opening the declarations, and the value it carries (if a flattened variant holds one) comes from a kind the case doesn't name. A smaller relative is worth deciding with it:case failure Endedstraight on aT ~ E, which today iscase failure eand then a match.The question to decide is whether kinds holding kinds turn out to be common enough in real programs to pay for a second spelling and a flattening rule, or whether the two matches are simply what a nested value costs.
Partly answered by kind imitation, built: a program's own kind that would wrap another can imitate it instead,
kind AppError::ParseErr, and has its variants as its own, flat, with one spelling and onematch, and a failure of the inner kind is sent on as the outer kind's. The builtinInputErrorstill holds itsParseError, and a kind that holds a value of another kind is still read through two matches. -
Types for kinds:
(done:
Kinds that take types,
decided for loops that give values, whose result is a kind taking
two types)
tasks and shapes take types today (Types a Declaration Takes), which leaves kinds as the one declaration that can't: no
kind Result<T>withOk(value: T)andErr(message: text), and nokind Tree<T>. It would fit the same syntax and the same words, and be made the same way (one kind per set of types), so the cost is not the idea but its reach: a kind's variants, their payloads, everymatchandcase, and the case forms Studio offers all follow the types, andmaybe TandT ~ Ealready cover what most generic kinds would be used for. The question to decide is whether a program that has shapes and tasks taking types still needs kinds taking them often enough to pay for that reach. -
Copies that share, knowingly: the refactor of the
variable words (
holdbecameflexandbindbecamefixed; then a newbindcame, naming one value whose contents may change) madefixedfully immutable. A slice, a map and a pointer are handles to items kept elsewhere, so aflexcopy of afixedone could change the items thefixedname sees. That is closed: giving what afixedname owns, a slice, a map or a pointer, to aflexname is an error (see What a fixed name owns), andbindfollows the same rule, so the program writes a complete copy instead (xs.copy() at r,new [T](xs) at r,m.copy() at r,xs.added(v) at randxs.without(i) at r, built: see A new slice with one change).What a complete copy costs.
copy()andnew [T](xs)are complete copies (see Copying into another region). It shares nothing with what it copies, at any depth: items that are slices or maps are copied too, and so is what each pointer points to. Pointers may form a cycle (next: maybe ^Node) or point two at one value, so the copy keeps a table of the values it has already copied and points to the copy it made before, which keeps the cycle and the sharing as they were, inside the copy. That is one lookup per pointer, a table that lives while the copy is made, and room in the region for every item and every value pointed to, all of it kept until the region ends. For a long list or a large graph, that is the whole structure again, every time.The idea. An operation, written so that it can't be missed, that makes a shallow copy on purpose: a
flexhandle to the items afixedname sees, at the price of the guarantee that afixedvalue never changes. Nothing about it is decided: not its syntax, not what it is allowed on, and not what it checks. Directions to weigh, some from the author's notes on data races:- A word for unsafe operations in general, of which this is the first, so that a reader finds every place a program gave up a guarantee by searching for it.
-
Checks where the program runs: the items the shared copy reaches
stay marked as owned by the
fixedname, and a write to them through the copy stops the program with a message, as an index out of range does. It costs a check on each write through the copy. -
The author's lock idea,
flex x!: int: a place that only one name may change, refused by the compiler where it can tell, and stopping the program where it can't. The same mark on a shared copy would say which side may write. - Plain sharing with no check at all, for code that measured the copy and can't pay for it, as C would do it.
The question to decide is whether real programs copy large
fixedvalues intoflexones often enough to need a way around the cost, and if so, which guarantee they give up and how loudly the program says so.
Suggested Roadmap
Now — Foundations Low Risk
Source spans and good error messages(done)-
Complete type checking and portable C output(done) -
Vela Studio: an editor with language support and a terminal(done: Vela Studio) -
Multiple results, then named results ((done: Several Results, Named Results)-> total: int, set withtotal <- value) -
Defined integer overflow and division by zero(done: they stop the program with a message) -
Fixed-size arrays and slices, which grow and shrink(done: arrays and slices),and a real(done: lengths, parts, joins,texttype"{n}" at r) -
Text in regions(done:text@r),reading input(done:scan),copies into another region(done:.copy()),and type conversions(done:text(n) at r,int(x)andfloat(n),int(t),scan("Age: ", int)) -
Every mistake in a file reported at once, not only the first(done: one error at a time) - Small region improvements: result-only regions, typed case forms, a lint for regions that grow in loops, and a chunk cache (ideas)
- Move-only owned pointers, on top of regions
-
Basic standard library (math, strings,
files: done,std/filesin the library)
Next — Growth Medium Risk
-
Module system(done: modules, with aliases andKind.Variant), then a package manager -
Methods on shapes and kinds(done: methods, withalters,.this, and@this) -
Tuples(done: tuples),generics for tasks and shapes(done: types a declaration takes),and for kinds(done: kinds that take types) -
Missing values and error handling(done:maybe T, plan B,T ~ E) -
Pattern-match expressions(done: a match or a when as a value, withelse whenchains) -
Region-based allocation(done: regions) -
Tasks as values, lambdas, decorators, and type groups(done: tasks as values, lambdas, decorators, type groups) -
Region brackets with inferred region arguments (
generated copies between regions: done,.copy())
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.