Core syntax Functions

Functions

Top-level functions use the def keyword. They may declare typed parameters, default values, overloads, recursive behavior, explicit return types, and exception contracts.

Basic Declaration

def add(a as Int, b as Int) as Int:
    return a + b

Unreachable Statements

An executable statement after an unconditional return, throw, break, or continue in the same block is a syntax error. This also applies after an if/else whose branches all terminate. The compiler rejects the unreachable statement before checking its operand types. For example, the diagnostic after a return is Unreachable statement after 'return'.

def absolute(value as Int) as Int:
    if value < 0:
        return -value
    return value
    # print(value)  # SyntaxError: Unreachable statement after 'return'

A conditional return does not make subsequent statements unreachable when another path can still reach them. Comments, blank lines, and hoisted nested function declarations are not executable statements.

Parameter Passing and Contracts

Parameters in functions, methods, and constructors are passed by value by default. Klyn provides three declaration qualifiers to express how a callable interacts with a parameter binding—readonly, ref, and out—and the notnull contract for reference types.

Declaration Direction Caller argument Call-site syntax Callee contract
x as T Input (by value) Any compatible expression f(expr) Local parameter binding may be reassigned without affecting the caller.
readonly x as T Locked input Any compatible expression f(expr) Local parameter binding cannot be reassigned.
ref x as T In-out alias Initialized writable variable of exact type T f(x) or f(ref x) Reads the caller's current value and writes updates directly back to the caller's variable.
out x as T Output alias Writable variable of exact type T f(x) or f(out x) Cannot be read before assignment; must be assigned on every normal exit.
Value and readonly Parameters

By default, reassigning a parameter updates only the function's local binding. Add readonly when the body must not rebind that parameter name. For reference types, readonly locks the parameter variable itself; it does not make the referenced object deeply immutable. Note that const is reserved for module, class, and local constants, and in is reserved for collection membership and for loops; neither can qualify a parameter.

def copy(readonly source as Int, out destination as Int):
    # source = 0               # SyntaxError: source is read-only.
    destination = source

original = 42
result = 0
copy(original, out result)
assert result == 42
In-Out Parameters with ref

Use ref when a function, method, or constructor needs to read the caller's variable and modify it in place. Inside the callee, reading or assigning a ref parameter directly reads or updates the caller's storage.

At the call site, you may pass the variable implicitly (swap(a, b)) or prefix it with ref (swap(ref a, ref b), including on named arguments such as second=ref b) to make the in-out mutation explicit to readers.

def swap(ref first as Int, ref second as Int):
    temporary = first
    first = second
    second = temporary

left = 10
right = 20
swap(ref left, ref right)
assert left == 20 and right == 10

def advance(ref count as Int, ref label as String):
    count += 5
    label = f"{label}:{count}"

total = 10
tag = "run"
advance(total, label=ref tag)
assert total == 15 and tag == "run:15"

On reference types (classes, interfaces, or String), ref goes beyond ordinary object mutation. When an object is passed by value, mutating one of its properties modifies the shared instance, but reassigning the parameter only changes the local variable. With ref, reassigning the parameter updates the caller's variable so that it points to a new object, while leaving any other alias untouched:

class Box:
    public value as Int

    public Box(value as Int):
        this.value = value

def replaceBox(ref target as Box, delta as Int):
    target = Box(target.value + delta)

first = Box(10)
alias = first
replaceBox(ref first, 32)
assert first.value == 42       # first now points to the new Box instance.
assert alias.value == 10       # alias still references the original Box.
Output Parameters with out

Use out when a callable writes a result into the caller's variable without reading its prior value. Like ref, an out argument may be passed implicitly (createNode(21, node, result)) or explicitly with out (createNode(21, out node, doubled=out result)), and works for both primitive types and object references:

class Node:
    public id as Int

    public Node(id as Int):
        this.id = id

def createNode(id as Int, out created as Node, out doubled as Int):
    created = Node(id)
    doubled = created.id * 2

node = Node(1)
doubled = 0
createNode(21, out node, doubled=out doubled)
assert node.id == 21 and doubled == 42

A notnull variable can only bind to a notnull ref or notnull out parameter: a nullable alias could otherwise store null into the protected variable.

Both ref and out require an addressable, writable variable whose static type matches the parameter's declared type T exactly; literals, temporary expressions, const or readonly bindings, and variables requiring an implicit numeric conversion are rejected at compile time. Both require an explicit type annotation (`as T`), and neither can be variadic, declare a default value, or be combined with readonly or with each other. Explicit call-site modifiers must match the declaration: passing ref x to a by-value or out parameter, or out x to a by-value or ref parameter, raises TypeError.

The callee cannot read an out parameter before assigning it and must assign it on every normal exit. For a function returning Boolean (the try... pattern), this obligation applies only when returning true; on false, the output variable may remain unchanged. A caller forwarding its own unassigned out parameter must check that Boolean result before reading it, unless the compiler proves from the available function body that the output is assigned on every normal return, including false.

def acceptPositive(input as Int, out output as Int) as Boolean:
    if input <= 0:
        return false
    output = input
    return true

result = 7
assert not acceptPositive(0, out result)
assert result == 7
assert acceptPositive(42, out result)
assert result == 42

assert not Int.tryParse("invalid", out result)
assert result == 42

A nested function or lambda capturing a ref or out parameter keeps a reference to the caller's variable, not a copy. That storage remains alive even when the closure is returned or stored and the caller has already returned.

def counterFor(out value as Int) as () -> Int:
    value = 0
    def nextValue() as Int:
        value += 1
        return value
    return nextValue

value = 0
counter = counterFor(out value)
assert counter() == 1
assert value == 1
Non-Null Parameter Contracts (notnull)

For reference-type parameters, adding notnull instructs the compiler to enforce that the parameter never holds null. It can be combined with by-value, ref, and out parameters:

  • notnull param as T (by value): the argument must be non-null at call entry, and the function body cannot reassign param to null.
  • notnull ref param as T (in-out): the caller's variable must hold a non-null reference when passed in, and any reassignment inside the body must also be non-null.
  • notnull out param as T (output): because an out parameter does not read the caller's prior value, the caller may pass an uninitialized variable or a variable currently set to null. The contract guarantees that every value assigned to param inside the body is non-null.

Whenever the compiler can prove statically that a passed or assigned value is null (such as a literal null or a local variable known to be null), compilation fails immediately with TypeError. When static analysis cannot determine the runtime value, the compiler automatically emits a runtime check that raises NullException as soon as a null reference enters or is assigned to the parameter.

def normalize(notnull ref text as String):
    text = text.trim().toLower()
    # text = null              # TypeError: cannot assign null to notnull parameter.

def initLabel(notnull out label as String):
    label = "ready"
    # label = null             # TypeError: cannot assign null to notnull parameter.

current = "  KLYN  "
normalize(ref current)
assert current == "klyn"

target as String = null        # Allowed before calling a 'notnull out' parameter.
initLabel(out target)
assert target == "ready"
Local notnull variables

The notnull modifier can also be placed on local reference variable declarations (notnull name as String = ...) so that every assignment in the block is checked for non-nullity. See Null References and notnull Contracts for details.

Updating a Visible Variable

An ordinary assignment inside a function updates an existing visible variable, including a mutable variable declared at module scope. It does not implicitly create a local copy of that global variable. The variable keeps its declared or inferred static type.

counter as Int = 0

def increment():
    counter += 1

increment()
assert counter == 1

Parameters and explicitly declared local variables have their own bindings. If no variable with the assigned name is visible, the first assignment introduces one in the current block. See Typing for the assignment and scope rules.

Nested functions capture visible local variables by reference, preserving their static types. Assignments update the same binding in both functions. Captured bindings remain alive if the nested function is returned or stored as a function value.

Lambdas follow the same rule, including for primitive variables. A reader sees later assignments to its captured variable, and several closures share the same binding. No ref modifier is needed on the function or lambda declaration. The compiler may store an unchanged value directly in a closure when it proves that this optimization cannot be observed; it does not change the capture contract. Sharing a binding does not synchronize concurrent access: use atomic variables or synchronization when several threads modify the same captured variable.

value = 1
reader = lambda() as Int: value
value = 42
assert reader() == 42
def makeCounter() as () -> Int:
    count = 0
    def nextValue() as Int:
        count += 1
        return count
    return nextValue

counterFunction = makeCounter()
assert counterFunction() == 1
assert counterFunction() == 2

Default Arguments

def greet(name as String = "World") as String:
    return "Hello, " + name

Default expressions are checked at compilation, even if the function is never called. Names resolve in the declaration scope, not among the callee's parameters or local variables. A default may call a function declared later in the same source file; an unknown name or an incompatible result type is a compile-time error. The expression is evaluated when an argument is omitted, not when the function is declared.

fallback = 10

def read(value as Int = fallback) as Int:
    return value

assert read() == 10
# def invalid(first as Int = second, second as Int = 1):
#     pass    # Compile-time error: second is not visible in the default.

Lambdas also support default arguments. Their defaults are evaluated inside the closure, where earlier parameters are available. Each omitted default is evaluated once per invocation; an explicitly supplied argument skips that expression and its side effects. If a default raises an exception, the lambda body is not entered. A default cannot read its own parameter or a later parameter, including through an f-string interpolation. Argument labels and member names are not variable reads: lambda(value as Int = answer(value=42)): value and lambda(value as Int = Defaults().value): value are valid when the called function and member have compatible types. Defaults do not become part of a structural function type such as (Int) -> Int; see Function and Lambda Types for the invocation and conversion rules.

A lambda may also use throw instead of returning a value. Its exception follows the ordinary exception-handling rules, and its declared return type still describes the callable signature; no result is produced on the throwing path.

fail = lambda() as Boolean: throw Exception("Unexpected evaluation")
assert true or fail()    # The right operand is not evaluated.

Overloading

def add(a as Int, b as Int) as Int:
    return a + b

def add(a as Double, b as Double) as Double:
    return a + b

Overloads are resolved by parameter types, so explicit typing is especially important here.

Variadic Parameters

Place ... after a parameter name to collect positional or named arguments. The dedicated Variadic Parameters page explains both forms, their static types, declaration order, and overload resolution.

def sumAll(values ... as Int) as Int:
    total as Int = 0
    for value in values:
        total += value
    return total

assert sumAll() == 0
assert sumAll(10, 20, 30) == 60

Returning Multiple Values

def powers(n as Int):
    return n, n ** 2, n ** 3

result = powers(10)
a, b, c = powers(10)

Returning multiple comma-separated values produces a positional Tuple<...Ts> that can be stored or unpacked.

Recursion

def fact(n as ULong) as ULong:
    return 1uL if n == 0uL else n * fact(n - 1uL)

Functions and Exceptions

Functions may raise exceptions as part of their normal implementation. Public APIs on types also commonly declare explicit throws clauses. The detailed exception model is covered in Exceptions and Resource Safety.

Functions Are Not Methods

Keep the two declaration styles separate

Top-level functions use def. Methods declared inside a class body do not. Inside a class you write declarations such as public compute() as Int: directly.