Functions

Functions in EZ are declared with the do keyword. They support typed parameters, return values, and multiple returns.

Basic Functions

Use do to declare a function:

do greet() {
    println("Hello!")
}

do main() {
    greet()  // "Hello!"
}

Parameters

Parameters require type annotations:

do greet(name string) {
    println("Hello, ${name}!")
}

do add(x int, y int) {
    println(x + y)
}

do main() {
    greet("Alice")  // "Hello, Alice!"
    add(5, 10)      // 15
}

Mutable Parameters

By default, function parameters are read-only. Use the & prefix to declare a parameter as mutable, allowing the function to modify it.

// & means "I will modify this parameter"
do birthday(&p Person) {
    p.age = p.age + 1  // OK - parameter is mutable
}

// No symbol means "read-only"
do get_name(p Person) -> string {
    // p.age = 100  // ERROR: cannot modify read-only parameter
    return p.name
}

do main() {
    mut person = Person{name: "Alice", age: 30}
    birthday(person)
    println(person.age)  // 31
}

Rules

Parameter DeclarationCan modify inside function?
p PersonNo (read-only)
&p PersonYes (mutable)
Caller VariableTo p PersonTo &p Person
mutOK (read-only)OK (writable)
constOK (read-only)ERROR

Passing a const variable to a mutable parameter will produce an error:

const config = Config{debug: true}
// update_config(config)  // ERROR: cannot pass immutable variable to mutable parameter

Note: The & mutable parameter syntax applies to user-defined functions only. Standard library functions that modify data (like arrays.append()) require the variable to be declared with mut, not const.

What Can Be Passed to Mutable Parameters

Mutable parameters (&p) work with:

  • Primitive variables: increment(x)
  • Struct fields: increment(point.x)
  • Array elements: increment(arr[0])
  • Map values: increment(map["key"])

Default Parameter Values

Parameters can have default values, making them optional when calling the function:

do greet(name string = "World") -> string {
    return "Hello, ${name}!"
}

do main() {
    println(greet())        // Hello, World!
    println(greet("Alice")) // Hello, Alice!
}

Mixed Required and Optional Parameters

Required parameters must come before parameters with defaults:

do create_player(name string, health int = 100, mana int = 50) -> string {
    return "${name}: HP=${health}, MP=${mana}"
}

do main() {
    println(create_player("Hero"))           // Hero: HP=100, MP=50
    println(create_player("Boss", 200))      // Boss: HP=200, MP=50
    println(create_player("Wizard", 80, 150)) // Wizard: HP=80, MP=150
}

Default Parameter Rules

  • Required parameters must come before optional parameters
  • Mutable parameters (&) cannot have default values
  • Default values are evaluated at call time

Named Arguments

When calling a function, arguments can be passed by name using name: value syntax. This lets callers provide arguments in any order and skip over defaulted parameters to target specific ones:

do connect(host string, port int = 8080, verbose bool = false) {
    if verbose {
        println("Connecting to ${host}:${port}")
    }
}

connect(host: "localhost", verbose: true)  // port uses default 8080
connect(verbose: true, host: "localhost")  // same — order doesn't matter
connect("localhost", verbose: true)        // positional + named mix

Named Argument Rules

  • Positional before named: Once a named argument appears, all remaining arguments must also be named:

    add(1, b: 2)     // OK: positional first, then named
    add(a: 1, 2)     // error: positional argument after named argument
    
  • Names must match parameters exactly. Unknown names are rejected:

    do add(a int, b int) -> int { return a + b }
    add(a: 1, c: 2)  // error: unknown parameter name 'c' in call to 'add'
    
  • No duplicate parameters. A parameter cannot be provided both positionally and by name:

    add(1, a: 2)     // error: parameter 'a' is already provided positionally
    
  • Works with instance dispatch. For instance dispatch calls, the self parameter is implicit — only name the non-self parameters:

    mut v = Vec{x: 2, y: 3}
    mut scaled = v.scale(factor: 5)           // instance dispatch
    mut also = Vec.scale(self: v, factor: 5)  // static dispatch
    
  • Not supported for builtins or stdlib: Named arguments do not work with built-in functions (println, len, cast, etc.) or standard library functions (strings.to_upper, math.sqrt, etc.).


Ensure Statement

The ensure statement specifies a function to call when the enclosing function exits, whether it returns normally or via early return. This is useful for cleanup tasks like closing files or releasing resources.

do cleanup() {
    println("cleaning up!")
}

do process(should_bail bool) {
    ensure cleanup()
    println("starting work...")
    if should_bail {
        return  // cleanup() still called
    }
    println("finished work!")
    // cleanup() called here too
}

do main() {
    process(false)
    // Output:
    //   starting work...
    //   finished work!
    //   cleaning up!

    process(true)
    // Output:
    //   starting work...
    //   cleaning up!
}

Rules:

  • ensure takes a function call (not a function reference)
  • The ensured function runs on every exit path from the enclosing function
  • Multiple ensure statements are allowed; they run in reverse order (LIFO)

Type Sharing

Parameters of the same type can share a type annotation:

// x, y, and z all share the int type
do sum(x, y, z int) -> int {
    return x + y + z
}

// a and b are floats, divisor is int
do calculate(a, b float, divisor int) -> float {
    return (a + b) / float(divisor)
}

do main() {
    println(sum(1, 2, 3))              // 6
    println(calculate(10.0, 20.0, 3))  // 10.0
}

Return Values

Use -> to specify a return type:

do add(x, y int) -> int {
    return x + y
}

do isEven(n int) -> bool {
    return n % 2 == 0
}

do formatName(first, last string) -> string {
    return first + " " + last
}

do main() {
    mut sum = add(10, 20)
    println(sum)  // 30

    if isEven(4) {
        println("4 is even")
    }

    mut name = formatName("John", "Doe")
    println(name)  // "John Doe"
}

Multiple Return Values

Functions can return multiple values:

do divmod(dividend, divisor int) -> (int, int) {
    mut quotient = dividend / divisor
    mut remainder = dividend % divisor
    return quotient, remainder
}

Named Return Values

You can name your return values to document what each position in the return tuple represents. Named return values are labels only — they do not declare variables in the function body. You must explicitly declare any variables you use with mut:

do divide(a, b int) -> (quotient int, remainder int) {
    mut quotient int = a / b
    mut remainder int = a % b
    return quotient, remainder
}

mut q, r = divide(17, 5)  // q=3, r=2

Named returns support grouped types (multiple names sharing one type):

do get_info() -> (name, city string, age int) {
    mut name string = "Alice"
    mut city string = "NYC"
    mut age int = 30
    return name, city, age
}

The names serve as documentation for callers and tooling (e.g., ez doc) but have no effect on the function’s scope or variable declarations.

Restriction: Wildcard types (?) cannot be used in named return positions:

// Error: wildcard type '?' cannot be named
do first(arr [?]) -> (result ?) { ... }

// OK: unnamed wildcard return
do first(arr [?]) -> ? { ... }
do divmod(dividend, divisor int) -> (int, int) {
    mut quotient = dividend / divisor
    mut remainder = dividend % divisor
    return quotient, remainder
}

do minmax(a, b, c int) -> (int, int) {
    mut min = a
    mut max = a

    if b < min { min = b }
    if c < min { min = c }
    if b > max { max = b }
    if c > max { max = c }

    return min, max
}

do main() {
    mut q, r = divmod(17, 5)
    println("17 / 5 =", q, "remainder", r)  // 3 remainder 2

    mut min, max = minmax(5, 2, 8)
    println("min:", min, "max:", max)  // min: 2 max: 8

    // Use _ to discard unwanted return values
    mut quotient, _ = divmod(10, 3)
    println("quotient only:", quotient)  // 3
}

Error Returns

Functions that may fail conventionally return a tuple with the result and an Error:

do parse(s string) -> (int, Error) {
    if s == "" {
        return 0, error("empty string")
    }
    return 42, nil
}

mut value, err = parse("test")
if err != nil {
    // Handle error
}

Array Parameters

do sum(numbers [int]) -> int {
    mut total = 0
    for_each n in numbers {
        total += n
    }
    return total
}

do contains(arr [string], target string) -> bool {
    for_each item in arr {
        if item == target {
            return true
        }
    }
    return false
}

do main() {
    mut nums [int] = {1, 2, 3, 4, 5}
    println("Sum:", sum(nums))  // 15

    mut names [string] = {"Alice", "Bob", "Charlie"}
    println(contains(names, "Bob"))    // true
    println(contains(names, "David"))  // false
}

Struct Parameters

import @math

const Point struct {
    x int
    y int
}

do distance(p1, p2 Point) -> float {
    mut dx = p2.x - p1.x
    mut dy = p2.y - p1.y
    return math.sqrt(float(dx * dx + dy * dy))
}

do translate(p Point, dx, dy int) -> Point {
    return Point{x: p.x + dx, y: p.y + dy}
}

do main() {
    mut a = Point{x: 0, y: 0}
    mut b = Point{x: 3, y: 4}

    println("Distance:", distance(a, b))  // 5.0

    mut moved = translate(a, 10, 20)
    println("Moved to:", moved.x, moved.y)  // 10 20
}

Function References

Functions can be passed as values using the () prefix syntax or the ref() builtin:

do is_positive(n int) -> bool { return n > 0 }

// ()func_name — implicit syntax
mut check = ()is_positive

// ref(func_name) — explicit syntax
mut check2 = ref(is_positive)

// Call through the reference
check(5)  // true

// Pass as argument
do filter(arr [int], test func) -> [int] {
    // ...
}
mut positives = filter(numbers, ()is_positive)

Function references:

  • ()func_name is the implicit form (shorter)
  • ref(func_name) is the explicit form (more readable)
  • Both produce identical results
  • No anonymous functions or lambdas — every reference points to a named function
  • The func type is used for parameters that accept function references

Struct-Namespaced Functions

Functions can be declared inside struct blocks as namespaced free functions:

const Point struct {
    x int
    y int

    do create(x int, y int) -> Point {
        return Point{x: x, y: y}
    }

    do distance(a Point, b Point) -> float {
        return math.sqrt(math.pow(float(a.x - b.x), 2) + math.pow(float(a.y - b.y), 2))
    }

    private do validate(p Point) -> bool {
        return p.x >= 0 && p.y >= 0
    }
}

// Called as Type.func()
mut p = Point.create(3, 4)
mut d = Point.distance(p1, p2)

Rules:

  • No implicit self or this — every parameter is explicit
  • private restricts access to other functions in the same struct
  • Called as StructName.func_name(args...)
  • Cross-module: module.StructName.func_name(args...)
  • Module-qualified types can be used in variable declarations, parameters, and return types: mut p module.Point

Instance Dispatch

When a struct function takes the struct (or a pointer/reference to it) as its first parameter, callers can use the instance form instance.func(...) instead of writing the type name. The compiler rewrites the call as Type.func(instance, ...):

const Vec struct {
    x int
    y int

    do len_sq(v Vec) -> int {
        return v.x * v.x + v.y * v.y
    }

    do bump(&v Vec) {
        v.x = v.x + 1
        v.y = v.y + 1
    }
}

mut a Vec = Vec{x: 3, y: 4}
a.len_sq()         // sugar for Vec.len_sq(a)
Vec.len_sq(a)      // still valid

a.bump()           // sugar for Vec.bump(a); '&v' makes it a mutable alias

Both do f(v Vec) (value), do f(&v Vec) (mutable reference), and do f(v ^Vec) (pointer) participate in instance dispatch.

Factory-style functions whose first parameter isn’t the struct (e.g. do make(x int) -> Vec) require the type-namespaced form (Vec.make(...)); there is no instance to bind.

Chained calls not supported: a.f().g() is rejected. Assign each intermediate result to a variable.

Wildcard Types (?)

The ? type is a wildcard placeholder that enables generic-style functions. When used in a function’s parameter types, ? is bound to the concrete type of the argument at each call site:

do identity(x ?) -> ? {
    return x
}

mut a = identity(42)        // ? binds to int, returns int
mut b = identity("hello")   // ? binds to string, returns string

All ? placeholders in a function signature bind to the same concrete type:

do pick_first(a ?, b ?) -> ? {
    return a
}

pick_first(1, 2)          // OK — both args are int
pick_first(1, "hello")    // Error — conflicting bindings for ?

Wildcard types also work with composite types:

do first(arr [?]) -> ? {
    return arr[0]
}

mut x = first({1, 2, 3})      // ? binds to int
mut y = first({"a", "b"})     // ? binds to string

? is only valid in function parameter types and return types. It is rejected everywhere else (variable declarations, struct fields, etc.).

Guaranteed Cleanup with ensure

The ensure keyword guarantees a function call runs when the current function exits, regardless of how it exits:

import @io

do process_data() {
    mut content, _ = io.read_file("data.txt")
    ensure cleanup()

    if content == "" {
        return  // cleanup() still runs!
    }

    // cleanup() runs when function ends
}

Execution Order (LIFO)

Multiple ensure statements run in reverse order (Last-In, First-Out):

do cleanup1() { println("cleanup 1") }
do cleanup2() { println("cleanup 2") }
do cleanup3() { println("cleanup 3") }

do example() {
    ensure cleanup1()  // runs 3rd
    ensure cleanup2()  // runs 2nd
    ensure cleanup3()  // runs 1st
}

do main() {
    example()
    // Output:
    // cleanup 3
    // cleanup 2
    // cleanup 1
}

Rules

  • ensure statements trigger on normal return, early return, and reaching end of function
  • Only function calls are allowed after ensure:
    ensure cleanup()           // OK
    // ensure { block }        // Not supported
    

Early Returns

Use return to exit a function early:

do findIndex(arr [int], target int) -> int {
    for i in range(0, len(arr)) {
        if arr[i] == target {
            return i  // Found, return early
        }
    }
    return -1  // Not found
}

Void Functions

Functions without a return type don’t return a value:

do printHeader(title string) {
    println("===================")
    println(title)
    println("===================")
}

do logError(message string) {
    println("[ERROR]", message)
}

Visibility

By default, all functions are public. The private keyword restricts access to the declaring module:

private do validate(n int) -> bool {
    return n > 0
}

do factorial(n int) -> int {
    // Can call private members within the same module
    if !validate(n) { return 1 }
    // ...
}

Recursion

Functions can call themselves:

do factorial(n int) -> int {
    if n <= 1 {
        return 1
    }
    return n * factorial(n - 1)
}

do fibonacci(n int) -> int {
    if n <= 1 {
        return n
    }
    return fibonacci(n - 1) + fibonacci(n - 2)
}

do main() {
    println("5! =", factorial(5))   // 120
    println("fib(10) =", fibonacci(10))  // 55
}

The main() Function

Every EZ program needs a main() function as its entry point:

do main() {
    println("Program started")
    // Your code here
    println("Program finished")
}

See Also

  • Control Flow — loops and conditionals used within functions
  • Variables — variable declarations, mut vs const
  • Structs — struct types as parameters and return values
  • Keywordsdo, return, ensure keyword reference