Skip to content

Functions & Control Flow

Functions are the primary unit of logic in NSL. They are always declared at the top level (no nested function definitions).

ReturnType functionName(Type param1, Type param2) {
// body
return value;
}
int add(int a, int b) {
return a + b;
}
string greet(string name) {
return `Hello, ${name}!`;
}

Parameters can have default values. Optional parameters must appear after all required parameters.

void log(string message, string level = "INFO", boolean timestamp = true) {
// level defaults to "INFO", timestamp defaults to true
}
// Valid calls:
log("Server started"); // level="INFO", timestamp=true
log("Error occurred", "ERROR"); // timestamp=true
log("Debug info", "DEBUG", false); // All specified

The VM has no concept of default parameters. The compiler handles them entirely:

  1. The compiler sees log("Server started")
  2. It looks up the function definition
  3. It finds 2 missing arguments
  4. It injects their default values into the bytecode

The generated bytecode is identical to what would be produced for log("Server started", "INFO", true). Zero runtime overhead.

A function can accept a variable number of arguments using the varargs syntax. At most one varargs parameter is allowed, and it must be the last parameter.

int sum(int ...values[]) {
int total = 0;
for (int i = 0; i < values.length(); i++) {
total = total + values[i];
}
return total;
}
// Calls:
int a = sum(1, 2, 3); // values = [1, 2, 3]
int b = sum(10, 20, 30, 40); // values = [10, 20, 30, 40]

Every .nox program must have a main function. This is the entry point called by the runtime.

  1. Parameters define the program’s input schema. The runtime deserializes incoming arguments (from JSON) into the specified types.
  2. Return type is implicitly string. The runtime automatically converts the final returned value (even int, double, or json) into a string.
  3. The main keyword replaces the return type in the signature.
main(string url, double minThreshold = 10.5) {
// url is required, minThreshold defaults to 10.5
return `Processed ${url} with threshold ${minThreshold}`;
}

The main function’s signature directly maps to the program’s input schema:

NSL ParameterSchema
string urlRequired string argument
double minThreshold = 10.5Optional double, defaults to 10.5
int[] idsRequired array of integers
User userRequired struct mapping (JSON object)

When running via the nox CLI, required parameters must be provided. Optional parameters that are omitted will use their default values.

  • Flags (-a, --arg): Pass arguments using name=value format. For structs or arrays, provide valid JSON strings:
    Terminal window
    nox run script.nox -a url="https://api.example.com" -a minThreshold=5.0
    nox run auth.nox -a user='{"name": "Alice", "age": 30}'
  • Interactive Prompts: If a required argument is omitted, the CLI will interactively prompt for it. For struct types, the CLI recursively prompts for each field individually, making complex input ergonomic.

UFCS allows a function to be called as if it were a method of its first argument. This provides an ergonomic, object-oriented feel without classes.

variable.method(args...) ←→ method(variable, args...)

These two calls are semantically identical.

type Point { int x; int y; }
double calculateDistance(Point p) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
main() {
Point origin = { x: 3, y: 4 };
// Standard function call
double dist1 = calculateDistance(origin);
// UFCS identical semantics
double dist2 = origin.calculateDistance();
// Both return 5.0
}

When the compiler encounters variable.method(args...), it resolves in this order:

  1. Host Method: Is variable a built-in type (like string) that has a native Kotlin-backed method called method?
  2. Struct Field: Is method a field on the struct type of variable?
  3. Global Function (UFCS): Is there a global function named method whose first parameter type matches variable’s type?
  4. Error: If none match, a SemanticError is thrown at compile time.

UFCS enables a “method chaining” style without requiring class hierarchies:

type User { string name; int age; }
boolean isAdult(User u) { return u.age >= 18; }
string displayName(User u) { return u.name.upper(); }
main() {
User alice = { name: "alice", age: 25 };
if (alice.isAdult()) {
yield `Adult: ${alice.displayName()}`;
}
}
if (condition) {
// true branch
} else if (otherCondition) {
// else-if branch
} else {
// false branch
}
while (condition) {
// body
}
for (int i = 0; i < 10; i++) {
// body
}

Iterates over arrays:

string[] names = ["Alice", "Bob", "Charlie"];
foreach (string name in names) {
yield `Processing ${name}`;
}

Under the hood: The compiler desugars foreach into a standard while loop with an index counter, .length() check, and array index access.

for (int i = 0; i < 100; i++) {
if (i % 2 == 0) {
continue; // Skip even numbers
}
if (i > 50) {
break; // Stop at 50
}
yield `Odd: ${i}`;
}

Under the hood: break compiles to a JMP to the loop’s exit instruction. continue compiles to a JMP to the loop’s condition re-evaluation.

NSL supports shorthand operators for common mutations:

i++; // Equivalent to i = i + 1
i--; // Equivalent to i = i - 1
i += 5; // Equivalent to i = i + 5
i -= 3; // Equivalent to i = i - 3
i *= 2; // Equivalent to i = i * 2
i /= 4; // Equivalent to i = i / 4
i %= 10; // Equivalent to i = i % 10

These work for both int and double values.

Under the hood: i++ and i-- compile to dedicated IINC/IDEC instructions. i += N compiles to IINCN. All are single-instruction operations with no temporary registers.

try {
json data = Http.getJson(url);
process(data);
} catch (NetworkError e) {
yield `Network error: ${e}`;
} catch (TypeError e) {
yield `Type error: ${e}`;
} catch (err) {
// Catch-all for any unhandled error type
yield `Unexpected error: ${err}`;
}
  • The catch variable (e.g., e, err) is a string containing the error message
  • Multiple catch blocks can target different error types
  • A catch-all (no type specified) catches anything not matched above
  • try-catch blocks can be nested
  • See Error Handling for the zero-cost table-driven implementation
if (value < 0) {
throw "Value must be non-negative";
}

These keywords send data back to the Host application, but with critically different effects.

Sends an intermediate result to the Host. The Sandbox continues executing.

main(string url) {
yield "Starting download..."; // Progress update
json data = Http.getJson(url);
yield `Downloaded ${data.size()} items`; // More progress
for (int i = 0; i < data.size(); i++) {
process(data[i]);
if (i % 100 == 0) {
yield `Processed ${i} items...`; // Periodic updates
}
}
return "All done!";
}

Key behavior:

  • yield sends output to the Host, which decides what to do with it (print to stdout, stream over a WebSocket, buffer in a list, etc.)
  • Execution continues immediately after each yield
  • yield can be called from any function, not just main

Sends the final result and terminates the Sandbox.

main(string name) {
return `Hello, ${name}!`; // Sandbox terminates after this
// Any code here is unreachable
}

Returns the value to the caller and exits the current function (standard semantics):

int max(int a, int b) {
if (a > b) {
return a;
}
return b;
}
KeywordWhereEffect
yield valueAny functionSends interim output; execution continues
return valuemainSends final output; Sandbox terminates
return valueOther functionsReturns to caller; function exits