Vela Regions.

Memory without a garbage collector, a borrow checker, or free: what regions are, why Vela has them, and how to use them well.

This page is the long guide to Vela's memory model. The language reference has the short version in Memory and Regions; this page explains the idea behind it, answers which types need a region and which don't, walks through using it step by step, lists what it can't do yet (with a workaround for each), and collects ideas for where it could go. Every complete program on this page compiles and prints the output shown below it; the shorter pieces marked with an error are rejected with that message; and the code under Ideas is proposed syntax that doesn't exist yet.

1. The idea

A region is a named place to put memory. You declare one with region r, and new T(value) at r copies a value into it and gives you a pointer to the copy. Nothing in a region is freed on its own. Instead, the whole region is freed at once, when the block that declared it ends.

Think of a whiteboard in a meeting room: during the meeting anyone can write on it, and when the meeting ends it is wiped clean in one go. Nobody erases single words, and nobody can read the board after it was wiped, because the meeting (the block) is over.

What makes Vela's regions safe is that the region's name is part of every pointer's type. A ^Node@nodes is "a pointer to a Node in region nodes". A type can only name a region that exists where the type is written, so a pointer can never be kept anywhere that lives longer than its region. The compiler checks this with a few simple rules, and nothing is checked at run time.

task main()
    region scratch                     ; a new, empty region
    fixed n = new int(42) at scratch    ; n has type ^int@scratch
    show(n^)                           ; 42
fi                                     ; scratch, and everything in it, is freed here

The whole model in five rules

  1. region r declares a region; region r1, r2, ... declares several on one line; new T(v) at r, or new@r T(v), allocates in it. region r default makes r the block's default, and then what allocates there names no region.
  2. A pointer into r has type ^T@r, and a type can only name regions visible where it is written.
  3. When the block declaring r ends, however it ends, everything in r is freed.
  4. A task allocates only in a region it takes as a parameter (r: region); it can read and link pointers in any region its parameters' types name.
  5. A pointer is never stored in a different region than the one its type names.

2. Why Vela has regions

Most Vela values don't need any of this. Numbers, bools, shapes, and kinds are copied when they are assigned or passed, and live in the block that declares them (When a type needs a region is the full answer to which values do). But some data can't work that way:

These need pointers to memory allocated somewhere else. Before regions, Vela did this the way C does: new allocated a value, and free(p) gave it back. That is simple, and it is where the classic memory bugs come from:

; Vela before regions: this no longer compiles.
task main()
    fixed p = new int(1)
    free(p)
    show(p^)          ; use after free: reads memory that may belong to something else
    free(p)           ; double free: corrupts the allocator
    fixed q = new int(2)
fi                    ; q is never freed: a leak

Nothing in the language stopped any of these. Languages solve this in a few ways, and each has a price:

Approach Used by What it costs
Garbage collection Go, Java, C# A runtime that pauses or slows the program, and memory freed "later" rather than at a known point
Reference counting Swift, Python A counter update on every copy, and cycles that leak unless broken by hand
Borrow checker Rust Lifetimes and borrowing rules to learn, and a large, complex checker
Move-only ownership Linear type systems Values can't be freely shared or copied, which makes ordinary code awkward
Regions Vela, Cyclone, arenas in Zig and C Memory is freed a region at a time, not a value at a time

Vela chose regions (the full comparison is in Future Directions), because they fit what Vela wants to be:

3. What regions guarantee

If a Vela program compiles:

What they don't promise:

4. Pros and cons

Pros

  • Memory safety checked entirely at compile time, with nothing to pay at run time
  • Very fast allocation (a pointer bump) and freeing (one call per chunk of memory, not per value)
  • Good cache locality: values allocated together sit next to each other
  • Lifetimes are blocks you can see, named in types you can read
  • Few rules to learn; no lifetime annotations beyond a region's name
  • Tasks that only read are generic over regions for free (no extra argument), as they are over the types they take (Types a declaration takes)
  • A natural fit for work that happens in phases: a request, a frame, a loop pass, a compiler pass

Cons

  • One value can't be freed early; memory goes a whole region at a time
  • Data that changes over a long run needs care, or its region keeps growing
  • A value holds pointers into one region only, and a pointer can't be copied into another region
  • Region names appear in signatures, and tasks that allocate take an extra argument (though a block can name a default region, so the lines inside it needn't repeat it)
  • Some safe programs are rejected (see Limitations)
  • Regions aren't values: a program can't choose a region at run time or keep a list of regions

5. When a type needs a region

The question that comes up first, and keeps coming up, is: does this type name a region or not? The short answer is the one you would guess — memory that would be on the heap in C goes in a region — but it is worth seeing why, because the rule then answers every case, including the one type that goes both ways.

Where a value's bytes are

Every byte in a running Vela program is in one of three places, and each has its own answer to "when is it freed?":

  1. In the value itself. A number, a bool, an array of a known size, a shape of such fields: the bytes sit in the variable, the field, or the parameter that holds them, and are copied when the value is. Their size is known as the program is compiled. Freed when the variable's block ends, automatically, with nothing to say about it. In C: a local variable or a field.
  2. In the program. The bytes of a text literal are in the compiled program, put there before main starts. Never freed. In C: a string literal in static storage.
  3. In a region. Bytes the program asked for while it was running, because their size or their number wasn't known before: what a pointer points at, a slice's items, text built out of other text. Freed when the region's block ends. In C: the heap, taken from a region's chunks.

Only the third kind needs a name, and that is the whole reason regions are in types. For the first two, the compiler already knows the answer to "when do these bytes go away?" — with the block, or never. For the third, the answer is "when that region ends", and a pointer can be stored anywhere; without the region's name in the type, there would be nothing to compare when the pointer is stored somewhere longer-lived. So:

A type names a region exactly when a value of it can reach bytes the program made while it was running. Three types do: a pointer ^T@r, a slice [T]@r, and text made rather than written, text@r. Every other type names a region only because it holds one of those.

Note what this is not about. It isn't about how big the value is: a [int#1000] is eight thousand bytes and needs no region, because they are part of the value. It isn't about being changed: a flex variable of any plain type changes in place. And it isn't about being passed around: plain values are copied into a task and copied back out, which needs no shared memory at all.

Which types name a region

The type Names a region? Where its bytes are
int, float, bool No In the value; a size known as the program is compiled
[int#4], an array No In the value: four ints side by side
Point, a shape of plain fields No In the value; Point@r is an error
Shade, a kind whose variants hold nothing No In the value: which variant it is
text No In the program: a literal, or a part of one, that lasts as long as the program
maybe int, int ~ ParseError No In the value: the int, and which of the two it is
^int@r, a pointer Yes The int is in r; the pointer to it is in the value
[int]@r, a slice Yes The items are in r; the pointer and the count are in the value
text@r Yes Bytes made while the program ran, in r
Node@r, a shape or kind holding a pointer or a slice Yes, always What it points at is in r, and all of it in the one region
Person or Person@r, holding text and no pointers Either Whichever its text is: Person holds text that lasts, Person@r text in r
maybe ^Node@r, ^Node@r ~ text Yes maybe and ~ wrap a type without changing where it lives
(int, text@r), a tuple If an item does Each item keeps its own; a tuple holds no pointers or slices, since its items never change
a field, next: maybe ^Node Never written The region of the value that holds it, so a field never names one
r: region It is one A region isn't a value: it can't be stored, sent, or chosen while running

Nothing in this program is in a region, so no region is declared and no type names one:

; Every value here lives where it is written, and its text is part of the program.
shape Point
    x: int
    y: int
fi

task moved(p: Point, by: int) -> Point
    <- Point { x: p.x + by, y: p.y + by }
fi

task main()
    fixed count = 41                    ; int: in the variable
    fixed ratio = 0.5                   ; float: in the variable
    fixed grid: [int#3] = [count#3]   ; an array: three ints, in the variable
    fixed here = Point { x: 1, y: 2 }    ; a shape of plain fields: in the variable
    fixed label = "point"               ; text: in the program, and never freed
    fixed pair = (count, label)         ; a tuple: each item where it belongs
    show(label, " ", moved(here, 1).x, " ", grid[0] + count, " ", ratio, " ", pair.0)
fi
point 2 82 0.5 41

Everything in this one is made while the program runs, so every type says where: a pointer, text built from other text, a slice, and a shape that holds them.

shape Note
    title: text                ; in the region of the Note that holds it
    next: maybe ^Note            ; a field never names a region
fi

task main()
    region notes
    fixed count = new int(7) at notes          ; ^int@notes: the int is in notes
    fixed number = count^.str() at notes       ; text@notes: new bytes, made here
    fixed title = "note " + number at notes     ; text@notes: a join makes new text
    fixed first = new Note(Note { title: title, next: none }) at notes
    fixed second = new Note(Note { title: "second", next: first }) at notes
    flex titles = new [text]() at notes        ; [text]@notes: the items are in notes
    titles.add(second^.title)
    titles.add(first^.title)
    show(titles.len, " ", titles[0], " ", titles[1], " ", count^)
fi
2 second note 7 7

Text, the one that goes both ways

Text is the one type the answer isn't settled for: the same text is sometimes in a region and sometimes not, and a shape or kind holding text follows its text. That is why it causes most of the questions, and the reason is simple: a text is a pointer and a length, and the bytes it points at are either in the program (a literal, written in the source, there before the program starts) or in a region (made while running, out of other text or out of a number). So the test is not what the text says but where its bytes came from: text you wrote needs no region; text the program made needs one.

What you write What you get A region?
"hello" text No: the bytes are in the program
t[a..b] A view: the same bytes, not copied Whatever t is: none for a literal, @r for a text@r
t.len, t == u, t < u int, bool No: nothing new is made
a + b New text, the two joined Yes
"{name} is {age}" New text, the values written into it Yes
x.str(), text(x) New text: a value written out Yes
scan(prompt) maybe text: a line read, or none Yes
scan(prompt, int) int ~ InputError No: a number is no memory
int(t), float(t) int ~ ParseError, float ~ ParseError No
show(a, b, c) Printed, nothing given back No: the line gets a region of its own, freed as soon as it is printed
t.copy() New text, its own bytes Yes, the region the copy goes into
task main()
    region r
    fixed greeting = "Hello, world"              ; text: written, so it lasts
    fixed hello = greeting[0..5]                 ; text: the same bytes, so still no region
    fixed name = "Ada"
    fixed line = greeting + " and " + name at r   ; text@r: new bytes
    fixed age = 42.str() at r                    ; text@r: a number written out
    fixed both = "{hello}, {name}" at r           ; text@r: values written into text
    show(line)
    show(both, " ", age, " ", greeting.len, " ", hello == "Hello")
fi
Hello, world and Ada
Hello, Ada 42 12 true

The two never mix in the wrong direction. Text that lasts fits anywhere text of a region is expected, since it outlives every region; text of a region can't be kept in a text, which would outlive it (that limitation, and .copy() is the way across). And a task that reads text of a region names the region in its header, title: text@r, exactly as it would for a pointer.

What a value holds decides for it

A shape or a kind is in a region when anything it holds is — a pointer, a slice, text made while running, or another shape or kind that is. Its fields never name a region: they are all in the one region of the value that holds them, which is what Node@r says. That is also why one value can't hold pointers into two regions (this limitation): @r is one name for all of it.

A shape holding only text is the case where both exist, and the same declaration serves both: Person is a person whose name is a literal, Person@r one whose name was read or built in r. A kind is the same, variant by variant: Status.Todo holds nothing and fits any region, Status.Blocked(reason) holds text, so it is in the region that text is in.

Tuples work the other way around, because their items never change: a tuple holds no pointer and no slice at all, and each item keeps its own type, so (text@a, text@b) — text of two different regions in one value — is fine, where a shape with two such fields is not. maybe and ~ only wrap: whether maybe ^Node@r is in a region is decided by the ^Node@r inside it.

One case looks like a region and isn't: a value built with no pointers in it yet, such as Leaf(1) or Node { value: 1, next: none }. It fits into any region, so nothing in it fixes one; that is why such a value needs its region written where it is bound (flex head: Node@nodes = …), and why the compiler writes @_ for "no region" when it talks about one (this limitation).

When a region is an argument, and when a name is enough

The second half of the question is about headers. A task can always read data in a region it merely names in a type; it can only allocate in a region it was given as a parameter or declared itself. So the test is not whether a task touches memory in a region, but whether it makes any:

What the task does with memory What its header writes What the call costs
Nothing: plain values in, plain values out No region at all: task twice(n: int) -> int Nothing
Reads, walks, or links data the caller made Names the region in a type: list: maybe ^Node@r Nothing: no argument is passed, and one task serves every region
Makes data the caller keeps Takes the region: r: region, or r: region default One pointer argument
Makes data only it uses Declares its own: region work inside the body Nothing; the region is freed before the task returns
Sends a value of a region without making anything Still needs a parameter naming that region (why) Nothing, or one pointer for a region parameter it never allocates in
A method reading what its value holds @this, the region its value lives in Nothing: each call works it out from the value
A method that makes something A region parameter as well: made(r: region, …) One pointer argument
shape Node
    value: int
    next: maybe ^Node
fi

; Makes data for its caller, so the caller's region is an argument.
task push(r: region, list: maybe ^Node@r, value: int) -> ^Node@r
    <- new Node(Node { value: value, next: list }) at r
fi

; Only reads what the caller made: it names the region and is given nothing.
task total(list: maybe ^Node@r) -> int
    when list is {node}
        <- node^.value + total(node^.next)
    fi
    <- 0
fi

; Adds what the list holds to a slice of the caller, in the region it takes.
task gather(list: maybe ^Node@r, flex& seen: [int]@w, w: region)
    when list is {node}
        seen.add(node^.value)
        gather(node^.next, &seen, w)
    fi
fi

; Allocates only for itself: a region of its own, and a result holding nothing.
task largest(list: maybe ^Node@r) -> int
    region work
    flex seen = new [int]() at work
    gather(list, &seen, work)
    flex most = 0
    each v in seen
        when v > most
            most = v
        fi
    fi
    <- most
fi

; Plain values only: no region anywhere in the header.
task twice(n: int) -> int
    <- n * 2
fi

task main()
    region nodes
    flex list: maybe ^Node@nodes = none
    each i in 1..4
        list = push(nodes, list, i)
    fi
    show(total(list), " ", largest(list), " ", twice(total(list)))
fi
6 3 12

largest is the one to notice: it allocates a slice on every call, and its header says nothing about regions, because that memory is nobody else's business — it is freed at the task's fi. Taking r: region there would be wrong twice over: the caller would pay for it, and the scratch items would stay in the caller's region until its block ended.

Three rules follow from the same place, and each has its own message when it is broken:

Where a region is an argument, it is passed by name at the call, push(nodes, list, i), even inside a block with a default region: a default settles where an allocation goes, not which region a task is handed. And .copy() takes its region from the place the copy goes, so a copy needs one written only where nothing else names it.

Deciding, in order

  1. Does the value hold a pointer, a slice, or text the program made? If not, there is no region to write, and nothing to pass: use it as a plain value.
  2. If it does, when should its memory go away? Declare the region in the block that ends then: the top of main for data the whole program uses, inside the loop for data of one pass.
  3. Who makes it? The task that allocates needs the region — as a parameter when the caller keeps the result, as its own region when the memory is scratch.
  4. Who else touches it? Everything that only reads, walks, or links it names the region in its types and takes nothing.
  5. Does one region do nearly all the work in the block? Then name it the block's default and let the lines that go elsewhere be the ones that say so.

Questions that come up

6. Tutorial

The lessons build on each other. Each program is complete: save it as a .vela file and run it with vela file.vela -o file && ./file, or open it in Vela Studio and press F5.

Lesson 1: A first region

task main()
    region scratch
    flex count = new int(41) at scratch
    count^ = count^ + 1
    show(count^)
fi
42

region scratch is a statement: from here to the end of the block (the task), scratch is a region you can allocate in. new int(41) at scratch copies 41 into it and gives back a pointer, so count has type ^int@scratch. count^ is the value it points to, which you can read and write, since count is a flex: through a fixed, nothing changes, not even the value it points to. At the task's fi, the region is freed.

Lesson 2: Pointer types name their region

shape Point
    x: int
    y: int
fi

task main()
    region points
    flex p: ^Point@points = new Point(Point { x: 1, y: 2 }) at points
    flex q = new@points Point(Point { x: 3, y: 4 })
    p^.x = p^.x + q^.x
    show(p^.x)

    flex spare: maybe ^Point@points = none
    when spare is {s}
        show(s^.y)
    else
        show("no spare yet")
    fi
    spare = q
    show(spare { p }^.y)
fi
4
no spare yet
4

Lesson 3: When memory is freed

task first_square_over(limit: int) -> int
    region work
    each i in 0..100
        fixed square = new int(i * i) at work
        when square^ > limit
            <- square^
        fi
    fi
    <- -1
fi

task main()
    region outer
    flex total = new int(0) at outer
    each round in 0..3
        region pass
        fixed step = new int(round * 10) at pass
        total^ = total^ + step^
    fi
    show(total^)
    show(first_square_over(50))

    fixed missing: maybe int = none
    fixed rescued = missing {
        region rescue
        fixed spare = new int(7) at rescue
        spare^ * 6
    }
    show(rescued)
fi
30
64
42

A region is freed when the block that declares it ends, whichever way it ends:

Notice that total lives in outer, declared before the loop: values that must survive every pass go in a region outside the loop, and values for one pass go in a region inside it.

Lesson 4: Linked data

shape Node
    value: int
    next: maybe ^Node
fi

task push(r: region, list: maybe ^Node@r, value: int) -> ^Node@r
    <- new Node(Node { value: value, next: list }) at r
fi

task sum(list: maybe ^Node@r) -> int
    when list is {node}
        <- node^.value + sum(node^.next)
    fi
    <- 0
fi

task main()
    region nodes
    flex list: maybe ^Node@nodes = none
    each i in 1..6
        list = push(nodes, list, i)
    fi
    show(sum(list))
    show(sum(none))
fi
15
0

Lesson 5: Tasks and regions

How a task mentions a region decides what it can do with it:

In the header Meaning The task can
r: region A region the caller passes by name Allocate in r, read and link pointers in it, send pointers into it
list: maybe ^Node@r only Whatever region the caller's argument is in Read and link pointers in r, send pointers into it; not allocate
-> ^Node@r The result is in r Only if a parameter names r
flex& list: maybe ^Node@r The caller's own place, given as &list Change it, putting in it only pointers into r
shape Node
    value: int
    next: maybe ^Node
fi

task push(r: region, list: maybe ^Node@r, value: int) -> ^Node@r
    <- new Node(Node { value: value, next: list }) at r
fi

task length(list: maybe ^Node@r) -> int
    when list is {node}
        <- 1 + length(node^.next)
    fi
    <- 0
fi

; Two regions, which may be different.
task longer(a: maybe ^Node@x, b: maybe ^Node@y) -> int
    when length(a) > length(b)
        <- length(a)
    fi
    <- length(b)
fi

; One name twice: both lists must be in the same region.
task join(flex last: ^Node@r, flex rest: maybe ^Node@r)
    last^.next = rest
fi

; A region passed on under another name.
task pair(into: region, value: int) -> ^Node@into
    <- push(into, push(into, none, value), value * 2)
fi

task main()
    region small
    region big
    flex a = push(small, none, 1)
    fixed b = push(big, push(big, none, 2), 3)
    show(longer(a, b))
    join(a, pair(small, 10))
    show(length(a))
fi
2
3

A parameter that changes the caller's value is written flex&, and the call gives the place with & (see Parameters that change). Its type names a region like any other, so what the task puts there stays in that region: prepend links a new node into the caller's list, and grow adds to the caller's slice, both in r. A place of another region is refused, since the task would leave a pointer to r in it: given a list of a region s, prepend(&other, r, 3) is expected region 's', got 'r'.

shape Node
    value: int
    next: maybe ^Node
fi

task prepend(flex& list: maybe ^Node@r, r: region, value: int)
    list = new Node(Node { value: value, next: list }) at r
fi

task grow(flex& xs: [int]@r, r: region)
    xs.add(xs.len)
fi

task main()
    region r
    flex list: maybe ^Node@r = none
    prepend(&list, r, 1)
    prepend(&list, r, 2)
    when list is {first}
        show(first^.value)
    fi
    flex xs = new [int]() at r
    grow(&xs, r)
    grow(&xs, r)
    show(xs)
fi
2
[0, 1]

A task that takes its region last can be given it after the call instead, as new is: copy(tree) at kept or copy(tree)@kept is copy(tree, kept). After a + chain of text, at r is the whole chain's, and a part names its own with @ (see The region after a call); with a block's default region, a call that names none uses it.

Lesson 6: Shapes and kinds that hold pointers

shape Node
    value: int
    next: maybe ^Node
fi

kind Tree
    Leaf(value: int)
    Branch(left: ^Tree, right: ^Tree)
fi

task build(r: region, depth: int) -> Tree@r
    when depth == 0
        <- Leaf(1)
    fi
    <- Branch(new Tree(build(r, depth - 1)) at r, new Tree(build(r, depth - 1)) at r)
fi

task leaves(t: Tree@r) -> int
    match t
    case Leaf value
        <- value
    case Branch left right
        <- leaves(left^) + leaves(right^)
    fi
fi

task main()
    region trees
    show(leaves(build(trees, 4)))
    show(leaves(Leaf(5)))

    flex head: Node@trees = Node { value: 1, next: none }
    head.next = new Node(Node { value: 2, next: none }) at trees
    when head.next is {second}
        show(head.value + second^.value)
    fi
fi
16
5
3

A shape or kind that holds pointers (in its fields, or in shapes and kinds it holds by value) lives in a region, written after its name: Tree@r, Node@trees. Everything one value holds is in that one region, so reading head.next of a Node@trees gives a maybe ^Node@trees. A shape without pointers, like Point, lives in no region, and Point@r is an error.

A value built without any pointers, like Leaf(5) or Node { value: 1, next: none }, can go in any region: it takes the region of the place it goes. build can send Leaf(1) as a Tree@r, and leaves(Leaf(5)) works. A binding without a written type can't tell which region such a value is meant for, so head is written head: Node@trees.

Text in a region. Text has a lifetime as well: text lasts as long as the program, as a literal does, and text@r is in region r. Text a value in a region holds is in that region, whatever it was made from, so the title of an Item@items is a text@items:

; A to-do item in a region holds its text there, whatever the text was made from.
kind Status
    Todo
    Blocked(reason: text)
fi

shape Item
    title: text
    status: Status
    next: maybe ^Item
fi

; Names the region, so it takes a status of any region, and sends text of it.
task describe(s: Status@r) -> text@r
    match s
    case Todo
        <- "todo"
    case Blocked reason
        <- reason
    fi
fi

task main()
    region items
    fixed first = new Item(Item { title: "write", status: Status.Todo, next: none }) at items
    fixed second = new Item(Item { title: "test", status: Status.Blocked("no compiler"), next: first }) at items
    show(describe(second^.status))
    fixed title = first^.title
    show(title)
    flex chosen = "nothing"
    show(chosen == title)
fi
no compiler
write
false

Text that lasts fits wherever text of a region is expected (the literals above go into items), and text of a region never goes where it could outlive the region: chosen = title would be an error, since chosen is a text. A task that reads text of a region names one, as it would for a pointer: describe takes a Status@r and sends a text@r, and still takes Status.Todo, whose text lasts. Text read with scan("…") at r is new text, so it is in the region scan names, and so is text joined with a + b at r, made from a value with n.str() at r or text(n) at r, or holding values, "{name} is {age}" at r; a part of text, t[a..b], is the same bytes, so it stays in the region the text is in. A number read from input, scan("Age: ", int), is no memory at all, so that form of scan names no region. A shape or kind holding text but no pointers may name a region or not: Person holds text that lasts, Person@r text in r. Text, the one that goes both ways lists every form that makes new text and every one that doesn't.

One region is made without being written: show(card) writes a shape or kind with its own str, and that text goes in a region the line gets for itself, freed as soon as the line ends. It is the region a program would have declared around the show and thrown away after it, so printing a value of your own needs no region of yours; a region you declare is still the place for text you keep, pass on, or send back.

A method of such a shape or kind is called on a value in some region, which its header and body name @this. It can send what the value holds there, and take pointers into it, and each call works out which region that is from the value. It isn't given the region itself, so it can't allocate there; to make something, it takes a region, as a task does:

shape Node
    value: int
fi

shape Named
    name: text
    node: ^Node

    nameOf() -> text@this
        <- .name
    fi

    alters point(flex to: ^Node@this)
        .node = to
    fi

    made(r: region, value: int) -> ^Node@r
        <- new Node(Node { value: value + .node^.value }) at r
    fi
fi

task main()
    region people
    flex ada: Named@people = Named { name: "Ada".copy() at people, node: new Node(Node { value: 1 }) at people }
    show(ada.nameOf())
    ada.point(new Node(Node { value: 5 }) at people)
    region scratch
    fixed sum = ada.made(scratch, 10)
    show(sum^.value)
fi
Ada
15

ada.point(…) with a node of scratch is an error: point takes its node in @this, which for ada is people. A method that should grow its value, such as adding to a slice the value holds, can't yet (see below).

Lesson 7: Regions with errors and maybe values

shape Node
    value: int
    next: maybe ^Node
fi

task parse_node(r: region, digit: int) -> ^Node@r ~ text
    when digit < 0 or digit > 9
        <- fail "not a digit"
    fi
    <- new Node(Node { value: digit, next: none }) at r
fi

task main()
    region nodes
    fixed good = parse_node(nodes, 7) { <- }
    show(good^.value)
    fixed fallback = parse_node(nodes, 42) {
        failure reason
        show(reason)
        good
    }
    show(fallback^.value)
fi
7
not a digit
7

Regions combine with maybe and T ~ E as you'd expect: ^Node@r ~ text is a pointer into r or a text error. The @r belongs to the pointer, so it comes before ~. A plan B's fallback must be in the same region as the value it replaces: good is also in nodes.

Lesson 8: Scratch work, and keeping what survives

A common shape for a program is: do some work that creates a lot of temporary data, keep a small result, and throw the rest away. With regions, the temporary data goes in a region inside the work's block, and the result is copied into a region outside it:

shape Node
    value: int
    next: maybe ^Node
fi

task push(r: region, list: maybe ^Node@r, value: int) -> ^Node@r
    <- new Node(Node { value: value, next: list }) at r
fi

task sum(list: maybe ^Node@r) -> int
    when list is {node}
        <- node^.value + sum(node^.next)
    fi
    <- 0
fi

task main()
    region kept
    flex best: maybe ^Node@kept = none
    each round in 0..3
        region work
        flex draft: maybe ^Node@work = none
        each i in 0..round + 2
            draft = push(work, draft, i)
        fi
        when sum(draft) > sum(best)
            best = draft.copy()                    ; every node, into kept
        fi
    fi
    show(sum(best))
fi
6

A pointer into work can't be stored in best, which lives in kept (that would leave best pointing at freed memory after the pass). So draft.copy() copies the list into kept, the region best names: every node, following the pointers. Each pass's draft is freed at the end of the pass, so only the lists that were copied stay allocated. A copy keeps a node two pointers share as one node, and copies a cycle as a cycle; where nothing names the region to copy into, write it: draft.copy() at kept (or draft.copy()@kept). See Copying into another region for what a copy covers.

Lesson 9: Slices in a region

An array in a variable, [int#4], is a value like any other and needs no region. When the number of items is known only as the program runs, they go in a region, as a slice: [int]@r is a pointer to the first and a count, and follows every rule a pointer does. new [int](…) at r makes one, copying the items in; xs[a..b] views part of it without copying:

; The first n Fibonacci numbers, in the caller's region.
task fibs(r: region, n: int) -> [int]@r
    flex out = new [int]([1#n]) at r
    each i in 2..n
        out[i] = out[i - 1] + out[i - 2]
    fi
    <- out
fi

; Only reads, so it names the region without taking it.
task sum(xs: [int]@r) -> int
    flex total = 0
    each x in xs
        total = total + x
    fi
    <- total
fi

task main()
    region kept
    fixed numbers = fibs(kept, 10)
    show(numbers[9])
    fixed tail = numbers[5..numbers.len]
    show(sum(tail))
    each round in 0..2
        region work
        flex scratch = new [int](tail) at work
        scratch[0] = 0
        show(sum(scratch))
    fi
    show(tail[0])
fi
55
131
123
123
8

tail is a view of numbers' last five items, in kept. Each pass copies it into work, changes the copy, and frees it at the end of the pass, so tail is unchanged. Storing scratch in a variable of type [int]@kept would be the same error as for a pointer into work. An array in a variable can't be sliced, since it isn't in a region: copy it with new [int](xs) at r, or allocate it there, new [int#4](xs) at r, and slice what the pointer points to (p^[0..2]).

A slice can also grow and shrink: xs.add(v) puts a value last, xs.add(i, v) before item i, and xs.del(i) and xs.del() take an item out and give it. new [int]() at r starts with no items. Adding may need more memory, which comes from the slice's own region, so a task that adds needs that region, as one that calls new does:

; The primes below n, added as they are found, in the caller's region.
task primes(r: region, n: int) -> [int]@r
    flex found = new [int]() at r
    each candidate in 2..n
        flex prime = true
        each p in found
            when candidate % p == 0
                prime = false
            fi
        fi
        when prime
            found.add(candidate)
        fi
    fi
    <- found
fi

task main()
    region kept
    flex numbers = primes(kept, 30)
    show(numbers.len)
    show(numbers.del())
    numbers.add(0, 1)
    show(numbers[0] + numbers[1])
    each round in 0..2
        region work
        flex stack = new [int](numbers) at work
        loop stack.len > 1
            stack.add(stack.del() + stack.del())
        fi
        show(stack[0])
    fi
    show(numbers.len)
fi
10
29
3
101
101
10

found gets its items in kept, the region primes is given. Its first add makes a block with room for more, and later ones fill that room; a full block is replaced by one twice as large, whose items are copied in, and the old one stays in the region until the region is freed (or grows in place, when nothing was allocated after it). Each pass builds stack in work, so its blocks are freed with the pass, and numbers is unchanged: a copy into another region never shares items. A fixed slice, a plain or fixed parameter, or a part xs[a..b] can't change its length. A flex parameter changes the task's own copy; a task changes its caller's slice through a flex& parameter, as grow does in Lesson 5, or through a pointer to what holds it, as in bag^.items.add(v).

Lesson 10: A default region

Writing at r on every line that allocates says where memory goes, and in a block where nearly everything goes to one region it says it over and over. A block may name one of its regions its default: what allocates there needs no at r, and only the lines that go somewhere else name a region.

task collect(r: region default, upto: int) -> [text]@r
    flex lines = new [text]()             ; in r, the region this task was given
    each n in 1..upto
        lines.add("line " + n.str())      ; the join and the number, both in r
    fi
    <- lines
fi

task main()
    region kept default
    fixed lines = collect(kept, 4)
    each line in lines
        show(line)
    fi
    each round in 0..2
        region pass default              ; the inner block's own default
        fixed note = "pass " + round.str()  ; text@pass: freed at the end of the pass
        show(note)
    fi
    fixed after = new int(1)             ; ^int@kept: the outer default again
    show(after^)
fi
line 1
line 2
line 3
pass 0
pass 1
1

The rules are few, and the types don't change: a value allocated in the default still has the region in its type (^int@kept), which is what keeps it from outliving its memory, and what hover in Vela Studio shows.

When to reach for it: a task or a block where one region does nearly all the work. When not to: a block that allocates into two regions in turn, where the names are the point — as in Lesson 8, where scratch work and what survives sit side by side.

Lesson 11: Choosing regions

Rules of thumb, once which types need a region is settled:

Lesson 12: Reading the compiler's messages

A run reports every mistake it finds in a file, in the order the lines come, each with the line quoted and the place marked; past twenty it prints the first twenty and 3 more mistakes, not shown; fix these first and check again. Region mistakes come in groups — one wrong region name can make several lines complain — so it is worth reading the first of a group before changing anything. These are the ones that come up most:

Message (shortened) What it means, and the fix
'new' needs a region to allocate in Add at r (or write new@r), or make a region the block's default.
this block already has a default region, 'first', named at 3:12 A block names one default at most. Write at r on the lines that go elsewhere, or declare that region in an inner block.
only a region can be a task's default region default marks a region parameter, as in r: region default; nothing else takes it.
a pointer lives in a region, so its type names one Write the region: ^int@r, not ^int.
a slice lives in a region, so its type names one Write the region: [int]@r, not [int].
a slice points into a region, but this array isn't in one The array is in a variable. Copy it into a slice with new [int](xs) at r, or allocate the array in a region and slice p^[a..b].
a tuple's items never change, so it can't hold a pointer (^int@a), whose value can change A tuple holds no pointer and no slice. Keep them in a shape, whose fields can change, or hold the values themselves. Text of a region is fine in a tuple.
expected type text, got text@r; this text is in region 'r', and text here must last as long as the program Text of a region is going somewhere that outlives the region. Keep it in a text@r (or a Person@r), or, for a task's parameter or result, name the region in the header.
adding to a slice may need more room in its region 'r', which this task only names in its types The task adds to a slice in a region it wasn't given. Take the region as a parameter, r: region, and pass it from the caller.
'add' changes 'xs', which is immutable A fixed slice can't change its length; keep it in a flex variable. A parameter gets its own message: write it flex xs to change the task's own copy, or flex& xs, given &xs, to change the caller's.
'Node' holds pointers, so it lives in a region Write Node@r where the shape is used.
'Point' holds no pointers or text, so it lives in no region Remove @r: plain values need none.
a field lives in the region of the value holding it In a shape or kind, write ^Node, not ^Node@r.
unknown region 'r'; declare it with 'region r' The region isn't visible here: it is declared in another block, later, or not at all.
expected type ^int@outer, got ^int@inner; a value can't move to another region A pointer would be stored where it could outlive its region, or in a different region. Allocate in the right region, or store a copy: p.copy().
can't tell which region to copy into Nothing the copy goes into names a region (a fixed without a type, a comparison). Name it: x.copy() at r. A block's default region doesn't settle a copy, since where the value goes says more.
'copy' copies what a value holds in a region, but this value is int The value holds no text, slice, or pointer, so it needs no copy: use it as it is.
this task can't allocate in 'r', which it only names in its types Take the region as a parameter: r: region.
the result lives in region 'r', which the task isn't given A result can't point into the task's own region; take r: region from the caller.
cannot tell which region 'x' lives in The value holds no pointers yet; write the binding's type, x: Node@r.
'r' is a region, not a value Regions can only be passed to a region parameter or used after at/@.
expected type maybe ^Node@_, got ^Node@a @_ means "no region": the value was made without any pointer into one, so it can't be given a pointer (see this limitation).
method 'Named.point' takes 'to' in '@this', the region the value it is called on lives in A method's parameter is in the value's own region. Allocate the argument where the value lives.
'@this' is the region the value this method is called on lives in, which a method isn't given, so it can't allocate there Grow the value where it is kept, outside the method (see this limitation).
this value is in region 'r', which is freed when the case ends, so it can't be the match's value A match or a when used as a value gives it from a region declared inside a branch. Declare the region outside, or copy the value into one that lasts: x.copy() at kept.
Vela has no 'free' Memory is freed when the region's block ends; use a smaller region to free sooner.

7. How it runs

A region is a list of chunks of memory. new takes the next free bytes of the newest chunk, aligned for the type, and copies the value in: a few instructions. When the chunk is full, a new one is added, twice as large as the last, starting at 4 KB and growing up to 1 MB. An allocation too large for that gets a chunk of exactly its size, placed behind the newest chunk, which keeps serving small allocations. Freeing a region frees its chunks, however many values they hold. A region that never allocates costs nothing.

The generated C for Lesson 1's main (vela 01_first.vela --emit-c):

void vela_main(void) {
    vela__rt__region scratch[1] = { VELA__RT__REGION_EMPTY };
    int64_t* count = ((int64_t*)vela__rt__new(scratch, (int64_t[1]){ 41 }, sizeof(int64_t), _Alignof(int64_t)));
    (*(count)) = (vela__rt__add((*(count)), 1, "4:21 in task 'main'"));
    vela__rt__show_int((*(count)));
    vela__rt__region_free(scratch);
    return;
}

A region parameter is a plain vela__rt__region*; region names in types disappear, since C doesn't need them. A copy (value.copy()) is a generated function per type that copies the value's bytes, then its text, its slices' items, and what its pointers point to; each pointed-to value is copied once, remembered in a table, and waits in a list rather than in a C call, so a long list copies without deep recursion. The allocator is about a hundred lines in runtime/vela_rt.h, and the test suite runs the region programs in tests/run and examples under AddressSanitizer and UndefinedBehaviorSanitizer, with leak detection.

8. Limitations and workarounds

Regions trade some flexibility for simplicity. Some of these limits are part of the model; others reject programs that would be safe, because the rules that would accept them would be more complicated. Ideas for regions discusses how each could be lifted.

One value can't be freed early

Memory is returned a whole region at a time; free(p) is an error. A big temporary value stays allocated until its region's block ends.

Workaround: give short-lived data a region of its own, in the smallest block that holds its use, such as a helper task or a loop body. Return plain values, or copy what must survive.

shape Report
    lines: int
    total: int
fi

task summarize(count: int) -> Report
    region work                          ; only this call's scratch data
    flex sum = new int(0) at work
    each i in 0..count
        sum^ = sum^ + i
    fi
    <- Report { lines: count, total: sum^ }
fi

task main()
    fixed report = summarize(5)
    show(report.total)
fi
10

Replacing a value in a long-lived region keeps the old one's memory

region long
flex latest = new int(0) at long
each i in 0..1000000
    latest = new int(i) at long        ; a million ints stay allocated until long is freed
fi

Workaround: change the value in place through its pointer, or allocate per-pass values in a region inside the loop and copy out only what survives with .copy() (Lesson 8).

task main()
    region long
    flex latest = new int(0) at long
    each i in 0..1000
        latest^ = i                      ; reuse the memory: no new allocation
    fi
    show(latest^)
fi
999

A value holds pointers into one region only

shape Pair
    first: ^int
    second: ^int
fi

region a
region b
fixed p = Pair { first: new int(1) at a, second: new int(2) at b }
; error: expected type ^int@a, got ^int@b; a value can't move to another region

Workaround: allocate everything a value points to in the same region (usually the longer-lived one), or copy the value from the other region. A task's results are separate values, so each may be in a region of its own: task both(a: region, b: region) -> ^int@a, ^int@b is fine, and fixed x, y = both(first, second) receives them (see Several Results). It is one value holding both that can't.

shape Pair
    first: ^int
    second: ^int
fi

task main()
    region a
    region b
    fixed short = new int(2) at b
    ; Copy the value into a, the region the pair lives in.
    fixed p = Pair { first: new int(1) at a, second: new int(short^) at a }
    show(p.first^ + p.second^)
fi
3

Text of a region can't become text that lasts

flex chosen = "nothing"
chosen = item^.title
; error: expected type text, got text@items; this text is in region 'items', and text here must last as long as the program

Workaround: keep the text where its region is known: a text@items variable, a shape named with the region (Person@items), or a slice in it. A task that reads it names the region in its header (title: text@r). To keep it longer, copy it into a region that lives longer: kept_title = item^.title.copy(), where kept_title is a text@kept.

Younger data can't point at older data

A node in an inner region pointing at a node in an outer region would be safe, since the outer region lives longer. But a value's pointers are all in its own region, so this is rejected:

region outer
fixed root = new Node(Node { value: 1, next: none }) at outer
when root^.value > 0
    region inner
    fixed leaf = new Node(Node { value: 2, next: root }) at inner
    ; error: expected type Node@inner, got Node@outer; a value can't move to another region
fi

Workaround: copy the older value into the younger region (root.copy() at inner, as below), or allocate the younger data in the older region.

shape Node
    value: int
    next: maybe ^Node
fi

task main()
    region outer
    fixed root = new Node(Node { value: 1, next: none }) at outer
    when root^.value > 0
        region inner
        ; Copy the older value into the younger region.
        fixed older = root.copy() at inner
        fixed leaf = new Node(Node { value: 2, next: older }) at inner
        show(leaf^.value + older^.value)
    fi
fi
3

A task that only names a region can't allocate in it

task grow(list: maybe ^Node@r) -> ^Node@r
    <- new Node(Node { value: 1, next: list }) at r
    ; error: this task can't allocate in 'r', which it only names in its types; take it as a parameter, 'r: region'
fi

A named region has no run-time handle to allocate with; that is what makes such tasks free to call. Workaround: take the region as a parameter, task grow(r: region, list: maybe ^Node@r).

A method can't allocate in its value's region

shape Bag
    items: [int]

    alters put(value: int)
        .items.add(value)
        ; error: adding to a slice may need more room in its region, and '@this' is the region the value
        ;   this method is called on lives in, which a method isn't given, so it can't allocate there, and a
        ;   region it takes is another one, which the value can't point into; add to it where the value is
        ;   kept, outside the method
    fi
fi

A method is given the value it is called on, and @this only names that value's region in its types, as a task's named region does (above). A region parameter is another region, which the value can't point into. Workaround: grow the value where it is kept, bag.items.add(v), or bag^.items.add(v) behind a pointer. Giving an alters method its value's region is an idea for later.

Regions aren't values

A region can't be stored in a variable or field, sent from a task, or chosen at run time, and a task can't make a region for its caller: task fresh() -> ^int@r with a region r inside is an error, because r is freed when fresh returns.

Workaround: declare the region in the caller and pass it down; to pick between two regions, call the task in both branches of a when.

A task can't send a pointer-free value in a region none of its parameters names

task leaf() -> Tree@r
    <- Leaf(1)
    ; error: the result lives in region 'r', which the task isn't given; take it as a parameter, as in 'r: region'
fi

Leaf(1) holds no pointer and could go in any region, but a result's region must be one a parameter names. (task leaf(t: Tree@r) -> Tree@r may send Leaf(1), since t names r.) Workaround: add a parameter that names the region; a region parameter works even if the task never allocates in it.

kind Tree
    Leaf(value: int)
    Branch(left: ^Tree, right: ^Tree)
fi

task leaf(r: region, value: int) -> Tree@r
    <- Leaf(value)
fi

task main()
    region trees
    fixed t: Tree@trees = leaf(trees, 3)
    match t
    case Leaf value
        show(value)
    case Branch _ _
        show("branch")
    fi
fi
3

A name bound from a pointer-free value can't be given a pointer

region a
fixed q = new Node(Node { value: 2, next: none }) at a
when first(none) is {flex n}
    n^.next = q
    ; error: expected type maybe ^Node@_, got ^Node@a; this place holds a value without pointers (of no region), ...
fi

first(none) is given nothing from any region, so its result is in no region (@_), and so is n. Storing q in it would put a pointer where Vela can't tell how long it may live. (Here n can never exist, since first(none) is always none, but the rule doesn't look at values.) Workaround: give the call a value whose type names the region.

shape Node
    value: int
    next: maybe ^Node
fi

task first(list: maybe ^Node@r) -> maybe ^Node@r
    <- list
fi

task main()
    region a
    flex q = new Node(Node { value: 2, next: none }) at a
    flex start: maybe ^Node@a = new Node(Node { value: 1, next: none }) at a
    when first(start) is {flex n}
        n^.next = q
        show(n^.value + q^.value)
    fi
fi
3

A binding without a type can't hold a pointer-free regional value

flex head = Node { value: 1, next: none } is an error, because nothing says which region head is for. Workaround: write its type: flex head: Node@nodes = ….

Regions have no size limit, and running out of memory stops the program

There is no way to cap a region or to handle a failed allocation. Workaround: none inside Vela yet; keep regions short-lived so memory use stays predictable.

9. Ideas for regions

These are proposals, each with what it would cost and how it fits the rest of the language; the ones built since are marked Done, and the rest are not implemented. Badges: Recommended Worth trying Later Not recommended.

Easier region passing

Every call to a task that allocates passes a region: push(nodes, list, 1). In a program that allocates a lot, that region argument repeats everywhere, and it is easy to confuse with the real arguments. Several spellings could make it lighter:

Done in part A task that takes its region last is now given it after the call, as new is: push(list, 1) at nodes or push(list, 1)@nodes, and a call that leaves it out takes the block's default region (see The region after a call). What follows is still open for a region taken anywhere but last, and for working the region out from the arguments.

nodes.push(list, 1): the region before a dot Later

task push(r: region, list: maybe ^Node@r, value: int) -> ^Node@r
    <- new Node(Node { value: value, next: list }) at r
fi

list = nodes.push(list, 1)       ; the same call as push(nodes, list, 1)
  • Grammar: no conflict today. The parser would accept a call after name., and since regions share the variables' names and have no fields, the checker tells nodes.push(…) from a field access by looking nodes up.
  • Conflict with methods: this is the real cost. Vela now has methods, where p.shift(2, 2) finds shift in p's shape. nodes.push(…) would give .name(…) a second lookup rule ("a task whose first parameter is a region"), decided by whether the left side is a region; a region has no methods, so the two never meet, but a reader has to know which the left side is.
  • Reads as: "push is something a region does", which isn't quite the model.
  • Verdict: if Vela adopts uniform call syntax (x.f(a) means f(x, a) for any x), region calls come with it and need no rule of their own. Vela's methods chose the other way (a method belongs to its type, and a task is never called after a dot), so this would be the one place a task is.

nodes->push(list, 1): an arrow from the region Not recommended

  • Grammar: no conflict. -> only appears after a task header's ), and after a value it is a syntax error today, so a parser can accept it.
  • Meaning: Vela gives each symbol one idea, and -> already means "what a task sends back", paired with <-. Here it would mean "in this region". C and C++ programmers would read it as a member access through a pointer, which suggests a region is an object with a push member.
  • Verdict: implementable, but it muddies one of Vela's few symbols.

push->nodes(list, 1): an arrow to the region Not recommended

  • The same symbol problem as above, and it reads backwards: nodes sits where the called name usually is, so push->nodes(copy->kept(list), 1) is hard to scan.

task push[r: region](…) and push[nodes](list, 1): regions in brackets Recommended

task push[r: region](list: maybe ^Node@r, value: int) -> ^Node@r
    <- new Node(Node { value: value, next: list }) at r
fi

task sum[r](list: maybe ^Node@r) -> int      ; a region the task only names
    ...
fi

list = push[nodes](list, 1)
  • The declaration matches the call: regions go in brackets in both, and ordinary values in parentheses. The region stops looking like a value argument.
  • Grammar: [ after a name indexes an array or slice (a[i]), but tasks aren't values, so name[…](…) is a region list when name is a task and an index when it is a variable. Go resolves its generic calls (f[int](x)) the same way.
  • Room to grow: a task or a shape takes types in angle brackets today (task first<T>(…), shape Box<T>, see Types a declaration takes), and a type it takes names no region of its own; a shape given types names the one its values live in, Box<text>@r. Regions are compile-time parameters too, so a task that wrote both would have two lists, task find<T>[r](…), or one that holds types and regions together. Shapes could take several regions the same way (see below).
  • Safer headers: task sum[r](…) declares the regions a task only names. Today a misspelled @rr silently becomes a second region, and the mistake only shows where the two meet.
  • Cost: parser (brackets in headers and calls), checker (region parameters apart from parameters), codegen (region arguments first in C), Vela Studio, and changing every existing region task. On its own it isn't shorter, but it makes the next idea unambiguous.

push(list, 1): inferred region arguments Recommended, with brackets

region nodes
flex list: maybe ^Node@nodes = none
list = push(list, 1)             ; r is nodes: list's type says so
fixed other = push[nodes](none, 2) ; nothing names a region, so it is written
  • Rule: the brackets may be left out when the arguments' types, or the type the result must fit, name every region parameter. Otherwise the call is an error that asks for them: cannot tell which region 'push' allocates in; write push[r](…).
  • Sound: the inferred region must be one the caller can allocate in; a region the caller only names is still an error that suggests taking r: region.
  • Implementable: calls already infer a task's regions from its arguments (unify_regions in the checker); a missing region argument would use the same result, plus the expected type that coerce already has for bindings, assignments, arguments, and sends.
  • Needs brackets: with today's positional region parameters, "one argument fewer" can't be told apart from a forgotten argument, and a task with two region parameters would be ambiguous.
  • Cost to readers: the call no longer shows where memory goes. Vela Studio can show the inferred [nodes] as an inline hint.

fixed p: ^int@scratch = new int(1): new takes its region from the type Worth trying

  • When the place a new goes already names a region (a typed binding, an assignment, a field, an argument, or a send in a task whose result names it), at r could be left out.
  • Small: the checker would fill in new's region from the expected type, with the same error as today when nothing names one.
  • Best limited to places where the region is written on the same line, such as typed bindings, so the source still says where memory goes.

region r default: a default region for a block Done

  • Built on 2026-09-24, and written up as Lesson 10: a block, or a task through r: region default, names one region, and everything in it that allocates without naming a region goes there.
  • This was Not recommended here, for hiding where memory goes behind something declared lines away. What changed the verdict was measuring it: of the lines that allocate and name a region in examples/ and tests/run/, only about one in nine also writes that region in a type on the same line, so the narrower idea below (new takes its region from the type) reaches few of them, while a default reaches nearly all — a cut of about two thirds in the region names those lines carry.
  • What answers the objection: it is opt-in, a block names one default at most (so two lines in a block never differ in where they allocate), the region is still in every value's type, and a copy keeps taking its region from the place it goes. Region arguments at calls are untouched; that is the idea above.

Freeing one value early

Smaller regions Available now

The model's own answer: a value that should go early belongs in a region that ends early. It covers most cases, but not long-lived data where individual values come and go, like a cache or a game's entities.

Owned pointers with drop Recommended, long term

region cache
fixed big = new own Buffer(Buffer { size: 1024 }) at cache
read(big^)
drop big                         ; its memory goes back to the region now
show(big^.size)                  ; error: 'big' was dropped
  • An own ^T@r is move-only (the linear ownership option in Future Directions): assigning or passing it moves it, and the old name can't be used again. Since there is only ever one, drop can free it safely.
  • The region keeps dropped memory in free lists by size and reuses it for later allocations of that size.
  • Cost: a move checker (which variables have been moved from, on every path), and an own pointer can't be copied into shapes freely. Plain ^T@r pointers keep working as today.

Pools with checked handles Worth trying

pool entities of Entity at world
fixed player = entities.add(Entity { health: 100 })  ; a handle, not a pointer
entities.remove(player)
when entities.get(player) is {e}                     ; none: removed
    show(e^.health)
fi
  • A pool hands out handles (a slot index and a generation number). Removing a value frees its slot; a stale handle is detected at run time because its generation no longer matches, so get gives a maybe.
  • Safe without a move checker, and a good fit for long-lived, changing collections (games, caches, graphs being edited). The cost is a small run-time check on every access, and handles instead of pointers.

Recycling memory by type Not recommended

recycle p could return p's memory for the next new of the same type in the region, without checking that p is the only pointer. The memory stays a valid T, so it can't crash, but an old pointer would silently see a different value: a use-after-free bug without the undefined behavior. That trades a crash for a wrong answer, which is worse.

Lifting today's limitations

Result-only regions are free Recommended, small

Fixes this limitation. A region that only the result names (no parameter names it, and it isn't a region parameter) can only ever hold values without pointers: the task has no pointer into it and no way to allocate there, and its body can't declare a region with that name. So task leaf() -> Tree@r is sound, and each call's result can be in no region (@_) and take the region of the place it goes, as Leaf(1) does. The change is removing one error from the header check.

Typed case forms and region inference for bindings Recommended

when first(none) is {flex n: ^Node@a} ; the case form names the region
    n^.next = q
fi

flex head = Node { value: 1, next: none }
head.next = push(nodes, none, 2)      ; head's region is inferred from this line
  • Fixes this limitation and this one.
  • Typed case forms are small: a case form's name may have a type, which the value must fit (an @_ value fits any region).
  • Inference is larger: a binding of a value in no region gets a region variable that the first use with a real region settles, checked across the whole task. A variable nothing settles never holds a pointer, so any region is correct for it.

Shapes with several regions Worth trying

shape Edge[from, to]
    source: ^Node@from
    target: ^Node@to
fi

task connect(a: ^Node@x, b: ^Node@y) -> Edge[x, y]
    <- Edge { source: a, target: b }
fi

Fixes this limitation for data that really spans regions. Node@r would stay the shorthand for a shape with one region. It fits the bracket syntax above. The cost is in the checker: a shape's regions are substituted like a task's, and every field access maps them.

Younger data pointing at older data Later, with borrowing

Fixes this limitation, but it needs care. An enclosing region always outlives the regions of the blocks inside it, so storing a pointer into outer in a value in inner is safe. Simply letting ^Node@outer pass as a ^Node@inner is not safe, though: through that pointer, a program could write a pointer into inner into a node that lives in outer, and it would dangle when inner is freed. It is only sound for pointers that can't be written through: read-only views, which is the borrowing idea (&T) in Future Directions. With several regions per shape (above), shape Leaf[own, parent] is another way to say it.

A generated copy: value.copy() Done

Done (2026-09-17): value.copy() copies into the region the place it goes names, as in node^.title = line.copy(), or into r with value.copy() at r (or value.copy()@r) where nothing names one; see Copying into another region. It copies text, slices' items, and what pointers point to, all the way down, and lifted the limit that a value holding pointers couldn't be copied to another region whole. The compiler generates a copy for each type the program copies; linked data can share nodes or form cycles, so a copy keeps a table of what it has already copied (freed when it ends) to keep shared nodes shared and to stop at cycles.

Moving a whole region into another Worth trying

region kept
each line in lines
    region work
    fixed parsed = parse(work, line)
    when parsed.ok
        move work into kept          ; everything in work is in kept now; work can't be used again
    fi
fi

A cheaper alternative to copying, in some cases. A value can't be moved out of a region on its own: a region hands out memory in chunks and frees only whole chunks, and other pointers may still reach the value, so freeing it early would leave them dangling. A whole region can move, though. Its chunks would be linked into the longer-lived region's list without copying a byte, and every value of the moved region would count as the other region's from then on. Rules it needs: the target must outlive the moved region (it is declared in an enclosing block, or is a region parameter); after the move, no name of the moved region can be used, which is a flow check like the one for named results; and the values that stay in variables are retyped to the target's region. Cost: constant time, and no memory is copied, but nothing is freed either: the moved region's scratch data comes along. It suits building a value in a scratch region and keeping it only when the work succeeds, where .copy() would copy what is kept and free the rest.

Text fields of their own region, and tasks that take text of any region Worth trying

Today a shape's text is in the one region of the value holding it, and a task that reads text of a region names that region in its header (name: text@r), as it does for pointers. Two ideas would ease that. Per-field regions would let a shape hold text of two regions, or text that lasts inside a value in a region, as part of Shapes with several regions above. Header elision would read a text parameter, and a Person holding text, as text of a region the header doesn't name, shared by the header's other unnamed ones, so a task that only reads text needs no @r; its results would need the same rule, as Rust's lifetime elision has. Both were weighed when text got regions (2026-09-17), and the plain rule came first: a region is written where it matters.

Handing a region to the next loop pass Later, research

A long-running loop that keeps a changing state (a simulation, an editor's document) would ideally copy the live state into a fresh region each pass and free the old one, like a copying garbage collector run by hand. Today a region can't outlive its pass. A construct to "continue with this region as the next pass's region" needs every variable that names the old region to be replaced at the same time, which is a flow analysis of its own.

Safety and diagnostics

Idea What it gives Cost
A lint for growth in loops Warn when a loop allocates in a region declared outside it and replaces the pointer (the growth pattern), suggesting p^ = v or a region inside the loop Small Recommended
Explicit named regions: task sum[r](…) A misspelled region is an error at once Small, with brackets Recommended
Debug builds that poison freed memory Freed chunks are filled with a pattern (or unmapped), catching bugs in C code that Vela calls, or in the compiler Small, runtime only Worth trying
Region statistics VELA_REGION_STATS=1 prints each region's peak size at exit, to find regions that grow Small, runtime only Worth trying
Limited regions region cache max 64_000_000, where new gives ^T@r ~ OutOfMemory instead of stopping the program Medium Worth trying
Pointers from C Pointers C gives to Vela arrive in a region Vela never frees, as maybe ^T@extern Medium, with C calls Later
Regions and threads A region belongs to the thread that declared it; handing a whole region to another thread is safe, since every pointer into it names it Large, with concurrency Later

Performance

Idea What it gives Cost
A chunk cache Freed chunks are kept (up to a limit) for the next region, so a region per loop pass no longer calls malloc and free on every pass Small, runtime only Recommended
A first chunk on the stack A small buffer (say 256 bytes) inside the region's C variable, so small regions never call malloc Small; bounded by stack size in deep recursion Recommended
Size hints region frame(64_000) starts with one chunk of that size Small Worth trying
Stack allocation A region whose allocations are few and of known size becomes plain stack storage, with no allocator at all Medium (escape analysis) Later
Freeing nothing at exit main's regions could skip their frees when the program ends, since the system reclaims everything (behind a flag, so leak checkers stay useful) Small Worth trying

Growing with the other memory models

Regions were chosen over the other options in Future Directions, but those options aren't discarded. Several combine well with regions:

A suggested order.

  1. Small and safe: result-only regions, typed case forms, the growth lint, the chunk cache, and a first chunk on the stack.
  2. Region brackets with inferred region arguments, and new taking its region from the type.
  3. Generated copies and shapes with several regions.
  4. Owned pointers with drop, or pools, once real programs show which fits better.