08 - Functions and Modules
Functions are where Verge spends most of its time, and they’re designed to read clearly. Modules keep a growing codebase from collapsing into a single unmanageable file.
Function declarations
Every function follows the same shape you’ve already seen on main — name: f(params) => ReturnType:
import std.io
## A single-expression body uses `= expression`.
add: f(a, b: i32) => i32 = a + b
main: f() {
io.writeln(add(2, 3))
}
5
Reading that declaration left to right: add is the name, f(...) says it’s a function,
a, b: i32 are two i32 parameters (grouped — same-typed parameters can share one
annotation), => i32 is the return type, and = a + b is the body.
When the body needs more than one expression, use a braced block. The last expression in
a block is its value, so you can return without writing return:
import std.io
greet: f(name: string) => string {
message := "Hello, " + name
message # last expression is the return value
}
main: f() {
io.writeln(greet("Ada"))
}
Hello, Ada
Use return when you want to leave early; rely on the tail expression when you’re just
handing back the final value:
import std.io
safe_subtract: f(a, b: i32) => i32 {
if b > a {
return 0 # early exit
}
a - b # tail expression
}
main: f() {
io.writeln(safe_subtract(10, 3), " ", safe_subtract(3, 10))
}
7 0
First-class functions
Functions are values. You can store them, pass them, and call them through a variable. A
type alias keeps the signatures legible:
import std.io
BinOp: type = f(i32, i32) => i32
add: f(a, b: i32) => i32 = a + b
apply: f(a, b: i32, op: BinOp) => i32 = op(a, b)
main: f() {
io.writeln(apply(10, 5, add)) # pass a named function
io.writeln(apply(2, 3, f(a, b: i32) => i32 = a * b)) # or an anonymous one inline
}
15
6
That inline f(a, b: i32) => i32 = a * b is an anonymous function — a lightweight closure
you don’t have to name first.
Variadic functions and spread
A variadic parameter uses ... and collects extra arguments into a slice:
import std.io
sum: f(nums: ...i32) => i32 {
total := 0
for n := nums {
total += n
}
total
}
main: f() {
io.writeln(sum(1, 2, 3)) # pass individual values
values := []i32{4, 5, 6}
io.writeln(sum(values...)) # or spread a slice with `...`
}
6
15
Pure functions
Declaring a function with pure instead of f promises it has no side effects — and the
compiler holds you to it:
import std.io
square: pure(n: i32) => i32 = n * n
main: f() {
io.writeln(square(6))
}
36
If a pure function tries to do I/O or otherwise reach outside itself, compilation fails
with a message like “pure function square cannot perform I/O”. Use pure when you
genuinely mean it — it documents intent and lets the compiler enforce it.
Multiple return values
A function returns several values by returning a tuple, and the caller destructures it:
import std.io
divmod: f(a, b: i32) => (i32, i32) = (a / b, a % b)
main: f() {
q, r := divmod(10, 3)
io.writeln("quotient ", q, ", remainder ", r)
}
quotient 3, remainder 1
This is the mechanism behind returning a value alongside an error, which the next chapter is entirely about.
Modules
A module groups declarations into a reusable unit. A module file opens with module <name>,
and export marks what other files may use — anything unexported stays private to the
module. Put this in mathutils.v:
module mathutils
export double: f(n: i32) => i32 = n * 2
export triple: f(n: i32) => i32 = n * 3
Then a main.v alongside it can import and use it:
import mathutils
import std.io
main: f() {
io.writeln("double 21 = ", mathutils.double(21))
io.writeln("triple 7 = ", mathutils.triple(7))
}
Running the project (verge run from its directory) prints:
double 21 = 42
triple 7 = 21
Importing
Imports are always explicit — there is no implicit preamble. There are four forms:
import mathutils # a local module, used qualified: mathutils.double(...)
import std.io # a standard-library module, used as: io.writeln(...)
import std.strings.{trim} # pull specific members into scope, used unqualified: trim(...)
import std.strings as s # give the module an alias, used as: s.trim(...)
Here are the member and alias forms in a program you can actually run:
import std.io
import std.strings.{to_upper}
import std.strings as s
main: f() {
io.writeln(to_upper("hello")) # member imported unqualified
io.writeln(s.to_lower("HELLO")) # via the alias
}
HELLO
hello
Project structure and dependencies
Once a project outgrows one file, split it by concern — a main.v entry point, a few module
files like mathutils.v, and the verge.toml manifest that ties them together. That’s
enough structure for most programs before you need anything more elaborate.
External dependencies are managed from the CLI: verge add <url>@<version> records a
dependency in verge.toml, and verge fetch downloads everything listed there. Once
fetched, you import third-party code exactly like anything else — explicitly, by module
name. The language doesn’t care whether code came from your repo, the standard library, or
someone else’s repo; your future debugging session might, though.
Functions give your program behaviour; modules keep that behaviour from collapsing into one enormous file. Both are worth learning early.