The Vela Language Specification

Version 1.0 · Authoritative reference for the Vela programming language.

This document defines the language. When the implementation and this document disagree, one of them is a bug. See docs/compiler.md for how the implementation maps onto this specification.


0. Identity

Vela is a small, statically typed, natively compiled application language.

Design axes, in priority order:

  1. Simple — one obvious way to do things. The whole language fits in this document.
  2. Fast — compiles straight to x86-64 machine code. No VM, no interpreter, no

JIT warm-up, no libc, no dynamic linker. A Vela binary is a static ELF that talks to the kernel directly.

  1. Expressive — closures, generics, sum types, pattern matching, string

interpolation, first-class errors.

  1. Cohesive — the standard library is written in Vela using the same

features you get. There is no privileged "builtin" layer you cannot read.

Non-goals: manual memory management, a trait/typeclass system, operator overloading, inheritance, macros, implicit conversions, exceptions, threads (v1.0).


1. Lexical structure

1.1 Source encoding

Source files are UTF-8. The extension is .vela. A file is a module.

1.2 Comments

// line comment, runs to end of line
/* block comment,
   /* nests */ correctly */
/// doc comment (attaches to the next declaration)

1.3 Identifiers

ident := (letter | '_') (letter | digit | '_')*

letter is an ASCII letter. Identifiers are case-sensitive.

1.4 Keywords

and    as     break  const  continue  else   enum   false  fn     for
if     in     let    match  mut       nil    not    or     pub    return
struct test   true   type   use       while

self is a contextual keyword valid only as the first parameter of a method.

1.5 Literals

KindExamplesType
Integer0 42 1_000_000 0xFF 0b1010 0o777Int
Float1.0 3.14 1e9 2.5e-3Float
Booltrue falseBool
String"hi" "a\nb" "x = {x}"Str
Char'a' '\n' '\x41'Byte
Nilnil?T

Integer literals are 64-bit signed. _ may separate digits anywhere except at the start. Escapes in strings and chars: \n \r \t \\ \" \' \0 \xHH \{. Strings also accept \u{H..H}, which inserts the UTF-8 encoding of a Unicode code point (1 to 6 hex digits).

String interpolation. Inside a "..." literal, {expr} splices the value of expr, converted with str(...). \{ produces a literal {. A } outside an interpolation is literal.

let name = "world"
io.println("hello, {name}! 1+2={1 + 2}")

1.6 Statement termination

Statements end at a newline. A statement continues onto the next line when the line ends with a binary operator, a comma, or an open bracket ((, [, {) that is not yet closed.

; is a legal statement *separator*, so several statements may share a line. It is never a terminator, and the formatter puts each statement on its own line.

let total = a +
    b +
    c

2. Types

2.1 The type universe

Type := Int | Float | Bool | Byte | Str
      | List[T] | Map[K, V]
      | ?T                       // optional
      | !T                       // fallible: T or Error
      | fn(T1, ..., Tn) -> R     // function
      | Range
      | Name | Name[T1, ...]     // struct / enum, possibly generic
      | Void                     // the type of a function returning nothing

Sugar:

SugarMeans
[T]List[T]
{K: V}Map[K, V]
?Toptional T
!TT or Error

Types declared in another module are written module.Type, e.g. json.Json or store.Task.

2.2 Value kinds

Vela has exactly two value kinds:

This is the single most important rule in the language:

Primitives are copied. Everything else is a reference to a GC object.

There is no &, no *, no ownership, no borrow checker, and no way to observe a dangling pointer from safe code.

2.3 Structs

struct Point {
    x: Float,
    y: Float,
}

pub struct User {
    name: Str,
    age:  Int,
    tags: [Str],
}

Construction uses the type name with named fields; every field must be given:

let p = Point{ x: 1.0, y: 2.0 }

Field access and assignment: p.x, p.x = 3.0. Structs are mutable references. Two struct values are == when they are the same object or all fields are == (structural equality, recursive).

Generic structs:

struct Pair[A, B] { first: A, second: B }
let q = Pair[Int, Str]{ first: 1, second: "one" }

2.4 Enums

enum Shape {
    Circle(Float),
    Rect(Float, Float),
    Empty,
}

enum Color { Red, Green, Blue }     // no payloads: represented as an integer

Construction: Shape.Circle(2.0), Color.Red. Enums are deconstructed with match (§5.5). An enum whose variants all have zero payloads is a primitive (unboxed integer); any other enum is a reference type.

Generic enums are allowed: enum Tree[T] { Leaf, Node(Tree[T], T, Tree[T]) }.

2.5 Optionals

?T is either nil or a T.

let a: ?Int = nil
let b: ?Int = 5          // implicit wrap
if let v = b {           // binds v: Int when b is not nil
    io.println("got {v}")
}
let c = b ?? 0           // default
let d = b?               // propagate nil out of a ?-returning fn

?T and T are distinct types. A ?T cannot be used where T is expected without unwrapping. A T is implicitly widened to ?T.

2.6 Errors

Error is a built-in struct:

struct Error { msg: Str, code: Int }

!T is a value that is either Ok(T) or an Error. It is produced by ok(v) / err(msg) / err_code(msg, code) and consumed by ?, ??, or match.

fn parse_port(s: Str) -> !Int {
    let n = str.to_int(s) ?? return err("not a number: {s}")
    if n < 1 or n > 65535 {
        return err("port out of range: {n}")
    }
    return ok(n)
}

fn main() -> !Void {
    let p = parse_port("8080")?      // propagates the Error on failure
    io.println("port {p}")
    return ok(void)
}

? is the only propagation form. It is a postfix operator valid on ?T (in a function returning ?U or !U) and on !T (in a function returning !U). There are no exceptions and no stack unwinding.

main may be declared fn main(), fn main() -> Int, or fn main() -> !Void. In the last form an Error result prints a diagnostic to stderr and exits 1.

2.7 Function types

let f: fn(Int, Int) -> Int = add
let g = |x: Int| x * 2
let h = |x, y| x + y            // param types inferred from context
xs.map(|x| x * x)

A closure captures the variables it mentions by reference to their storage; captured variables are promoted to the heap so a closure may outlive its defining frame.

2.8 Type inference

2.9 Generics

Functions, structs and enums may be generic:

fn max[T](a: T, b: T) -> T {
    if a > b { return a }
    return b
}

Generics are monomorphised: each distinct instantiation is compiled separately, and the body is type-checked *after* substitution. There are no trait bounds; if max[Str] needs > on Str and Str has it, it compiles. If it does not, you get an error at the instantiation site:

error: `>` is not defined for `Point`
  --> demo.vela:9:12
   |
 9 |     if a > b { return a }
   |        ^^^^^
   = note: in instantiation of `max[Point]`
  --> demo.vela:14:9
   |
14 |     let m = max(p1, p2)
   |             ^^^^^^^^^^^

Type arguments are inferred from the argument types when possible, otherwise written explicitly: max[Float](1.0, 2.0).

2.10 Type aliases

type Grid = [[Int]]
type Handler = fn(Str) -> !Str

Aliases are transparent: they are the same type as their definition.


3. Declarations

A module consists of declarations. Order does not matter; all top-level names are visible to each other.

use std/io                 // import
use std/str as s           // aliased import

pub const MAX: Int = 100   // constant (compile-time evaluated)

pub struct P { x: Int }    // struct
pub enum E { A, B }        // enum
pub type T = [Int]         // alias

pub fn f(a: Int) -> Int {  // function
    return a + 1
}

fn P.norm(self) -> Int {   // method on P
    return self.x
}

test "f adds one" {        // test (only compiled by `vela test`)
    assert(f(1) == 2)
}

pub makes a declaration visible to other modules. Without it a declaration is private to its module. Struct fields follow their struct's visibility.

3.1 Constants

const initialisers must be compile-time constant: literals, arithmetic on constants, other constants, and string concatenation.

3.2 Methods

fn Point.length(self) -> Float { ... }        // read-only-by-convention
fn Point.scale(self, k: Float) { self.x = self.x * k; ... }

A method's first parameter is self, whose type is the receiver. Methods may be declared on any type declared in the same module, and on the built-in types Str, List[T], Map[K,V], Int, Float, Byte, Bool, Range (the standard library does exactly this). Calls are recv.method(args).

Method lookup is: (1) methods declared in the current module, (2) methods declared in the module that declares the receiver type, (3) methods declared in any used module. Ambiguity is an error.


4. Modules

4.1 Paths

use std/io           // stdlib module, bound as `io`
use std/fs
use ./util           // sibling file util.vela, bound as `util`
use ./sub/thing      // sub/thing.vela
use json/parse       // module `parse` of package `json`
use std/io as term   // rebind

The last path segment is the binding name unless as is given. Members are accessed with .: io.println, fs.read_file.

Resolution order for use a/b/c:

  1. a == "std"<vela-root>/lib/std/b/c.vela (or .../b.vela for std/b)
  2. path starts with ./ or ../ → relative to the current file
  3. otherwise → deps/a/src/b/c.vela (see §11 package manager)

Circular imports are an error and are reported with the full cycle.

4.2 The prelude

Every module implicitly has these in scope:

The prelude is lib/core/prelude.vela. It is ordinary Vela and it is worth reading.


5. Statements

stmt := let | assign | expr | return | if | while | for | match | break | continue | block

5.1 Bindings

let x = 1              // immutable
let mut y = 2          // mutable
let z: Float = 3.0     // annotated
y = y + 1              // assignment (only for `mut`)
y += 1                 // compound: += -= *= /= %=

Rebinding with a new let in the same scope shadows the old binding. Reading an uninitialised variable is impossible: let always initialises.

5.2 If

if cond {
    ...
} else if other {
    ...
} else {
    ...
}

if let v = maybe { ... } else { ... }     // optional binding

Braces are mandatory.

if is also an expression when it has an else:

let label = if n < 0 { "negative" } else if n == 0 { "zero" } else { "positive" }

An if used as a value must have an else, because every branch has to produce one. if let is a statement only.

5.2.1 Blocks as values

A block used as a value evaluates to its final expression. This applies to if/else branches, match arms and lambda bodies — but not to fn bodies, which always need an explicit return.

let cost = if premium {
    let base = 100
    base * 2                 // the value of the branch
} else {
    50
}

let f = |a: Int, b: Int| {
    let t = a + b
    t * 2                    // the value of the lambda
}

5.3 While / loop

while i < n {
    if skip { continue }
    if done { break }
    i += 1
}

5.4 For

for i in 0..n { }            // Range, exclusive upper bound
for i in 0..=n { }           // inclusive
for x in xs { }              // List[T] -> T
for i, x in xs { }           // index and value
for b in s { }               // Str -> Byte
for i, b in s { }            // index and byte
for k in m { }               // Map[K,V] -> K
for k, v in m { }            // key and value

5.5 Match

match is both a statement and an expression.

let name = match shape {
    Shape.Circle(r) if r > 10.0 => "big circle",
    Shape.Circle(_)             => "circle",
    Shape.Rect(w, h)            => "rect {w}x{h}",
    Shape.Empty                 => "empty",
}

Patterns:

pattern := '_'                      // wildcard
         | ident                    // binding
         | literal                  // Int / Float / Str / Bool / Byte / nil
         | 'some' '(' pattern ')'   // a present optional
         | 'ok' '(' pattern ')'     // a successful result
         | 'err' '(' pattern ')'    // a failed result
         | [module '.'] Type '.' Variant
         | [module '.'] Type '.' Variant '(' pattern,* ')'
         | Type '{' field ':' pattern,* '}'   // struct
         | pattern '|' pattern      // alternation (no bindings)

A guard if expr may follow any pattern. Arms are tried top to bottom. A match used as an expression must be exhaustive; the compiler proves exhaustiveness for enums, Bool, optionals and !T, and otherwise requires a _ arm. Unreachable arms are a warning.

Match arms take either an expression (=> expr,) or a block (=> { ... }).

5.6 Blocks and scope

Every { ... } introduces a scope. Names are visible from their let to the end of the enclosing block.


6. Expressions

6.1 Operator precedence

From loosest to tightest:

LevelOperatorsAssoc
1orleft
2andleft
3== != < <= > >=none
4.. ..=none
5`\ ^`left
6&left
7<< >>left
8+ -left
9* / %left
10unary - not ~right
11? (postfix), ??left
12call f(x), index a[i], field a.b, asleft

and / or short-circuit. not is logical negation on Bool; ~ is bitwise complement on Int.

a ?? b evaluates to a unwrapped if a is non-nil/ok, otherwise b. The right side may be a return/break/continue.

6.2 Operator typing

OperatorValid operand typesResult
+Int,Int Float,Float Str,Str [T],[T] Byte,Bytesame
- * / %Int,Int Float,Float (% not on Float)same
`& \^ << >> ~`Int,Int Byte,Bytesame
== !=any two values of the same typeBool
< <= > >=Int Float Str ByteBool
and or notBoolBool
.. ..=Int,IntRange

There are no implicit numeric conversions. 1 + 1.0 is an error; write float(1) + 1.0.

Integer division by zero and % by zero panic. / on Int truncates toward zero.

6.3 Conversions

int(3.9)        // 3       (truncates toward zero)
int("42")       // ?Int    (nil when malformed)
int('A')        // 65
float(3)        // 3.0
float("1.5")    // ?Float
str(42)         // "42"    (works for every type)
byte(65)        // 'A'     (wraps mod 256)
bool(0)         // false   (Int/Byte: != 0)

x as T is the checked cast form used for enum payload access in rare cases and for IntByte; it is otherwise unnecessary.

6.4 Collection literals

let xs = [1, 2, 3]                 // [Int]
let ys: [Str] = []
let m = { "a": 1, "b": 2 }         // {Str: Int}
let e: {Str: Int} = {:}            // empty map

Indexing:

xs[0]        // T          — panics if out of bounds
m["a"]       // ?V         — nil when absent
m["a"] = 3   // insert or update
xs[0] = 9    // assign, panics if out of bounds
xs[1..3]     // [T] slice (copy)
s[1..3]      // Str slice

6.5 String interpolation, str, and Show

str(x) produces a Str for any value:

If a type declares fn T.to_str(self) -> Str, that is used instead.


7. Memory model

See docs/memory.md for the full treatment. Summary:


8. Execution model

Compilation is whole-program:

main.vela + imports + stdlib
      -> lex -> parse -> resolve -> typecheck -> monomorphise
      -> Vela IR -> optimise -> x86-64 -> static ELF64

There is no runtime linker, no libc, and no interpreter. _start sets up the heap, records the stack base for the GC, marshals argc/argv/envp, calls main, and issues exit_group. All I/O is a direct system call.

Two targets are supported, x86_64 and arm64, both Linux ELF64. The compiler encodes instructions and writes the executable itself, so cross-compiling needs nothing installed beyond the compiler. core.arch() reports the architecture a program was built for.

A panic prints panic: <message> plus a source location to fd 2 and exits with status 101.


9. Tests

test "addition" {
    assert(1 + 1 == 2)
    assert_eq(2 + 2, 4)
}

test blocks are compiled only by vela test, which links them into a test binary with a harness. assert, assert_eq, assert_ne are in the prelude.

A test body is a !Void function, so ? may be used inside it; a propagated error fails that test rather than aborting the run.


10. Grammar (EBNF)

module      = { decl } ;
decl        = [ "pub" ] ( use | const | struct | enum | alias | fn | test ) ;

use         = "use" path [ "as" ident ] ;
path        = ident { "/" ident } | ( "." | ".." ) "/" ident { "/" ident } ;

const       = "const" ident ":" type "=" expr ;
struct      = "struct" ident [ generics ] "{" { field "," } "}" ;
field       = ident ":" type ;
enum        = "enum" ident [ generics ] "{" { variant "," } "}" ;
variant     = ident [ "(" type { "," type } ")" ] ;
alias       = "type" ident "=" type ;
generics    = "[" ident { "," ident } "]" ;

fn          = "fn" [ ident "." ] ident [ generics ] "(" [ params ] ")"
              [ "->" type ] block ;
params      = ( "self" | param ) { "," param } ;
param       = ident ":" type ;
test        = "test" string block ;

type        = "?" type
            | "!" type
            | "[" type "]"
            | "{" type ":" type "}"
            | "fn" "(" [ type { "," type } ] ")" [ "->" type ]
            | [ ident "." ] ident [ "[" type { "," type } "]" ] ;

block       = "{" { stmt } "}" ;
stmt        = let | assign | if | while | for | match | return
            | "break" | "continue" | expr ;
let         = "let" [ "mut" ] ident [ ":" type ] "=" expr ;
assign      = lvalue ( "=" | "+=" | "-=" | "*=" | "/=" | "%=" ) expr ;
lvalue      = ident | postfix "." ident | postfix "[" expr "]" ;
if          = "if" ( expr | "let" ident "=" expr ) block
              [ "else" ( if | block ) ] ;
while       = "while" expr block ;
for         = "for" ident [ "," ident ] "in" expr block ;
return      = "return" [ expr ] ;
match       = "match" expr "{" { arm } "}" ;
arm         = pattern [ "if" expr ] "=>" ( expr | block ) [ "," ] ;

expr        = or_expr ;
if_expr     = "if" expr block "else" ( if_expr | block ) ;
(* see §6.1 for the full precedence chain *)
primary     = literal | ident | "(" expr ")" | list_lit | map_lit
            | lambda | struct_lit | match | if_expr | intrinsic ;
struct_lit  = [ ident "." ] ident [ "[" type { "," type } "]" ]
              "{" { ident [ ":" expr ] "," } "}" ;
lambda      = "|" [ lparams ] "|" ( expr | block )
            | "fn" "(" [ params ] ")" [ "->" type ] block ;
intrinsic   = "@" ident [ "[" type { "," type } "]" ] "(" [ args ] ")" ;

11. Packages

A package is a directory with vela.toml:

[package]
name    = "hello"
version = "0.1.0"
main    = "src/main.vela"

[deps]
json = { path = "../json" }

12. Reserved for future versions

async, await, spawn, trait, impl, where, defer, unsafe, macro, i8..u64, move, static. These are not keywords in 1.0 but are reserved identifiers the compiler warns about.


13. Intrinsics (unsafe, for the runtime only)

Available in any module, but only the standard library is expected to use them. They are the primitive operations the runtime is built from.

@syscall(n, a1..a6) -> Int      raw Linux syscall
@argc() @argv() @envp() -> Int  the process arguments and environment
@rt_base() -> Int               base of the runtime state area
@fsqrt(f: Float) -> Float       hardware square root
@load8/@load16/@load32/@load64(addr: Int) -> Int
@store8/@store16/@store32/@store64(addr: Int, v: Int)
@addr(x) -> Int                 address of a reference value
@ref[T](a: Int) -> T            reinterpret an address as a reference
@sizeof[T]() -> Int             size in bytes of T's representation
@stack_top() -> Int             stack base recorded by _start
@save_regs() -> Int             spill callee-saved registers, return rsp
@restore_regs()                 undo @save_regs
@f2bits(f: Float) -> Int        bit reinterpretation
@bits2f(i: Int) -> Float
@trap()                         emit ud2 (unreachable)

Misuse of an intrinsic is undefined behaviour. Everything above this layer is safe.