06 - Variables and Types
Storing and manipulating values is the core of any language. Verge has the usual primitives, a few nicer high-level types, and pointers for lower-level work. This chapter walks through all of them.
Declaring variables
You declare a value with an explicit type, or let the compiler infer it with :=:
import std.io
main: f() {
count: i32 = 5 # explicit type
name := "Verge" # inferred as string
ratio := 0.5 # inferred as f64
enabled := true # inferred as bool
port: const u16 = 8080 # a constant; reassigning it is a compile error
io.writeln(name, " ", count, " ", ratio, " ", enabled, " ", port)
}
Verge 5 0.5 true 8080
i32 is the default integer type and f64 the default float type, so 5 infers to i32
and 0.5 to f64. const marks a value that can’t be reassigned, and it always needs an
explicit type.
Verge treats an unused local variable as an error, not a warning — if you declare it, use it. It keeps dead code from quietly accumulating.
Primitive types
Verge ships the primitives you’d expect:
| Category | Types |
|---|---|
| Signed integers | i8, i16, i32, i64 |
| Unsigned integers | u8, u16, u32, u64 |
| Floating point | f32, f64 |
| Other | bool, string |
Integer literals are untyped until context pins them down, and integer arithmetic wraps on
overflow (255::u8 + 1 == 0), with a compiler warning when the overflow is statically
obvious. Reach for the smaller widths when size matters; otherwise i32 is the default.
Strings
Strings are UTF-8, null-terminated, and stored as a fat pointer (pointer + length) under the
hood — which means they hand straight to C functions expecting char* with no conversion.
A handful of operations are built-in methods on string:
import std.io
main: f() {
name := "Verge"
io.writeln("upper: ", name.to_upper()) # VERGE
io.writeln("lower: ", name.to_lower()) # verge
io.writeln("length: ", name.length()) # 5 (bytes, excluding the null terminator)
io.writeln("concat: ", "hello, " + name) # hello, Verge
}
upper: VERGE
lower: verge
length: 5
concat: hello, Verge
Everything else — trimming, splitting, searching, replacing, parsing — lives in the
std.strings module as free functions you call as strings.fn(s, ...):
import std.io
import std.strings
main: f() {
io.writeln(strings.trim(" padded ")) # padded
io.writeln(strings.contains("Verge", "erg")) # true
io.writeln(strings.replace("a-b-c", "-", ".")) # a.b.c
io.writeln(strings.substring("Verge", 0, 3)) # Ver
parts := strings.split("a,b,c", ",") # []string{"a", "b", "c"}
io.writeln(parts[0], " ", parts[1], " ", parts[2])
}
padded
true
a.b.c
Ver
a b c
Two extra spellings help with strings that would otherwise need heavy escaping — triple-quoted multiline strings and backtick raw strings (no escaping, no interpolation):
import std.io
main: f() {
multiline := """
first line
second line
"""
raw := `C:\Users\someone\verge`
io.write(multiline)
io.writeln(raw)
}
first line
second line
C:\Users\someone\verge
String interpolation and formatting
Embed expressions directly in a string with {...}, and control rendering with a : format
specifier (Rust-style {value:[align][0][width][.precision][type]}):
import std.io
main: f() {
pi := 3.14159
n := 42
io.writeln("pi = {pi:.2}") # pi = 3.14
io.writeln("hex = {n:x}, padded = {n:05}") # hex = 2a, padded = 00042
io.writeln("escape a brace: {{literal}}") # escape a brace: {literal}
}
pi = 3.14
hex = 2a, padded = 00042
escape a brace: {literal}
This is the formatting mechanism in Verge — there is no printf-style %s format string.
Arrays and slices
A fixed-size array carries its length in the type. A slice is a growable, zero-indexed list — usually what you actually want:
import std.io
main: f() {
rgb: [3]u8 = [3]u8{255, 128, 64} # fixed length, baked into the type
io.writeln("green channel: ", rgb[1])
numbers := []i32{1, 2, 3} # a slice
numbers.add(4) # grows in place, returns nothing
io.writeln("len ", numbers.length(), ", last ", numbers[3])
reversed := numbers.reverse() # returns a NEW slice; numbers is unchanged
io.writeln("reversed first ", reversed[0])
}
green channel: 128
len 4, last 4
reversed first 4
Slices carry a useful set of methods. add, remove, length, and capacity are the
core mutating/inspecting builtins, and there’s a large auto-imported functional toolkit on
top — map, filter, reduce, for_each, find, any, all, first, last,
reverse, sort, sort_by, min, max, sum, index_of, contains:
import std.io
main: f() {
nums := []i32{5, 2, 8, 1}
doubled := nums.map(f(x: i32) => i32 = x * 2)
evens := nums.filter(f(x: i32) => bool = x % 2 == 0)
total := nums.reduce(0, f(acc, x: i32) => i32 = acc + x)
io.writeln("doubled first: ", doubled[0]) # 10
io.writeln("even count: ", evens.length()) # 2
io.writeln("sum: ", total) # 16
}
doubled first: 10
even count: 2
sum: 16
One distinction worth internalising: add and remove mutate the slice in place and
return nothing, so numbers = numbers.add(4) is a mistake — just call numbers.add(4). The
functional methods (map, filter, reverse, sort, …) are the opposite: they leave the
receiver untouched and hand you a new slice or value, so you keep their result
(reversed := numbers.reverse()).
Maps
map<K, V> is a hash map. Create one with map.new<K, V>(), then index it to read and
write. Indexing returns the value directly; a missing key yields the value type’s zero value:
import std.io
main: f() {
ages := map.new<string, i32>()
ages["alice"] = 30
ages["bob"] = 28
io.writeln("alice is {ages[\"alice\"]}") # alice is 30
io.writeln("count: {ages.length()}") # count: 2
io.writeln("has bob: {ages.contains(\"bob\")}") # has bob: true
# get returns (value, present) so you can tell "missing" from "zero"
years, present := ages.get("carol")
io.writeln("carol: {years} present={present}") # carol: 0 present=false
ages.delete("bob")
for name, age := ages {
io.writeln("{name} -> {age}")
}
}
alice is 30
count: 2
has bob: true
carol: 0 present=false
alice -> 30
The map methods are length(), contains(key), get(key) → (value, bool),
keys() → []K, values() → []V, and delete(key). Iterate with
for key, value := m { ... }. Iteration order is unspecified.
Tuples
Tuples bundle a few values without inventing a whole type. Access elements by position with
.0, .1, …, or destructure them:
import std.io
main: f() {
point := (10, 20)
io.writeln("x=", point.0, " y=", point.1)
first, second := point
io.writeln("destructured: ", first, " ", second)
}
x=10 y=20
destructured: 10 20
You can attach names to tuple fields in the type to document them. The names are
purely for readability — fields are still matched positionally, and (f64, f64) and
(x: f64, y: f64) are the same type:
import std.io
main: f() {
origin: (x: f64, y: f64) = (3.5, 9.0)
io.writeln("x=", origin.x, " y=", origin.y)
}
x=3.5 y=9
Objects
An object groups named, individually-typed fields, and can carry methods in its body. Fields
are mutable by default; const fields are fixed at construction:
import std.io
Person: object {
Id: const u64
Name: string
Age: u8
## Methods live in the body. `.Field` is the current instance.
greet: f() => string = "Hello, " + .Name
}
main: f() {
# Construction is by named field — there is no positional form.
bob := Person{Id: 1, Name: "Bob", Age: 32}
io.writeln(bob.greet())
bob.Name = "Alice" # fields are mutable...
io.writeln(bob.greet())
# bob.Id = 2 # ...but this would be an error: Id is const
io.writeln("age ", bob.Age, ", id ", bob.Id)
}
Hello, Bob
Hello, Alice
age 32, id 1
Two rules worth committing to memory: object fields are declared one per line as
Name: Type (there’s no grouped a, b: T form for fields), and objects are always
constructed with Type{Field: value, ...}.
Enums
Enums model a value that is one of several shapes. Variants can be bare tags or carry a
payload declared as Variant: PayloadType:
import std.io
Color: enum {
Red
Green: i32 # payload: a shade
Custom: object { # payload: an inline object
R: u8
G: u8
B: u8
}
}
describe: f(c: Color) => string {
return match c {
Red => "red"
Green(shade) => "green (shade {shade})"
Custom(rgb) => "rgb({rgb.R}, {rgb.G}, {rgb.B})"
}
}
main: f() {
io.writeln(describe(Color.Red))
io.writeln(describe(Color.Green(7)))
io.writeln(describe(Color.Custom(R: 255, G: 128, B: 64)))
}
red
green (shade 7)
rgb(255, 128, 64)
Enums are one of the nicer parts of Verge: they let you model states directly instead of encoding them as bare integers and hoping every caller agrees on the meaning. Pattern matching on them is covered in Control Flow.
Type aliases
If a type is worth naming, name it. type introduces an alias — a second name for exactly
the same type — which is especially handy for function types:
import std.io
UserId: type = u64
BinOp: type = f(i32, i32) => i32
add: f(a, b: i32) => i32 = a + b
main: f() {
id: UserId = 42
op: BinOp = add # any matching function fits the alias
io.writeln("id ", id, ", 3+4 = ", op(3, 4))
}
id 42, 3+4 = 7
Unique types
A unique type is built from an existing type but stays distinct — the compiler won’t let
you mix them up. Convert between the unique type and its base with a cast:
import std.io
Port: unique type = u16
main: f() {
http_port := 8080::Port # u16 value, viewed as a Port
raw := http_port::u16 # back to a plain u16
io.writeln("port ", raw)
}
port 8080
This prevents whole categories of nonsense — like passing a user id where a port number was
expected — that a bare u16 would happily allow.
Type union constraints
A type can also be a set of concrete types joined with |. That’s most useful as a
generic bound, where “any numeric type” becomes a real constraint instead of a comment:
import std.io
Numeric: type = i8 | i16 | i32 | i64 | f32 | f64
double: f<T: Numeric>(x: T) => T = x + x
main: f() {
io.writeln(double(21)) # 42
io.writeln(double(1.5)) # 3
}
42
3
Native unions
For genuine C-style layout control, union overlays several fields in the same memory. Only
one field is meaningful at a time — it’s your job to track which:
import std.io
NumberBits: union {
as_int: i32
as_float: f32
}
main: f() {
bits: NumberBits
bits.as_int = 42
io.writeln("as_int ", bits.as_int)
}
as_int 42
Use native unions when you truly need overlapping memory. For “one of several shapes” with
safety, an enum is the safer choice.
Casting
Verge casts with a postfix :: operator: take this value, view it as that type. You’ve
seen it already for unique types; it works for numeric conversions too:
import std.io
main: f() {
whole := 42
as_float := whole::f64 # 42 -> 42.0
small := 300::u8 # wraps to 44 (300 mod 256)
io.writeln("float ", as_float, ", small ", small)
}
float 42, small 44
Pointers
Verge has real pointers, with & for address-of and * for both the pointer type and
dereference. Pointers can hold null, which is exactly as much power and responsibility as
it sounds:
import std.io
main: f() {
value := 10
ptr: *i32 = &value
io.writeln("via pointer: ", *ptr) # 10
*ptr = 99 # write through the pointer
io.writeln("value is now: ", value) # 99
}
via pointer: 10
value is now: 99
That’s the tour. Most day-to-day Verge lives in the friendly part of the type system — inference, slices, maps, objects, enums — with the sharper tools (unions, pointers, unique types) waiting for when you actually reach for them.