Skip to content

ngnlang/ngn

Repository files navigation

ngn

Pronounced "engine".

An expressive and easy to use high-level programming language.

Checkout the Quickstart for an overview of the language, otherwise, proceed.

Status

Make it work - early development.

Aims

Right now, these are the two major aims I have for ngn:

  • building APIs and MCP servers
  • doing some AI things, like LLM inference

In other words, I'm hoping devs think of building APIs with ngn instead of Go or Typescript, and building some AI things with ngn instead of Python.

Inspiration

I've long thought of creating a programming language, and now that AI is getting better at coding I've taken the plunge - because, you know, skill issues. In the syntax, you'll see familiar things from other languages; of course, I've added some twists here and there.

Why ngn?

Here are some highlights:

  1. Multi-threaded/parallelism - whichever phrase you'd prefer

  2. Channels & Threads for async.

  • no async/await contagion
  • creating a thread returns a channel, then you can simply return data from the thread and it'll be sent to that channel
  • beautiful "await" syntax for channel messages; <- channel (one message), <-3 channel (three messages), <-tasks.size() channel
  • we even built this model into our fetch implementation: const res = <- fetch("https://api.example.com")
  1. Shared, mutable, atomic state for threads and closures. (oh, what are "closures"?)
  • var count = state(0); then you can have multiple threads mutate count via the .update() state method.
  1. Powerful inline if syntax, inspired by ternaries: if (x > 10) print("greater") : (x < 10) print("less") : print("equal")

  2. Built-in http(s) server.

  • via serve(handler) or export default { fetch: handler }

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, but not everything.

Declaring identifiers

example scope value
var z = "world" local mutable
const status = "go" local immutable
global DATA = [1, 2, 3, 4, 5] global immutable

var

Defines a variable who's value can be changed.

var x = "hello"
x = "goodbye" ✅

Why "var"? Because, to me, it fits better with "const" than "let" does. In other words, var is more congruent with const.

const

Defines a constant who's value cannot be changed.

const x = "hello"
x = "goodbye" ❌ // value is immutable

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 time
global DATA = [1, 2, 3, 4, 5] // not inlined

fn main() {
  print(VERSION)
  print(DATA)
}

Types

  • string
  • i64, i32, i16, i8, u64, u32, u16, u8, f64, f32
  • bool
  • array<type>
  • bytes
  • void
  • map<key_type, value_type>
  • set<value_type>
  • channel<type>
  • fn<...paramN, return_type>
  • A | B (union types)

Union types

Union types let you declare that a value may be one of several types.

var x: string | i64 = "hello"
x = 42

// unions can be used in generics too
const ch = channel<string | i64>()
ch <- "a"
ch <- 2

Note: without narrowing, operations on a union-typed value are conservative. In practice, unions are most useful at boundaries (channels, response bodies, etc.) until narrowing lands.

Type aliases

Type aliases name a type expression.

type UserId = i64
type Token = string | i64

type Token2 = Token

fn main() {
  var id: UserId = 123
  var t: Token = "abc"
  t = 99
}

explicit

const thing: string = "one"
var answer: u64 = 42
var truth: bool = false
const things: array<i64> = [1, 2, 3]
const stuff: array<string> = ["shirt", "hat", "coat"]

fn sideEffects(): void {
  // do something
}

implicit

Supported for literals and expressions, as well as inside functions (requires explict types for fn params and return).

const thing = "one" // inferred as `string`
const answer = 42 // inferred as `i64`
const pi = 3.14 // inferred as `f64`

const result = 3 + 2 // inferred as `i64`

echo

Log to the console, without formatting.

const name = "ngn"
echo(name)
// ngn

echo("Hello")
echo("World")
// HelloWorld

print

Line logging to the console. Implicit \n.

print("Hello")
print("World")
// Hello
// World

Bytes

bytes is 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.

Constructors

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

Methods

length()

Return the number of bytes.

copy(start?, stop?)

Copy an entire bytes value or a section of it, based on indices. This does not change the bytes you copied from.

  • If start is provided but stop is not, it copies everything upto and including the end.
  • If stop is provided (implies start), the copy excludes the item at that index.
  • If neither is provided, the entire bytes is copied.

slice(start, stop?)

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.

  • If stop is provided, the slice excludes the item at that index.
  • If stop is 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()) // bc
print(b.toStringStrict()) // ad

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()

Decode bytes as UTF-8 using a strict conversion.

If the bytes are not valid UTF-8, this throws a runtime error.

String Interpolation

const x = 5
print("x plus 1 is ${x + 1}") // x plus 1 is 6

const greeting = "world"
print("Hello, ${greeting}!") // Hello, world!

// you can escape if needed
print("hello \${x}") // hello ${x}

json

parse()

You can parse a JSON string or an array.

const data = json.parse('{"name": "ngn"}')
print(data.name) // ngn

stringify()

You can stringify an object or an array.

const data = { name: "ngn" }
const str = json.stringify(data)
print(str) // {"name": "ngn"}

env

Access environment variables from both process environment and .env files.

Automatic .env Loading

ngn automatically loads .env files at startup in this order (later overrides earlier):

  1. .env - Base configuration
  2. .env.{mode} - Mode-specific (production, development, test)
  3. .env.local - Local overrides (should be gitignored)

The mode is determined by the NGN_MODE environment variable (default: development).

Property Access

Access environment variables directly as properties. Returns Maybe<string>:

// Direct property access
const db = env.DATABASE_URL ?? "localhost"
const port = env.PORT ?? "3000"

// With pattern matching
match (env.API_KEY) {
  Value(key) => print("Key: ${key}"),
  Null => panic("API_KEY is required!"),
}

get(key)

Get by dynamic key. Returns Maybe<string>.

const key = "DATABASE_URL"
const value = env.get(key) ?? "default"

has(key)

Check if a variable exists. Returns bool.

if (env.has("DEBUG")) {
  print("Debug mode enabled")
}

Strings

length()

Return the length of a string.

index(pattern, start?)

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.

const sent = "I learned to draw today."
const ind = sent.index("to") // 10

includes(pattern)

Determine if a string includes a given pattern. Returns a bool.

const weather = "sunny"
const inc = weather.includes("sun") // true

starts(pattern)

Determine if a string starts with a given pattern. Returns a bool.

var process = "complete"
const beg = process.starts("c") // true

ends(pattern)

Determine if a string ends with a given pattern. Returns a bool.

var process = "working"
const end = process.ends("ing") // true

split(pattern?)

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.

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(search, replacement)

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.

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(start?, stop?)

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.

  • If start is provided but stop is not, it copies everything upto and including the end of the string.
  • If stop is provided (implies start), 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(start, stop?)

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.

  • If stop is provided, the slice excludes the item at that index.
  • If stop is 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()

Transform a string to all uppercase, returning a new string. Preserves original string.

const version = "one"
print(version.upper()) // ONE

lower()

Transform a string to all lowercase, returning a new string. Preserves original string.

var version = "ONE"
print(version.lower()) // one

trim()

Remove whitespace from both ends of a string, returning a new string. Preserves original string.

var thing = " strong "
print(thing.trim()) // "strong"

repeat(num)

Repeat a string some number of times.

const ending = "goodbye"
print(greeting.repeat(2)) // goodbyegoodbye

Numbers

There are currently no number methods, but we do have a math mod (see below), or you can use the extend keyword to add your own (see below).

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 types

size()

Return the size of the array.

push(item, index?)

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.

var stuff = ["guitar", "shirt"]
const size = stuff.push("hat")

print(size) // 3
print(stuff) // ["guitar", "shirt", "hat"]

stuff.push("coat", 0)
print(stuff) // ["coat", "guitar", "shirt", "hat"]

pop(index?)

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.

var stuff = ["coat", "guitar", "shirt", "hat"]
const popped = stuff.pop()

print(popped) // hat
print(stuff) // ["coat", "guitar", "shirt"]

const popped_one = stuff.pop(1)

print(popped_one) // ["guitar"]
print(stuff) // ["coat", "shirt"]

copy(start?, stop?)

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.

  • If start is provided but stop is not, it copies everything upto and including the last item.
  • If stop is provided (implies start), 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(start, stop?)

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.

  • If stop is provided, the slice excludes the item at that index.
  • If stop is 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(item[], start?)

Add multiple items to an array; optionally, at a specific index. Returns the new size of the array.

  • If start is 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) // 7

each(|item, index| {})

For each item in an array, execute a closure.

var things = ["hat", "gloves", "coat"]

things.each(|t, i| {
  print("${i}: ${t}")
})

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 arrays
const x = point[0] // 10
const y = point[1] // 20

const tup = (10, "hello", true)

size()

Return the size of the tuple.

includes(item)

Check if a tuple contains a specific item.

const tup = (10, "hello", true)
const has_hello = tup.includes("hello")

print(has_hello) // true

index(item)

Search a tuple for a given item, and return the index number of the first instance found. If no item is found, returns -1.

const tup = (10, "hello", true)
const ind = tup.index("hello") // 1

copy(start?, stop?)

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.

  • If start is provided but stop is not, it copies everything upto and including the last item.
  • If stop is provided (implies start), 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()

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(delimiter)

Join a tuple into a string, separated by a given delimiter.

const tup = (10, 20, 30)
const joined = tup.join(",")

print(joined) // "10,20,30"

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) // John
print(person.age) // 30
print(person.isStudent) // false

You can also use shorthand syntax for assigning values to object fields.

const name = "John"
const age = 30
const isStudent = false

const person = { name, age, isStudent }

print(person.name) // John
print(person.age) // 30
print(person.isStudent) // false

Destructuring

Destructuring allows you to extract values from objects and arrays into individual variables in a single statement.

Object Destructuring

Extract fields from an object into variables:

const person = { name: "Alice", age: 30, city: "NYC" }
const { name, age } = person

print(name) // Alice
print(age) // 30

Aliasing

Use a different variable name than the field name:

const user = { id: 42, email: "test@example.com" }
const { id, email: userEmail } = user

print(id) // 42
print(userEmail) // test@example.com

Rest Syntax (...rest)

Collect remaining fields into a new object:

const data = { a: 1, b: 2, c: 3, d: 4 }
const { a, b, ...rest } = data

print(a) // 1
print(b) // 2
print(rest.c) // 3
print(rest.d) // 4

Array Destructuring

Extract elements from an array into variables:

const arr = [10, 20, 30, 40]
const [first, second] = arr

print(first) // 10
print(second) // 20

Rest Syntax

Collect remaining elements into a new array:

const nums = [1, 2, 3, 4, 5]
const [head, ...tail] = nums

print(head) // 1
print(tail) // [2, 3, 4, 5]
print(tail.size()) // 4

Tuple Destructuring

Extract elements from a tuple into variables:

const point = (10, 20)
const (x, y) = point

print(x) // 10
print(y) // 20

Tuples 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)

Rest Syntax

Collect remaining elements into a new tuple:

const values = (1, 2, 3, 4, 5)
const (first, second, ...rest) = values

print(first)  // 1
print(second) // 2
print(rest)   // (3, 4, 5)

Destructuring with Models

Works with model instances too:

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
}

Mutable Destructuring

Use var for mutable bindings:

var items = ["a", "b", "c"]
var [first, second, third] = items

first = "x"  // allowed because var
print(first) // x

Custom TypeMethods

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
}

Enums

ngn provides two built-in enums for common patterns: Result and Maybe

Result<T, E>

Result represents an operation that can either succeed or fail.

Variants

  • Ok(T) — The operation succeeded with a value of type T
  • Error(E) — The operation failed with an error of type E

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 (or T?)

Maybe represents a value that may or may not exist. You can write Maybe<T> or use the shorthand T? syntax.

Variants

  • Value(T) — The value exists
  • Null (or null) — The value does not exist

Type syntax

// These are equivalent:
var x: Maybe<i64> = null
var 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 array

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

The null keyword is syntactic sugar for Maybe::Null. You can use it in any context where Null would be used:

// These are equivalent
var m1 = null
var m2 = Null

// Using null in return statements
fn maybeValue(flag: bool): Maybe<i64> {
  if (flag) return Value(42)
  return null  // Syntactic sugar for Maybe::Null
}

// null works with the ?? (null-coalescing) operator
var x: Maybe<i64> = null
var result = x ?? 100  // result is 100

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 (?.)

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? = null
var 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")

// Chaining
model Address { city: string }
model Person { address: Address }

var p: Person? = null
var city = p?.address?.city  // null (multiple short-circuits)

check

check is a way of guarding logic that requires a value. It can only be used for variables of type Maybe<T> or Result<T, E>.

  • If it evaluates to Null or Error, the statement block is run and it must either return or break.
  • If it evaluates to a value, the declared variable (u in the example) is assigned the value and the statement block is skipped.
fn getUser(user?: string): Result<User, string> {
  check var u = user {
    // failure case
    return Error("User not found")
  }
  // success case
  return Ok(u)
}

const user = getUser("jason")
match (user) {
  Ok(user) => print("User: ${user}"),
  Error(msg) => print("Error: ${msg}"),
}
Error Binding for Result Types

When checking a Result<T, E>, you can capture the error value in the failure block:

fn fetchData(): Result<string, string> {
  const result: Result<string, string> = Error("network timeout")
  check var data, err = result {
    print("Failed: ${err}")  // "Failed: network timeout"
    return Error(err)
  }
  return Ok(data)
}

The error binding is only scoped to the failure block. Using error binding with Maybe<T> is a compile-time error.

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

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).

loop

Run the statement block indefinitely. Use break to exit the loop.

loop {
  statement
  statement
}

while

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) statement

once variant

To always run the statement block once, before checking the condition.

while once (condition) {
  statement
}

for

Run a statement block for each message in a channel or items in a collection.

Arrays have an each method, 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)
}

if

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
: statement

The 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

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 next keyword. next will only try to match the very next case.
const value = 3
match (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)

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.

Traditional block, explicit return

fn add(a: i64, b: i64): i64 {
  return a + b
}

Traditional block, explicit multiline return

fn add(a: i64, b: i64): i64 {
  return (
    a + 
    b
  )
}

Implicit return

fn add(a: i64, b: i64): i64 a + b

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

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 string
fn 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 moved

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 available

Optional params

In this example, suffix is optional. Inside the function, it is either Maybe::Value<T> or Maybe::Null. There are a couple of ways to handle checking which variant it is:

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 {
  // Check if a value can be unwrapped from the enum variant. If so, assign to local variable and run the statement block; otherwise, it's `Maybe::Null`.
  if (let s = suffix) {
    return "Hello ${name}${s}"
  }
  return "Hello ${name}"
}
print(greet("Bob")) // "Hello Bob"
print(greet("Bob", "!")) // "Hello Bob!"

Default params

Default params are implicitly optional.

fn greet(name: string, suffix: string = "!") {
  print("Hello ${name}${suffix}")
}
print(greet("Bob")) // "Hello Bob!"
print(greet("Bob", ",")) // "Hello Bob,"

map

Create a key, value map. Type declartion is required.

const m = map<i64, string>()

// add an entry
m.set(1, "one") // returns the map

// chain set
m.set(2, "two").set(3, "three")

// checks if an entry exists, based on key
m.has(1) // returns a bool

// get an entry
m.get(1) // returns the value, or void if not found

// remove an entry
m.remove(1) // returns the removed value

// get the size
m.size() // returns the number of entries in the map

set

Create a set of values.

  • Type declartion is required
  • Values are deduplicated
const s = set<string>()

// add a value
s.add("one") // returns the set

// chain add
s.add("two").add("three")

// checks if a value exists
s.has("one') // returns a bool

// remove a value
s.remove("one") // returns a bool of whether the value was removed

// get the size
s.size() // returns the number of values in the set

// clear all values
s.clear()

// iterate over values
for (v in s) {
  print(v)
}

// iterate over values with index
for (v, i in s) {
  print("index: ${i}, value: ${v}")
}

Closures

Closures are similar to functions, but have important differences:

  • assign them with const then call like a function
  • access to external values, even ones outside its environment
  • uses pipe syntax to wrap params
  • 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.
  1. The closure captures outside values at creation.

    var count = 0
    
    const incrementBy = |a: i64| count + a // captures `count` at 0
    
    print(incrementBy(10)) // 10
    
    print(incrementBy(5)) // 5
    
    count = 100
    print(incrementBy(7)) // still 7
    
    const incrementCount = |a: i64| count + a // captures `count` at 100
    print(incrementCount(7)) // 107
    
  2. You can mimic classic "close over" behavior by returning a closure from a function.

    fn main() {
      var count = 10
    
      fn 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 passed
    
      print(add_it(3)) // 13
      count = add_it(5) // sets count to 15
    
      const add_me = adder(count) // param `c` is 15 for this closure 
      print(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()  // 1
      counter()  // 2
      counter()  // 3
    }
    

Typed Objects and Composability

You can create typed objects using models, then create a new instance of a model.

model

Create object structures.

model Dog {
  name: string,
  breed: string
}

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 }
}

Type Inference and Enforcement

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 I64

This ensures type safety even with generic types - once a type parameter is bound to a concrete type, it remains consistent.

role

You 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

Extend a model's functionality with methods. You can implement custom methods, apply one or more roles, or a mix of both.

// extend with custom methods
extend Dog {
  fn fetch(thing: string): bool {
    return attemptToFetch(thing)
  }
}
// extend with role
extend 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!

Alternative for instantiating models

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
}

extend User {
  fn new(name: string, age: u32): User {
    return User { name, age }
  }
}

fn main() {
  var user = User.new("Chloe", 27)
}

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, global variables: ❌ not allowed, as these are all strictly immutable

this

There's no need to fear this in ngn. It's an implicit reference to the instance 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()) // true

Channels

Send and receive data.

  • You must declare a channel with const
  • You must provide a data type for the channel's messages.

<- 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.
  • <- line would 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).
<-? line
fn 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 sent
Worker started
Task A executing with 42
Task B executing: 42 * 2 = 84
Worker finished
Jobs complete

Channels 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)
}

Threads - Concurrency and Parallelism

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 started
4. 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 message
5c. 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

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
}

Shared, mutable 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().

spawn - Parallel Execution

The spawn global provides two related tools:

  • single-task offloading (spawn.cpu, spawn.block) which returns a channel immediately
  • 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>").

spawn.cpu(task)

Run a single task on the CPU worker pool.

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)
}

spawn.block(task)

Run a single task on the blocking worker pool (for work that may stall the current thread, e.g. filesystem operations, subprocess waits, etc.).

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)
}

spawn.all(tasks, options?)

Returns an array of results (including any errors).

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 time

spawn.try(tasks, options?)

Stop spawning new tasks on first error. Returns partial results up to and including the error.

const results = spawn.try([task1, task2, task3], { concurrency: 4 })
// Stops when first error occurs
// [Result::Ok (Task 1 done), Result::Error (Task 2 failed)]

spawn.race(tasks)

Returns the first successful result, or the first error if all tasks fail.

const result = spawn.race([task1, task2, task3])
// Result::Ok (Task 1 done)  - whichever completes first with success

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)

fetch properties

  • url: The URL of the request
  • options: An object containing the options for the request
    • method: The HTTP method of the request - defaults to GET
    • headers: The headers of the request
    • body: The body of the request
    • timeout: The timeout of the request, in milliseconds - defaults to 10 seconds

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
}

Request properties

  • method: The HTTP method of the request
  • url: The URL of the request
  • protocol: Whether HTTP or HTTPs was used
  • host: The host the client used to make the request
  • path: The path of the request
  • query: The query string of the request
  • params: Query parameters as a Map<string, string>
  • headers: The headers of the request
  • body: The body of the request
  • ip: The client's IP address
  • cookies: The cookies sent with the request

Request methods

  • clone(): Creates a new Request object with the same properties
  • text(): Parses the body as a string, returns a string
  • json(): Parses the body as JSON, returns a Result enum
  • formData(): Parses URL-encoded body, returns a Map<string, string>

Response

You can create a Response object to send HTTP responses.

fn handler(req: Request) {
  return Response {
    body: "Hello, world!",
  }
}

export default {
  fetch: handler
}

Response properties

  • status: The HTTP status code - default is 200
  • statusText: The HTTP status text - default is ""
  • ok: A boolean indicating whether the response status code is in the range 200-299
  • headers: The headers to include in the response
  • body: The body of the response - default is ""

Response methods

  • text(): Parses the body as a string, returns a string
  • json(): Parses the body as JSON, returns a Result enum
  • formData(): Parses URL-encoded body, returns a Map<string, string>

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 }

StreamingResponse properties

  • status: The HTTP status code - default is 200
  • headers: The headers to include in the response
  • body: A channel<string> that produces chunks. Close the channel to end the stream.

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 }

SseResponse properties

  • status: The HTTP status code - default is 200
  • headers: The headers to include in the response
  • body: A channel<SseMessage> that can send either a string (treated as event data), an SseEvent, or a raw object that represents an SseEvent
  • keepAliveMs: Optional keepalive interval (in milliseconds). If > 0, the server periodically sends : keepalive comments while idle.

SseEvent properties

  • data: Event payload (string). Newlines are sent as multiple data: lines.
  • event: Optional event name (maps to the SSE event: field)
  • id: Optional event id (maps to the SSE id: field)
  • retryMs: Optional client reconnection hint (maps to the SSE retry: field)
  • comment: Optional comment line (maps to the SSE : ... field)

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) and bytes (binary frames) - i.e. WsMessage type
  • 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 }

WebSocketResponse properties

  • headers: The headers to include in the 101 Switching Protocols response (optional)
  • recv: A channel<WsMessage> that receives client messages. It is closed when the client disconnects.
  • send: A channel<WsMessage> used to send messages to the client. Close it to close the websocket.

Modules

You can use export and import to create modules in your project. This is a functions-only feature.

// math.ngn
export fn add(a, b) a + b

export fn subtract(a, b) a - b

Here, we look for an add function from a math.ngn file within the same directory that your main.ngn file is in.

// main.ngn
import { add } from "math.ngn"

fn main() {
  print(add(21, 3)) // 24
}

Relative path imports

import { add } from "./lib/math.ngn"

Aliased imports

import { add as adder } from "math.ngn"

Module imports

// main.ngn
import * as Math from "math.ngn"

print(Math.subtract(10, 2)) // 5

Default export

// math.ngn
fn add(a, b) a + b

export default add
// main.ngn
import add from "math.ngn"

"Standard Library"

We call this the toolbox.

You can import in different ways:

  • import { abs } from "tbx::math"; use functions directly (best for tree-shaking)
  • import { abs as ABS } from "tbx::math"; aliased functions
  • import Math from "tbx::math"; use functions via alias Math.abs
  • import * as Math from "tbx::math"; (use same as above)

tbx::math

  • abs: return the absolute value of a number. abs(-5) // 5
  • ceil: return the smallest integer greater than or equal to ceil(3.2) // 4
  • floor: return the smallest integer less than or equal to floor(3.9) // 3
  • round: return the number rounded to the nearest integer round(4.5) // 5
  • sin: (Trigonometry? If you know, you know.) sin(0) // 0

tbx::test

  • assert: assert that a condition is true
import { assert } from "tbx::test"

fn main() {
  assert(1 + 1 == 2)
  // ✅ Assertion passed

  // with optional description
  assert(1 + 1 == 2, "1 + 1 should equal 2")
  // ✅ 1 + 1 should equal 2
}

tbx::http

Create an HTTP server.

  • serve(handler, config?): create an HTTP/HTTPS server

If config.tls is present, ngn starts an HTTPS server. Otherwise it starts HTTP.

import { serve } from "tbx::http"

fn handleRequest(req: Request): Response {
  return Response {
    status: 200,
    headers: map<string, string>(),
    body: "Hello from ngn HTTP server! Path: ${req.path}"
  }
}

fn main() {
  serve(handleRequest)
}
  • serve config (2nd arg)
serve(handleRequest, {
  port: 3000,
  keepAliveTimeoutMs: 30000,
  maxRequestsPerConnection: 1000,

  // If present, server starts in HTTPS mode.
  tls: {
    cert: "./cert.pem",
    key: "./key.pem",
  },
})
  • default export with fetch method (serve called under the hood)
fn fetch(req: Request): Response {
  return Response {
    status: 200,
    body: "Hello from export-based API!",
    headers: map<string, string>()
  }
}

export default { fetch: fetch }
  • export default config

If you export an object with a config field, ngn reads it when booting the server:

export default {
  config: {
    port: 3000,
    keepAliveTimeoutMs: 30000,
    maxRequestsPerConnection: 1000,

    // Optional TLS config. If present, the server starts in HTTPS mode.
    tls: {
      cert: "./cert.pem",
      key: "./key.pem",
    },
  },

  fetch: fetch,
}

tbx::llm

Language-level LLM APIs.

ngn vendors llama.cpp at vendor/llama.cpp and builds it into the runtime.

Build variants:

  • CPU (default): cargo build --release
  • CUDA (Linux): NGN_LLAMA_BACKEND=cuda cargo build --release

The CUDA build requires CUDA tooling at build time (e.g. nvcc) and NVIDIA drivers + CUDA runtime libraries on the target machine.

tbx::llm uses llama.cpp directly. Provide a local .gguf model file.

Import from tbx::llm:

import { load, generate, stream } from "tbx::llm"

load(path, opts?)

Load a model from disk.

Returns Result<LlmModel, string>.

generate(model, prompt, opts?)

Run a one-shot generation.

Returns Result<string, string>.

stream(model, prompt, opts?)

Stream generated text chunks. Returns channel<string>.

If the consumer closes the channel (e.g. client disconnects during StreamingResponse), generation should stop early.

import { load, stream } from "tbx::llm"

fn main() {
  const r = load("./model.gguf")
  match (r) {
    Ok(m) => {
      const ch = stream(m, "hello")
      for (chunk in <-? ch) {
        match (chunk) {
          Value(s) => echo(s),
          Null => break,
        }
      }
    },
    Error(e) => print(e)
  }
}

tbx::process

OS process execution utilities.

Import from tbx::process:

import { run, stream, streamRaw } from "tbx::process"

run(cmd, opts?)

Run a shell command (/bin/sh -c) and return a channel that produces a single Result<ProcessOutput, string>.

const result = <- run("printf 'hi'")
match (result) {
  Ok(out) => {
    print(out.code)   // exit code
    print(out.stdout) // captured stdout
    print(out.stderr) // captured stderr
  },
  Error(e) => print(e)
}

Options (all optional):

  • cwd: string
  • timeoutMs: i64

stream(cmd, opts?)

Stream a command's stdout/stderr while it runs.

Returns Result<ProcessStream, string>.

ProcessStream fields:

  • stdout: channel<string> (line-based)
  • stderr: channel<string> (line-based)
  • done: channel<Result<ProcessOutput, string>>
const r = stream("printf 'a\\nb\\n'")
match (r) {
  Ok(p) => {
    const a = <- p.stdout
    const b = <- p.stdout

    // Stop reading once you're done.
    p.stdout.close()
    p.stderr.close()

    const done = <- p.done
    match (done) {
      Ok(out) => print(out.code),
      Error(e) => print(e)
    }
  },
  Error(e) => print(e)
}

Options (all optional):

  • cwd: string
  • timeoutMs: i64

streamRaw(cmd, opts?)

Like stream(), but stdout/stderr are channel<bytes> chunks instead of lines.

const r = streamRaw("printf 'xyz'")
match (r) {
  Ok(p) => {
    const chunk = <- p.stdout
    print(chunk.toStringStrict())

    p.stdout.close()
    p.stderr.close()

    <- p.done
  },
  Error(e) => print(e)
}

Options (all optional):

  • cwd: string
  • timeoutMs: i64

tbx::io

File I/O operations. Import from tbx::io:

import { read, write, append, exists, delete } from "tbx::io"

read(path)

Read entire file contents as a string. Returns Result<string, string>.

const result = read("config.txt")
match (result) {
  Ok(content) => print(content),
  Error(e) => print("Failed: ${e}"),
}

write(path, content)

Write content to a file. Creates the file if it doesn't exist, or overwrites if it does. Returns Result<void, string>.

const result = write("output.txt", "Hello, file!")
match (result) {
  Ok(v) => print("Saved!"),
  Error(e) => print("Failed: ${e}"),
}

append(path, content)

Append content to a file. Creates the file if it doesn't exist. Returns Result<void, string>.

append("log.txt", "New line\n")

exists(path)

Check if a file exists. Returns bool.

if (exists("config.txt")) {
  print("Config found!")
}

delete(path)

Delete a file. Returns Result<void, string>.

delete("temp.txt")

About

Expressive programming language.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages