API
main()
Section titled “main()”Your entrypoint file must define a main() function. It’s found and run automatically. Most of your code will live inside of this function. The exceptions are:
globaldefinitionsimportstatementsenumdefinitionsroledefinitionsextendstatementstypedefinitions- other functions can be defined at the global level
Defines a variable who’s value can be changed.
var x = "hello"x = "goodbye" ✅Defines a constant who’s value cannot be changed.
const x = "hello"x = "goodbye" ❌ // value is immutableglobal
Section titled “global”Used for global declarations, which can only exist at the top-level of a file, not inside functions.
- usually inlined at compile time
- strings not inlined if longer than 32 bytes
- arrays and tuples not inlined if size is greater than 4 items or if any item is not a primitive type
global VERSION = "v3" // inlined at compile timeglobal DATA = [1, 2, 3, 4, 5] // not inlined
fn main() { print(VERSION) print(DATA)}Log to the console, without formatting.
const name = "ngn"echo(name)// ngn
echo("Hello")echo("World")// HelloWorldLine logging to the console. Implicit \n.
print("Hello")print("World")// Hello// Worldparse()
Section titled “parse()”You can parse a JSON string or an array. json.parse() returns a Result<any, { message: string, line: i64, column: i64 }>.
const data = json.parse('{"name": "ngn"}')
// unwrap the Resultcheck data?, err? { print("Parse error at line ${err.line}: ${err.message}") return}print(data.name) // ngnstringify()
Section titled “stringify()”You can stringify an object or an array.
const data = { name: "ngn" }const str = json.stringify(data)print(str) // {"name": "ngn"}Strings
Section titled “Strings”length()
Section titled “length()”Return the length of a string.
index()
Section titled “index()”Search a string for a given pattern, and return the index number of the first instance found. If no pattern is found, returns -1. You can pass an optional start index.
index(pattern, start?)
const sent = "I learned to draw today."const ind = sent.index("to") // 10includes()
Section titled “includes()”Determine if a string includes a given pattern. Returns a bool.
includes(pattern)
const weather = "sunny"const inc = weather.includes("sun") // truestarts()
Section titled “starts()”Determine if a string starts with a given pattern. Returns a bool.
starts(pattern)
var process = "complete"const beg = process.starts("c") // trueends()
Section titled “ends()”Determine if a string ends with a given pattern. Returns a bool.
ends(pattern)
var process = "working"const end = process.ends("ing") // truesplit()
Section titled “split()”Create an array of strings by splitting on a pattern of characters within a string. If you do not pass a pattern, each character in the string is split individually. Preserves the original string.
split(pattern?)
const sent = "What. On. Earth."const split_sent = sent.split(".") // ["What", " On", " Earth", ""]
var greeting = "Hello"const split_greeting = greeting.split() // ["H", "e", "l", "l", "o"]replace()
Section titled “replace()”Replace a pattern with a string. search can be a string or a RegEx; but if a string is passed, only the first occurrence is replaced. Preserves the original string and returns a new one.
replace(search, replacement)
var plain = "Forge ahead"const fancy = plain.replace("a", "@") // "Forge @head"var plain = "Forge ahead"const fancy = plain.replace(/a/g, "@") // "Forge @he@d"copy()
Section titled “copy()”Copies an entire string or a section of it, based on indices. This does not change the string you copied from, but returns the copied value as a new string.
copy(start?, stop?)
- If
startis provided butstopis not, it copies everything upto and including the end of the string. - If
stopis provided (impliesstart), the copy excludes the item at that index. - If neither is provided, the entire string is copied.
const some = "Some Stuff"const copied = some.copy(5)
print(copied) // "Stuff"print(some) // "Some Stuff"
var all = some.copy()
print(all) // "Some Stuff"print(some) // "Some Stuff"slice()
Section titled “slice()”Remove a section of a string by providing a start index and an optional stop index. This changes the original string and returns the sliced section as a new string.
slice(start, stop?)
- If
stopis provided, the slice excludes the item at that index. - If
stopis not provided, it removes everything upto and including the last item. - Since you’re mutating the original string, it must be declared with
var.
var quote = "I flew too close to the sun on wings of pastrami."const sliced = quote.slice(24, 31)
print(orig) // I flew too close to the wings of pastrami.print(sliced) // "sun on "upper()
Section titled “upper()”Transform a string to all uppercase, returning a new string. Preserves original string.
const version = "one"print(version.upper()) // ONElower()
Section titled “lower()”Transform a string to all lowercase, returning a new string. Preserves original string.
var version = "ONE"print(version.lower()) // onetrim()
Section titled “trim()”Remove whitespace from both ends of a string, returning a new string. Preserves original string.
var thing = " strong "print(thing.trim()) // "strong"repeat()
Section titled “repeat()”Repeat a string some number of times.
repeat(num)
const ending = "goodbye"print(greeting.repeat(2)) // goodbyegoodbyeNumbers
Section titled “Numbers”There are currently no number methods, but we do have a math mod in the Toolbox, or you can use the extend keyword to add your own.
Arrays
Section titled “Arrays”If you want to mutate arrays, be sure to declare them with var
var stuff = ["hat", "coat", "gloves"]const ages = [3, 8, 15, 23]
const mixed = ["hat", true, 7] ❌ // cannot mix typesDestructuring
Section titled “Destructuring”Extract elements from an array into variables:
const arr = [10, 20, 30, 40]const [first, second] = arr
print(first) // 10print(second) // 20Collect remaining elements into a new array:
const nums = [1, 2, 3, 4, 5]const [head, ...tail] = nums
print(head) // 1print(tail) // [2, 3, 4, 5]print(tail.size()) // 4size()
Section titled “size()”Return the size of the array.
push()
Section titled “push()”Push, i.e. add, an item into an array. By default, it pushes at the end. To push into another location, provide the index number. Returns the new size of the array as an i64.
push(item, index?)
var stuff = ["guitar", "shirt"]const size = stuff.push("hat")
print(size) // 3print(stuff) // ["guitar", "shirt", "hat"]
stuff.push("coat", 0)print(stuff) // ["coat", "guitar", "shirt", "hat"]Pop, i.e. remove, an item from an array. By default, it removes from the end. To pop from another location, provide the index number. Returns the removed item’s value.
pop(index?)
var stuff = ["coat", "guitar", "shirt", "hat"]const popped = stuff.pop()
print(popped) // hatprint(stuff) // ["coat", "guitar", "shirt"]
const popped_one = stuff.pop(1)
print(popped_one) // ["guitar"]print(stuff) // ["coat", "shirt"]copy()
Section titled “copy()”Copies an entire array or a section of it, based on indices. This does not change the array you copied from, but returns the copied items as a new array.
copy(start?, stop?)
- If
startis provided butstopis not, it copies everything upto and including the last item. - If
stopis provided (impliesstart), the copy excludes the item at that index. - If neither is provided, the entire array is copied.
const stuff = [10, 20, 30, 40, 50]const copied = stuff.copy(3)
print(copied) // [40, 50]print(stuff) // [10, 20, 30, 40, 50]
var all = stuff.copy()
print(all) // [10, 20, 30, 40, 50]print(stuff) // [10, 20, 30, 40, 50]slice()
Section titled “slice()”Remove a section of the array by providing a start index and an optional stop index. This changes the array and returns the item(s) as a new array.
slice(start, stop?)
- If
stopis provided, the slice excludes the item at that index. - If
stopis not provided, it removes everything upto and including the last item.
var stuff = [10, 20, 30, 40, 50]const sliced = stuff.slice(1, 3)
print(sliced) // [20, 30]print(stuff) // [10, 40, 50]splice()
Section titled “splice()”Add multiple items to an array; optionally, at a specific index. Returns the new size of the array.
splice(item[], start?)
- If
startis not provided, it adds the items at the end.
var stuff = [10, 20, 30]stuff.splice([40, 50]) // [10, 20, 30, 40, 50]
const size = stuff.splice([45, 47], 4)
print(stuff) // [10, 20, 30, 40, 45, 47, 50]print(size) // 7each()
Section titled “each()”For each item in an array, execute a closure.
each(|item, index| {})
var things = ["hat", "gloves", "coat"]
things.each(|t, i| { print("${i}: ${t}")})Tuples
Section titled “Tuples”Similar to arrays, but can contain mixed types. However, they are fixed-size and immutable.
const point = (10, 20)
// they can be indexed like arraysconst x = point[0] // 10const y = point[1] // 20
const tup = (10, "hello", true)Destructuring
Section titled “Destructuring”Extract elements from a tuple into variables:
const point = (10, 20)const (x, y) = point
print(x) // 10print(y) // 20Tuples preserve the type of each element:
const mixed = (42, "hello", true)const (num, str, flag) = mixed
print(num) // 42 (i64)print(str) // hello (string)print(flag) // true (bool)Collect remaining elements into a new tuple:
const values = (1, 2, 3, 4, 5)const (first, second, ...rest) = values
print(first) // 1print(second) // 2print(rest) // (3, 4, 5)size()
Section titled “size()”Return the size of the tuple.
includes()
Section titled “includes()”Check if a tuple contains a specific item.
includes(item)
const tup = (10, "hello", true)const has_hello = tup.includes("hello")
print(has_hello) // trueindex()
Section titled “index()”Search a tuple for a given item, and return the index number of the first instance found. If no item is found, returns -1.
index(item)
const tup = (10, "hello", true)const ind = tup.index("hello") // 1copy()
Section titled “copy()”Copies an entire tuple or a section of it, based on indices. This does not change the tuple you copied from, but returns the copied items as a new tuple.
copy(start?, stop?)
- If
startis provided butstopis not, it copies everything upto and including the last item. - If
stopis provided (impliesstart), the copy excludes the item at that index. - If neither is provided, the entire tuple is copied.
const tup = (10, "hello", true)const copied = tup.copy(1)
print(copied) // ("hello", true)print(tup) // (10, "hello", true)toArray()
Section titled “toArray()”Convert a tuple to an array. Items must be of the same type.
const tup = (10, 20, 30)const arr = tup.toArray()
print(arr) // [10, 20, 30]join()
Section titled “join()”Join a tuple into a string, separated by a given delimiter.
join(delimiter)
const tup = (10, 20, 30)const joined = tup.join(",")
print(joined) // "10,20,30"Objects
Section titled “Objects”You can create raw objects using the {} syntax and access their properties using the dot notation.
const person = { name: "John", age: 30, isStudent: false}
print(person.name) // Johnprint(person.age) // 30print(person.isStudent) // falseYou can also use shorthand syntax for assigning values to object fields.
const name = "John"const age = 30const isStudent = false
const person = { name, age, isStudent }
print(person.name) // Johnprint(person.age) // 30print(person.isStudent) // falseDestructuring
Section titled “Destructuring”Extract fields from an object into variables. Destructured fields are Maybe values.
const person = { name: "Alice", age: 30, city: "NYC" }const { name, age } = person
check name? { return }check age? { return }
print(name) // Aliceprint(age) // 30Aliasing
Section titled “Aliasing”Use a different variable name than the field name:
const user = { id: 42, email: "test@example.com" }const { id, email: userEmail } = user
check id? { return }check userEmail? { return }
print(id) // 42print(userEmail) // test@example.comCollect remaining fields into a new object:
const data = { a: 1, b: 2, c: 3, d: 4 }const { a, b, ...rest } = data
check a? { return }check b? { return }check rest? { return }
print(a) // 1print(b) // 2print(rest.c) // 3print(rest.d) // 4Create object structures.
model Dog { name: string, breed: string}Generic Models
Section titled “Generic Models”Models can have type parameters for creating reusable container types:
model Container<T> { value: T}
model Pair<K, V> { key: K, val: V}
fn main() { const intBox = Container { value: 42 } const strBox = Container { value: "hello" } const pair = Pair { key: "age", val: 25 }}Alternative for instantiating
Section titled “Alternative for instantiating”You may also choose to create a constructor method and use it to create a new instance of a model.
model User { name: string, age: u32}
fn main() { var user = User.new("Chloe", 27)}Destructuring
Section titled “Destructuring”model Point { x: i64, y: i64}
fn main() { const p = Point { x: 5, y: 10 } const { x: px, y: py } = p
print(px) // 5 print(py) // 10}Mutating model data
Section titled “Mutating model data”When you create an instance of a model, it’s essentially an object - although it can have methods attached to it as well.
The general rule is that you can mutate based on how the variable was declared (var, const). However, you can’t change a field’s type.
Here are the ways to manipulate an object’s fields, based on the above example code:
- direct assignment:
user.age = 7 - entire object:
user = { name: "Ben", age: 56 } - method:
user.changeName("Stacie") - by
const,globalvariables: ❌ not allowed, as these are all strictly immutable
Type Inference
Section titled “Type Inference”When you instantiate a generic model, ngn infers the concrete type from the field values:
model Box<T> { value: T}
var box = Box { value: 42 } // Inferred as Box<i64>print(box.value) // 42
// Type is enforced on reassignment:box.value = 100 // ✓ OK - same type (i64)box.value = "hello" // ✗ Type Error: Cannot assign String to field 'value' of type I64This ensures type safety even with generic types - once a type parameter is bound to a concrete type, it remains consistent.
There’s no need to fear this in ngn. It’s an implicit reference to the instance or type that a method is called on.
For models, it gives you access to the instance’s fields and other methods.
model User { name: string, age: u32}
extend User { fn greet(): string { print("Hello, I'm {this.name}") }
fn changeName(name: string): void { this.name = name }}
var user = User { name: "Jason", age: 47 }user.greet() // "Hello, I'm Jason"For custom type methods, it gives you access to the type’s value.
extend string { fn isBlank(): bool { return this.trim().length() == 0 }}
const name = ""print(name.isBlank()) // trueYou can extend a model’s functionality with groups of methods via roles. Declare one or more method signatures and/or method implementations. Use this to group methods into roles in order to define their functionality for models.
role Animal { fn speak(): void}extend
Section titled “extend”Extend a model or built-in type with methods.
Models
Section titled “Models”// extend with custom methodsextend Dog { fn fetch(thing: string): bool { return attemptToFetch(thing) }}// extend with roleextend Dog with Animal { fn speak(): void { print("Woof, woof!") }}Now, putting it all together:
const my_dog = Dog { name: "Apollo", breed: "Labrador"}print(my_dog) // { name: Apollo, breed: Labrador }print(my_dog.name) // Apollo
const fetched = my_dog.fetch("stick")print(fetched) // either true or false
my_dog.speak() // Woof, woof!Built-in methods
Section titled “Built-in methods”If you want to add methods to built-in types, you can use the extend keyword. This feature applies to following types:
- number (generic that applies to all numeric types)
- f64, i32, u8, etc (for specific numeric types)
- string
- bool
- array
- tuple
- map
- set
extend array { fn isEmpty(): bool { return this.size() == 0 }}
extend number { fn isEven(): bool { return this % 2 == 0 }
fn double(): number { return this * 2 }}
extend string { fn isBlank(): bool { return this.trim().length() == 0 }}
fn main() { [1, 2, 3].isEmpty() // false
// if using a number directly, wrap in parenthesis (2).isEven() // true
const x = 2 x.isEven() // true
// if a number method returns the generic `number` type, you should explicitly set the result type const y: i32 = x.double()
" ".isBlank() // true}bytes()
Section titled “bytes()”A built-in binary data type. It represents an arbitrary sequence of raw bytes (0..255).
You will most commonly use bytes for binary WebSocket frames, encoding/decoding, and other I/O-style APIs.
-
bytes(), Create an empty bytes value. -
bytes(string), Create bytes from a UTF-8 string.const b = bytes("hello")print(b.length()) // 5 -
bytes(array<u8>), Create bytes from an array of numeric byte values. Each element must be in the range 0..255.const raw: array<u8> = [0, 255, 16]const b = bytes(raw)print(b.length()) // 3
length()
Section titled “length()”Return the number of bytes.
copy()
Section titled “copy()”Copy an entire bytes value or a section of it, based on indices. This does not change the bytes you copied from.
copy(start?, stop?)
- If
startis provided butstopis not, it copies everything upto and including the end. - If
stopis provided (impliesstart), the copy excludes the item at that index. - If neither is provided, the entire bytes is copied.
slice()
Section titled “slice()”Remove a section of bytes by providing a start index and an optional stop index. This changes the original bytes value and returns the sliced bytes.
slice(start, stop?)
- If
stopis provided, the slice excludes the item at that index. - If
stopis not provided, it removes everything upto and including the last byte. - Since you’re mutating the original bytes, it must be declared with
var.
var b = bytes("abcd")const sliced = b.slice(1, 3)
print(sliced.toStringStrict()) // bcprint(b.toStringStrict()) // adtoString()
Section titled “toString()”Decode bytes as UTF-8 using a lossy conversion. Invalid sequences are replaced.
This is useful for logging/debugging or when you are working with “mostly” UTF-8 data.
toStringStrict()
Section titled “toStringStrict()”Decode bytes as UTF-8 using a strict conversion.
If the bytes are not valid UTF-8, this throws a runtime error.
ngn provides two built-in enums for common patterns: Result and Maybe
Result
Section titled “Result”Result<T, E> represents an operation that can either succeed or fail.
Variants
Section titled “Variants”Ok(T)— The operation succeeded with a value of typeTError(E)— The operation failed with an error of typeE
Examples
Section titled “Examples”fn divide(a: i64, b: i64): Result<i64, string> { if b == 0 return Error("Division by zero not allowed") return Ok(a / b)}
fn main() { const result = divide(10, 2) match (result) { Ok(value) => print("Ok: {value}"), // Ok: 5 Error(msg) => print("Error: {msg}"), // Error: Division by zero not allowed }}Maybe<T> represents a value that may or may not exist. You can write Maybe<T> or use the shorthand T? syntax.
Variants
Section titled “Variants”Value(T)— The value existsNull(ornull) — The value does not exist
Type syntax
Section titled “Type syntax”// These are equivalent:var x: Maybe<i64> = nullvar y: i64? = null
// Function return types:fn find(id: i64): i64? { // Same as Maybe<i64> if (id == 1) return Value(100) return null}
// Complex types:var arr: array<string>? = null // Optional arrayExamples
Section titled “Examples”fn findUser(id: u64): Maybe<string> { if (id == 1) return Value("Jason") if (id == 2) return Value("Brad") return Null}
const user1 = findUser(1)const user2 = findUser(99)
match (user1) { Value(name) => print("Found: {name}"), // matches Null => print("User not found"),}
match (user2) { Value(name) => print("Found: {name}"), Null => print("User not found"), // matches}null keyword
Section titled “null keyword”The null keyword is syntactic sugar for Maybe::Null. You can use it in any context where Null would be used:
// These are equivalentvar m1 = nullvar m2 = Null
// Using null in return statementsfn maybeValue(flag: bool): Maybe<i64> { if (flag) return Value(42) return null // Syntactic sugar for Maybe::Null}
// null works with the ?? (null-coalescing) operatorvar x: Maybe<i64> = nullvar result = x ?? 100 // result is 100Null checks with !
Section titled “Null checks with !”You can use the ! operator on Maybe values to check if they are null:
fn describe(x?: i64): string { if (!x) return "is null" // !null is true, !Value(_) is false return "has value"}print(describe()) // "is null"print(describe(42)) // "has value"Optional chaining (?.)
Section titled “Optional chaining (?.)”Use ?. to safely access fields or call methods on Maybe values. If the value is null, the entire expression short-circuits to null.
model User { name: string, age: i64}
var user: User? = nullvar name = user?.name // null (short-circuited)var safeName = user?.name ?? "Unknown" // "Unknown"
var user2 = Value(User { name: "Alice", age: 30 })var name2 = user2?.name // Value("Alice")
// Chainingmodel Address { city: string }model Person { address: Address }
var p: Person? = nullvar city = p?.address?.city // null (multiple short-circuits)Use the postfix ? guard to unwrap a Maybe<T> or Result<T, E> inside an if branch.
fn greet(name: string, suffix?: string): string { if (suffix?) { // inside this branch, `suffix` is a `string` return "Hello ${name}${suffix}" } return "Hello ${name}"}check ?
Section titled “check ?”check is a guard for Maybe<T> / Result<T, E> that reduces ceremony.
Syntax:
check value? { /* failure */ }check value?, err? { /* failure */ }Semantics:
- If
valueisNullorError, the failure block runs and must exit (return/break). - If
valueisValue(T)orOk(T), the failure block is skipped andvalueis upgraded to the unwrappedTfor the rest of the scope. err(if provided) is only available inside the failure block.
fn getUser(user?: string): Result<User, string> { check user? { return Error("User not found") } // after check, `user` is a `string` return Ok(User { name: user, age: 0 })}check error binding
Section titled “check error binding”fn fetchData(): Result<string, string> { const result: Result<string, string> = Error("network timeout")
check result?, err? { print("Failed: ${err}") return Error(err) }
// after check, `result` is a `string` return Ok(result)}Custom Enums
Section titled “Custom Enums”You can define your own enums for domain-specific types.
enum Color { Red, Green, Blue }
enum Status { Active, Inactive(string) // With associated data}
fn main() { const color = Red print(color) // Color::Red
const status = Inactive("maintenance") print(status) // Status::Inactive (maintenance)
match (status) { Active => print("Status: Active!"), Inactive(value) => print("Status: Inactive with reason, {value}") }}Generic Enums
Section titled “Generic Enums”Custom enums can also have generic type parameters:
enum Option<T> { Some(T), None}
fn main() { const value = Some(42) // Inferred as Option<i64>
match (value) { Some(v) => print("Got: {v}"), // v has type i64 None => print("Got nothing") }}When you use Some(42), ngn infers that this is an Option<i64>. In match patterns, bindings like v in Some(v) are given the concrete type (i64), not the type parameter (T).
Null Coalescing Operator
Section titled “Null Coalescing Operator”Returns the left-hand side if non-null, otherwise returns the right-hand side. If there’s a Maybe::Value<T>, it’s automatically unwrapped.
const u = getUser() // Maybe<User>const user = u ?? "anonymous"Logical Operators
Section titled “Logical Operators”ngn supports industry-standard short-circuit boolean operators:
&&: logical AND||: logical OR
fn main() { const a = 15 if (a > 10 && a < 20) print("in range")
const ok = true || false assert(ok == true)}Precedence:
&&binds tighter than||- both bind tighter than
??(null coalescing)
Notes on |:
|is still used for union types:type IntOrString = i64 | string|is still used inmatcharms to match multiple patterns:2 | 3 => ...
Ranges
Section titled “Ranges”Ranges are integer sequences. Use .. for inclusive ranges and ..< for exclusive upper bounds.
const arr<i32> = [1..5]print(arr) // [1, 2, 3, 4, 5]
for (i in 1..5) { print(i)}
match (score) { 94..99 => print("great"), 90..<94 => print("good"), _ => print("keep striving")}- Range bounds must be integers (no floats).
- Ranges are empty when the start is greater than the end.
- Array range literals only allow a single range (e.g.
[1..5]).
Run the statement block indefinitely. Use break to exit the loop.
loop { statement statement}Run the statement block while the condition is true. Not guaranteed to run at all.
while (condition) { statement statement}Can be inlined if only using a single statement.
while (condition) statementTo always run the statement block once, before checking the condition.
while once (condition) { statement}Run a statement block for each message in a channel or items in a collection.
Arrays have an
eachmethod, so you don’t need to use a for loop with them unless you want to.
for (msg in <-? channel) { print(msg)}
for (item in items) { print(item)}Run a statement based on if a condition is true.
For blocks with only a single statement, you can use the following syntax:
if (condtion) statement : (condition) statement : statement
if (condition) statement: (condition) statement: statementThe below syntax is required if any of your blocks have multiple statements. Note the first brace comes directly after the if keyword.
if { (condition) statement statement : (condition) statement : statement}Match a value against one test case; optionally, provide a default case.
If a match is found:
- that branch’s statement block is run.
- other cases are skipped, including the default, unless a matched statement block contains the
nextkeyword.nextwill only try to match the very next case.
const value = 3match (value) { 1 => statement, 2 | 3 => statement, // matches 2 or 3 4 => { statement statement next }, // matches 4, runs both statements, then tries to match the next case _ => statement // matches any other value}fn (functions)
Section titled “fn (functions)”Functions create an isolated environment, meaning it can’t access values outside of itself. If you need access to a value outside the environment, pass it as a parameter; but there are exceptions, which you can always access:
- globals (imports, models, enums, functions)
- sibling functions
If passing a function as a param, you can mark the param like fn<param1_type, param2_type, paramN_type, return_type>. return_type is always last in the list, even if that means it’s the only type listed.
Function params must be explicitly typed - otherwise ngn will show a console warning.
Explicit return
Section titled “Explicit return”fn add(a: i64, b: i64): i64 { return a + b}Explicit multiline return
Section titled “Explicit multiline return”fn add(a: i64, b: i64): i64 { return ( a + b )}Implicit return
Section titled “Implicit return”fn add(a: i64, b: i64): i64 a + bSide-effects only
Section titled “Side-effects only”Functions that only perform side-effects don’t need a return type, but you can declare void if you want.
fn doThing() { print("something")}Owned params
Section titled “Owned params”When you mark a function param as owned, here is what happens:
- the value is mutable within the function, if it was declared with
var - ownership of the passed data is moved to the function
- the var or const is no longer accessible outside of the function
- ngn will cleanup any associated memory after the function finishes
var x = "hello"
// the `<` means it requires an owned stringfn createRockMusic(stuff: <string) { // do stuff: read and/or mutate}
createRockMusic(x) ✅ // moves ownership of `x` to the function
print(x) ❌ // `x` is no longer available, since it's ownership was movedBorrowed params
Section titled “Borrowed params”This is the default for all params.
var x = "hello"
fn readThing(thing: string) { // do thing: but cannot mutate}
readThing(x) ✅ // does not move ownership of `x` to the function
print(x) ✅ // `x` is still availableOptional params
Section titled “Optional params”In this example, suffix is optional. Inside the function, it is either Maybe::Value<T> or Maybe::Null; so they must be checked or unwrapped (see Enums section for more options).
fn greet(name: string, suffix?: string): string { // explicit match match (suffix) { Value(s) => return "Hello ${name}${s}" Null => return "Hello ${name}" }}print(greet("Bob")) // "Hello Bob"print(greet("Bob", "!")) // "Hello Bob!"fn greet(name: string, suffix?: string): string { // If the optional has a value, unwrap it for this block. if (suffix?) { // inside this branch, `suffix` is a `string` (unwrapped) return "Hello ${name}${suffix}" } return "Hello ${name}"}print(greet("Bob")) // "Hello Bob"print(greet("Bob", "!")) // "Hello Bob!"Default params
Section titled “Default params”Default params are implicitly optional, but are never a “maybe” since it’s guaranteed to have a value.
fn greet(name: string, suffix: string = "!") { print("Hello ${name}${suffix}")}print(greet("Bob")) // "Hello Bob!"print(greet("Bob", ",")) // "Hello Bob,"Closures
Section titled “Closures”Closures are similar to functions, but have important differences:
- assign them with
constthen call like a function - access to external values, even ones outside its environment
- wrap params with
|
Param ownership transfer is the same as functions. To mutate the value of a variable from within a closure, use state() to declare the variable.
-
The closure captures outside values at creation.
var count = 0const incrementBy = |a: i64| count + a // captures `count` at 0print(incrementBy(10)) // 10print(incrementBy(5)) // 5count = 100print(incrementBy(7)) // still 7const incrementCount = |a: i64| count + a // captures `count` at 100print(incrementCount(7)) // 107 -
You can mimic classic “close over” behavior by returning a closure from a function.
fn main() {var count = 10fn adder(c) {return |m| {return c + m}}const add_it = adder(count)// add_it becomes the returned closure from adder,// and the value of `c` is locked-in as 10// since that was the value of `count` when it was passedprint(add_it(3)) // 13count = add_it(5) // sets count to 15const add_me = adder(count) // param `c` is 15 for this closureprint(add_me(5)) // 20}Or, the closed over value can be within the function. In this case, we use
state()to declare the variable since we need to mutate it from within the closure.fn main() {fn make_counter() {var count = state(0)return || {count.update(|c| c + 1)print(count)}}const counter = make_counter()counter() // 1counter() // 2counter() // 3}
Create a key, value map.
- You can seed a map with an array of 2-tuples
- Types are inferred from the seed
- Empty seeds require explicit type args
const m = map([("one", 1), ("two", 2)])
// add an entrym.set("three", 3) // returns the map
// chain setm.set("four", 4).set("five", 5)
// checks if an entry exists, based on keym.has("one") // returns a bool
// get an entrym.get("one") // returns the value, or void if not found
// remove an entrym.remove("one") // returns the removed value
// get the sizem.size() // returns the number of entries in the map
// empty seed requires explicit typesconst empty = map<string, i64>()Create a set of values.
- You can seed a set with an array
- Types are inferred from the seed
- Empty seeds require explicit type args
- Values are deduplicated
const s = set(["one", "two", "three"])
// add a values.add("four") // returns the set
// chain adds.add("five").add("six")
// checks if a value existss.has("one") // returns a bool
// remove a values.remove("one") // returns a bool of whether the value was removed
// get the sizes.size() // returns the number of values in the set
// clear all valuess.clear()
// empty seed requires explicit typesconst empty = set<string>()
// iterate over valuesfor (v in s) { print(v)}
// iterate over values with indexfor (v, i in s) { print("index: ${i}, value: ${v}")}channel
Section titled “channel”Send and receive data.
- You must declare a channel with
const - You must provide a data type for the channel’s messages.
<- syntax
Section titled “<- syntax”Use <- to both send and receive messages. Let’s assume we have a channel called line.
line <- "hello"would send a string message to the channel.<- linewould cause the program to stop and wait for a single message.
Regarding the last point: if you had multiple things sending messages, you have the following options:
// Would wait on two messages before continuing the program.<- line<- line
// If you know how many messages to wait on, here's a shorthand version of the above:<-2 line
// You can even base it off of other things:<-tasks.size() line // array size<-(x + y) line
// If you don't know how many messages you'll receive.// You'll need to close the channel for this to work (example futher below).<-? linefn main() { const c = channel<string>()
// Send a message c <- "first"
// Optionally close the channel for this example c.close()
// Assign channel output to a variable // Receiving "first" will still work here, because of buffering const msg = <- c print("Received: ${msg}")
// This will fail because the channel is closed and empty. const fail = <- c}You can send a closure to a channel:
fn main() { const job_queue = channel<fn<i64, void>>()
// (See next section for details on threads) const done = thread(|| { print("Worker started") loop { match (<-? job_queue) { Value(task) => task(42), Null => break } } print("Worker finished")
return })
job_queue <- |n: i64| { print("Task A executing with ${n}") }
job_queue <- |n: i64| { const res = n * 2 print("Task B executing: ${n} * 2 = ${res}") }
// must close the channel to break out of `while` loop job_queue.close()
print("Jobs sent")
// wait for jobs to complete <- done
print("Jobs complete")}Jobs sentWorker startedTask A executing with 42Task B executing: 42 * 2 = 84Worker finishedJobs completeChannels can even contain other channels, and you can send/receive data within those inner channels.
fn main() { const request_line = channel<channel<string>>()
thread(|| { // Thread waits for inbound data on the request_line channel, // which happens to be another channel that we assign to a constant. const reply_channel = <- request_line
// Reply back on the private channel reply_channel <- "Your order is ready!" })
// Create a private response channel const private_channel = channel<string>()
// Send private channel, which the worker is waiting for request_line <- private_channel
// Wait for the private reply print(<- private_channel)}thread
Section titled “thread”Allows you to do work while, optionally, continuing to do work in the main thread. Threads take a closure with no parameters, but have access to their surrounding scope. They also return a channel, if that fits your usecase.
Standalone threads are risky because as soon as the main program ends, all unfinished threads are killed. In the below example, setData(value) may never finish.
fn main() { const value = 100
thread(|| { setData(value) })
// Continue doing other work while the thread runs print(value)}In such cases, use the returned channel to await the thread.
fn main() { const value = 100
// "done" is a channel we can use for signaling const done = thread(|| { setData(value) return // returning from a thread sends that data to the created channel })
// Continue doing other work while the thread runs
// Now wait until we receive a message, // indicating thread work is done <- done}Threads may run in parallel or sequentially but unordered; however, you can control the order in which you wait on their results.
fn main() { print("1. Main started")
// Create a thread, to do some async work const c = thread(|| { print(" 2c. Thread c started (sleeping...") sleep(2000)
print(" 3c. Thread c sending message") return Ok("Hello from channel c!") })
// Create a thread, to do some async work const d = thread(|| { print(" 2d. Thread d started (sleeping...") sleep(2000)
print(" 3d. Thread d sending message") return Error("Oh this is bad channel d!") })
print("4. Main doing other work while thread runs...")
// This should block until the "c" thread sends a message const msgc = <- c print("5c. Main received, from thread c: ${msgc}")
// This should block until the "d" thread sends a message const msgd = <- d print("5d. Main received, from thread d: ${msgd}")}1. Main started4. Main doing other work while thread runs... 2c. Thread c started (sleeping...) 3c. Thread c sending message 2d. Thread d started (sleeping...) 3d. Thread d sending message5c. Main received, from thread c: Result::Ok (Hello from channel c!)5d. Main received, from thread d: Result::Error (Oh this is bad channel d!)If you’re unsure how much data is coming, use a for loop, and then close the channel at the end of the input in order to indicate that no more messages can be sent. Below, we’re simulating “unknown” amounts of data.
fn main() { // In this example, we can't use the thread's returned channel, // because we need to close the channel from within the thread // in order to signal the `for` loop to stop. const c = channel<string>()
thread(|| { c <- "A" c <- "B" c <- "C" c.close() })
for (msg in <-? c) { print("Got: {msg}") }
print("Done")}Returned Channels
Section titled “Returned Channels”Creating a thread returns a channel, which can be used to await the thread’s result. To send data to the channel, just return from the thread - either empty or with a value.
fn main() { const done = thread(|| { setData(value) return Ok("Done") })
<- done}state()
Section titled “state()”It’s safe to sequentially mutate shared data outside of threads or within a single thread. However, if one or more threads might mutate data, use state() to declare the variable. This gives you safe, atomic operations for mutating the data by using state variable methods.
You’d also use state() if you need to mutate data from within a closure.
fn main() { var counter = state(0) const done = channel<bool>()
thread(|| { // Pass a closure that mutates the data. // The closure receives the current value of `counter` via a param. counter.update(|n| n + 10) // implicit return used print("added 10") done <- true })
thread(|| { counter.update(|n| n + 5) print("added 5") done <- true })
// If number of awaited messages is known, you can declare that here. // They'll be returned as an array, if you need to assign them. <-2 done
print(counter) // Always 15}If needed, you also have access to these variable methods when using state():
.read(), gets the current value.write(), sets the current value - which replaces the existing one. Be careful, as it can be tricky to ensure proper mutation order when coupled with.update().
The spawn global provides two related tools:
- single-task offloading (
spawn.cpu,spawn.block) which returns achannelimmediately - multi-task parallel helpers (
spawn.all,spawn.try,spawn.race) which return results directly
Tasks should return Result<T, E>. If a task returns a non-Result value, it will be wrapped as Ok(value). Thread panics are automatically converted to Error("Thread panicked: <error message>").
Run a single task on the CPU worker pool.
spawn.cpu(task)
This call does not block immediately; it returns a channel right away, and you await it with <-.
- accepts either a function or a closure
- returns
channel<Result<any, string>> - if the CPU pool queue is full, the returned channel will contain
Error("spawn.cpu() queue is full")
fn expensive(): i64 { // some CPU-heavy work return 123 // automatically wrapped with `Ok()`}
fn main() { const ch = spawn.cpu(expensive) // do other work?
const result = <- ch print(result)}block()
Section titled “block()”Run a single task on the blocking worker pool (for work that may stall the current thread, e.g. filesystem operations, subprocess waits, etc.).
spawn.block(task)
The name block refers to the kind of work (it may block internally). The call itself returns a channel immediately, and you use <- when you want to await it.
Note: fetch() already runs on the blocking pool internally, so there’s no need to wrap fetch() in spawn.block().
- accepts either a function or a closure
- returns
channel<Result<any, string>> - if the blocking pool queue is full, the returned channel will contain
Error("spawn.block() queue is full")
import { file } from "tbx::io"
fn main() { // File I/O is blocking. Offload it so it doesn't stall other work. const ch = spawn.block(|| file.read("./big.txt"))
const result = <- ch print(result)}Returns an array of results (including any errors).
spawn.all(tasks, options?)
fn task1(): Result<string, string> { return Ok("Task 1 done") }fn task2(): Result<string, string> { return Error("Task 2 failed") }fn task3(): Result<string, string> { return Ok("Task 3 done") }
const results = spawn.all([task1, task2, task3])// [Result::Ok (Task 1 done), Result::Error (Task 2 failed), Result::Ok (Task 3 done)]
for (result in results) { match (result) { Ok(msg) => print(msg), Err(msg) => print(msg) }}With concurrency limit:
const results = spawn.all(tasks, { concurrency: 2 }) // Max 2 concurrent threads at a timeStop spawning new tasks on first error. Returns partial results up to and including the error.
spawn.try(tasks, options?)
const results = spawn.try([task1, task2, task3], { concurrency: 4 })// Stops when first error occurs// [Result::Ok (Task 1 done), Result::Error (Task 2 failed)]race()
Section titled “race()”Returns the first successful result, or the first error if all tasks fail.
spawn.race(tasks)
const result = spawn.race([task1, task2, task3])// Result::Ok (Task 1 done) - whichever completes first with successfetch()
Section titled “fetch()”Use fetch to make HTTP requests, such as to external APIs. It returns a channel, so you await it with the <- channel operator.
const response = <- fetch("https://example.com")print(response)const response = <- fetch("https://example.com", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/json", }, body: json.stringify({ "name": "John Doe", "email": "john.doe@example.com", }), timeout: 30000,})print(response)Properties
Section titled “Properties”url: The URL of the requestoptions: An object containing the options for the requestmethod: The HTTP method of the request - defaults to GETheaders: The headers of the requestbody: The body of the requesttimeout: The timeout of the request, in milliseconds - defaults to 10 seconds
Request
Section titled “Request”You can handle Request objects.
fn handler(req: Request) { const path = req.path const method = req.method const headers = req.headers const body = req.body
return Response { body: "Hello, world!", }}
export default { fetch: handler}Properties
Section titled “Properties”method: The HTTP method of the requesturl: The URL of the requestprotocol: Whether HTTP or HTTPs was usedhost: The host the client used to make the requestpath: The path of the requestquery: The query string of the requestparams: Query parameters as aMap<string, string>headers: The headers of the requestbody: The body of the requestip: The client’s IP addresscookies: The cookies sent with the request
Methods
Section titled “Methods”clone(): Creates a newRequestobject with the same propertiestext(): Parses the body as a string, returns astringjson(): Parses the body as JSON, returns a Result enumformData(): Parses URL-encoded body, returns aMap<string, string>
Response
Section titled “Response”You can create a Response object to send HTTP responses.
fn handler(req: Request) { return Response { body: "Hello, world!", }}
export default { fetch: handler}Properties
Section titled “Properties”status: The HTTP status code - default is 200statusText: The HTTP status text - default is ""ok: A boolean indicating whether the response status code is in the range 200-299headers: The headers to include in the responsebody: The body of the response - default is ""
Methods
Section titled “Methods”text(): Parses the body as a string, returns astringjson(): Parses the body as JSON, returns a Result enumformData(): Parses URL-encoded body, returns aMap<string, string>
StreamingResponse
Section titled “StreamingResponse”Send HTTP responses with chunked transfer encoding, allowing data to be streamed to the client as it becomes available. Perfect for LLM inference, large file downloads, or any scenario where you want to send data incrementally.
fn handler(req: Request): StreamingResponse { const chunks = channel<string>()
// Background thread produces data thread(|| { chunks <- "First chunk\n" sleep(500) chunks <- "Second chunk\n" sleep(500) chunks <- "Done!\n" chunks.close() // Signals end of stream })
return StreamingResponse { headers: { "Content-Type": "text/plain" }, body: chunks }}
export default { fetch: handler }Properties
Section titled “Properties”status: The HTTP status code - default is 200headers: The headers to include in the responsebody: Achannel<string>that produces chunks. Close the channel to end the stream.
SseResponse (Server-Sent Events)
Section titled “SseResponse (Server-Sent Events)”Server-Sent Events (SSE) streams a sequence of events over a single HTTP response. This is useful for realtime updates (notifications, progress updates, model inference tokens, etc.).
SSE works over both HTTP and HTTPS in ngn.
fn handler(req: Request): SseResponse { const events = channel<SseMessage>()
thread(|| { events <- SseEvent { data: "Hello", event: "hello", id: "", retryMs: 0, comment: "" } sleep(500)
// Send raw strings as event data events <- "World" sleep(500)
// Send raw objects shaped like SseEvent events <- { data: "Hello", event: "hello", id: "", retryMs: 0, comment: "" }
events.close() })
return SseResponse { status: 200, headers: { "Access-Control-Allow-Origin": "*" }, body: events, keepAliveMs: 15000, }}
export default { fetch: handler }Properties
Section titled “Properties”status: The HTTP status code - default is 200headers: The headers to include in the responsebody: Achannel<SseMessage>that can send either astring(treated as event data), anSseEvent, or a raw object that represents anSseEventkeepAliveMs: Optional keepalive interval (in milliseconds). If > 0, the server periodically sends: keepalivecomments while idle.
SseEvent properties
Section titled “SseEvent properties”data: Event payload (string). Newlines are sent as multipledata:lines.event: Optional event name (maps to the SSEevent:field)id: Optional event id (maps to the SSEid:field)retryMs: Optional client reconnection hint (maps to the SSEretry:field)comment: Optional comment line (maps to the SSE: ...field)
WebSocketResponse
Section titled “WebSocketResponse”WebSockets provides a full-duplex channel between a client and your server over a single upgraded HTTP connection.
In ngn, a WebSocket connection is represented by two channels:
recv: messages from the client (client -> server)send: messages to the client (server -> client)
v1 notes:
- supports
string(text frames) andbytes(binary frames) - i.e.WsMessagetype - no subprotocol selection
fn handler(req: Request): WebSocketResponse { const recv = channel<WsMessage>() const send = channel<WsMessage>()
// Echo everything back thread(|| { for (msg in <-? recv) { send <- msg } send.close() })
return WebSocketResponse { recv, send }}
export default { fetch: handler }Properties
Section titled “Properties”headers: The headers to include in the 101 Switching Protocols response (optional)recv: Achannel<WsMessage>that receives client messages. It is closed when the client disconnects.send: Achannel<WsMessage>used to send messages to the client. Close it to close the websocket.
Work with dates and times using the time global.
DateTime Model
Section titled “DateTime Model”All time functions return or work with the DateTime model, which has the following fields:
| Field | Type | Description |
|---|---|---|
year | i64 | Full year (e.g., 2026) |
month | i64 | Month (1-12) |
day | i64 | Day of month (1-31) |
hour | i64 | Hour (0-23) |
minute | i64 | Minute (0-59) |
second | i64 | Second (0-59) |
weekday | i64 | Day of week (0=Monday, 6=Sunday) |
timestamp | i64 | Unix timestamp in seconds |
timestampMs | i64 | Unix timestamp in milliseconds |
Get the current local time as a DateTime model.
const now = time.now()print("Current year: ${now.year}")print("Current month: ${now.month}")print("Current day: ${now.day}")print("Hour: ${now.hour}:${now.minute}:${now.second}")print("Weekday: ${now.weekday}") // 0=Mondayprint("Unix timestamp: ${now.timestamp}")Get the current UTC time as a DateTime model.
const utc = time.utc()print("UTC hour: ${utc.hour}")unix()
Section titled “unix()”Get the current Unix timestamp in seconds as an i64.
const timestamp = time.unix()print("Seconds since epoch: ${timestamp}")unixMs()
Section titled “unixMs()”Get the current Unix timestamp in milliseconds as an i64.
const timestampMs = time.unixMs()print("Milliseconds since epoch: ${timestampMs}")parse()
Section titled “parse()”Parse a date string into a DateTime model using a format string. Returns Result<DateTime, string>.
parse(dateString, formatString)
const result = time.parse("2026-01-21 15:30:45", "%Y-%m-%d %H:%M:%S")match (result) { Ok(dt) => { print("Year: ${dt.year}") print("Month: ${dt.month}") print("Day: ${dt.day}") }, Error(e) => print("Parse error: ${e}")}Format Specifiers
Section titled “Format Specifiers”| Specifier | Description | Example |
|---|---|---|
%Y | Full year | 2026 |
%m | Month (01-12) | 01 |
%d | Day of month (01-31) | 21 |
%H | Hour (00-23) | 15 |
%M | Minute (00-59) | 30 |
%S | Second (00-59) | 45 |
%B | Full month name | January |
%b | Abbreviated month | Jan |
%A | Full weekday name | Wednesday |
%a | Abbreviated weekday | Wed |
import/export
Section titled “import/export”See the Modules page for details.