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:

CategoryTypes
Signed integersi8, i16, i32, i64
Unsigned integersu8, u16, u32, u64
Floating pointf32, f64
Otherbool, 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.