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. |
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 reassignparamtonull. -
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 anoutparameter does not read the caller's prior value, the caller may pass an uninitialized variable or a variable currently set tonull. The contract guarantees that every value assigned toparaminside 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"
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
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.