radlang — Documentation

radlang is a statically-typed, compiled language with a familiar C/TypeScript- flavoured syntax. Source (.rad) is compiled through a custom Rust front end to LLVM 20 and linked into a native binary — there is no VM or interpreter at runtime.

This page is the entry point: it covers the base grammar and primitive types, and links out to the reference for each built-in namespace.


Table of contents

This page — language basics

Built-in namespace references

DocCovers
System & processsys — I/O, args, exit, timing, system info, OS env
Project configcfg — config.yaml values, cfg.env, .radenv, cfg.profile
Mathmath — functions, rounding, random
File systemfs — read/write files, paths, dirs, metadata
JSONjson — parse, serialize, navigate
XMLxml — parse, build, and navigate XML with XmlNode
MapsMap — key/value storage
HTTPhttp client + httpserver (routing, middleware, CORS)
Networkingtcp, websocket, sse, Channel — sockets, events, long-poll
Cryptography & hashinghash + crypto — digests, HMAC, base64, AES, bcrypt
Compressioncompress — gzip / deflate (de)compression
Dates & timeDate — construction, components, formatting, parsing
Databaseschema, Database, query builder, raw SQL
Built-in hover referenceExact namespace and value-method signatures shown by LSP hover
Regular expressionsregex.from, RegEx matching and replacement
Desktop GUIgui — native webview windows, HTML/CSS/JS UI, page↔radlang bridge
Native GUIsyn — native widget tree, intrinsic layout, typography, layout components

All built-in namespaces are compiler-intrinsic — reserved names dispatched straight to the runtime, needing no import.

Guides

DocCovers
Best practicesProject layout, config, tests, naming, idiomatic style
Defined semanticsOverflow, division by zero, Unicode, out-of-bounds

Program structure

Every program needs a fn main(). There is no top-level statement execution outside it. (Test files using test "..." { } blocks don’t need a main.)

fn main() {
    sys.output("Hello, world!")
}

Functions are hoisted — a function may call another defined later in the file.

Comments

// a single-line comment
/// a doc comment (attached to the next fn, type, or ns declaration)
/* a block comment — may span multiple lines; not nestable */

Comments are only valid where a statement or declaration may start, not in the middle of an expression.

Automatic semicolon insertion

Semicolons are optional: a newline after a statement-ending token acts as an implicit semicolon.

int x = 5
sys.output(x.toString())

Caveat. ) triggers ASI on a following newline, so a brace-less control-flow body split across two lines (if (cond)⏎statement) gets a stray semicolon and breaks. Keep brace-less forms on one line, or use { }.

return terminates at the end of its line

A newline immediately after return ends the statement — return on its own line is a bare (value-less) return, and any code after it is unreachable:

return
doWork()   // never runs

So a returned expression must start on the same line as return. To span it across lines, end each line on an operator, or wrap the expression in parens:

return a +
    b            // ok — line ends on an operator

return (
    a + b        // ok — parens suppress ASI
)

Primitive types

TypeUnderlyingNotes
inti32Signed integer
uintu32Unsigned integer
longi6464-bit integer
floatf64Double-precision float
decimalf128Software float
byteu8Byte
booli1true / false
charSingle character
stringText; + concatenates, == compares contents
numf64Numeric supertype; widens/narrows with any numeric type
voidNo value (return type)
nullThe null value
varInferred: resolves to the value’s concrete type at compile time

Numeric literals accept scientific notation (3e3, 1.5e-2) and auto-widen to a wider declared type (long l = 9000000000).

Declarations

Type comes before the name. A bare declaration with no initializer gets a zero/empty default.

int age = 25
const string name = "Tim"
var x = 42

string greeting     // ""
int count           // 0
bool active         // false

Nullable types append ? and default to null (not zero):

string? label = null
int? maybe          // null

Inside if (x != null) { ... }, x narrows to its inner type T.

Operators

+  -  *  /  %  **      // arithmetic (** exponent, right-associative)
== != < > <= >=        // comparison
&& || !                // logical (short-circuit)
&  |  ^  ~  << >>       // bitwise (integers only)
??                     // nullish coalescing: a ?? b
cond ? a : b           // ternary conditional (right-associative)
i++  i--               // increment / decrement
i += n  i -= n  i *= n  i /= n   // compound assign

For strings, - removes every non-overlapping occurrence of its right-hand operand and returns a new string:

string name = "tim"
name - "i"       // "tm"
"banana" - "an"  // "ba"

?? requires a nullable left operand and returns the inner type:

int? maybeCount = getCount()
int count = maybeCount ?? 0

The ternary cond ? a : b is an expression — it yields a when cond is true, otherwise b. It is right-associative, so it chains without parentheses:

string size = n > 100 ? "big" : n > 10 ? "medium" : "small"

Control flow

// if / else
if x > 0 { sys.output("positive") } else { sys.output("non-positive") }

// while
while count < 10 { count++ }

// C-style for
for (int i = 0; i < 10; i++) { sys.output("tick") }

// for-in — requires parentheses and an explicit item type
for (string name in names) { sys.output(name) }

// break / continue — exit or skip the innermost enclosing loop
for (int i = 0; i < 100; i++) {
    if i == 10 { break }        // stop the loop
    if i % 2 == 0 { continue }  // skip to the next iteration
    sys.output("odd: " + i)
}

break and continue work in every loop (while, C-style for, for-in) and always act on the innermost loop. Using either outside a loop is a compile error.

match is an expression; the subject needs parentheses and arms use =>. Patterns are literals, type names (union narrowing), or the _ wildcard.

string result = match (x) {
    1 => "one"
    2 => "two"
    _ => "other"
}

Functions & closures

The return type uses a colon suffix (:, not ->). Both type name and name: type parameter forms are accepted. Omitting the return type infers it (top-level named functions only).

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

fn greet(name: string, age: int): string { ... }   // name: type form

Functions are first-class values with full closure capture:

fn makeAdder(int x): fn(int): int {
    return fn(int y): int { return x + y }
}
var add5 = makeAdder(5)
sys.output(add5(10).toString())   // 15

Overloading: multiple functions may share a name if they differ in arity or parameter types. Optional/default params: nullable or default-valued parameters are optional at the call site and must come after required ones.

Custom types

Structs

type User { string name, int age }

User u  = User("Tim", 30)     // explicit constructor
User u2 = { "Tim", 30 }       // inferred constructor
u.name                        // field access (chainable)

Anonymous structs — inferred from field names; local use only:

var point = { x: 10, y: 20 }
var obj   = { name, age }     // shorthand when locals match field names

Unionsis narrows inside the guard:

type Status = string | int
Status s = "active"
if s is string { sys.output(s.upper()) }

Generics — C#/TypeScript-style <T>, monomorphized at compile time:

type Box<T> { T value }
fn identity<T>(T x): T { return x }

Box<int> b = Box(99)
int y = identity<int>(7)

Destructuring

int [good, better, best] = scores      // positional
int [a, ...rest] = scores              // rest binding (must be last)
string { name } = u                    // struct field destructure

Enums — a named set of members. Read a member with Enum.member.

enum Color { red, green, blue }

By default an enum is a string enum: each member reads as its own name, so Color.red is the string "red". Assign integers to make it an int enum; bare members then auto-increment C-style from the previous value:

enum Status { active = 1, inactive = 2, pending = 5 }
enum Level  { low, mid = 10, high }        // low=0, mid=10, high=11

Color.green        // "green"
Status.pending     // 5

An enum type annotation behaves as its underlying primitive (string or int), so members compare and convert naturally:

Color c = Color.green
if c == Color.green { ... }             // compare to a member
if c == "green" { ... }                 // …or the underlying value
c.upper()                               // "GREEN" — value methods work

Status s = Status.pending
int code = s                            // 5

Lists

string[] names = ["Alice", "Bob"]
int[] scores                              // empty
int[10] primes                            // pre-sized
int[] oneToFive = [1..5]                  // range: [1, 2, 3, 4, 5] (inclusive)

names[0]                                  // index
names.add("Carol")                        // mutates in place
names.remove("Bob")                       // mutates in place
names.combine(other)      // -> T[]  (new list, both lists' elements)
names.map(fn(string s, int i): string { return s.upper() })   // -> mapped T[]
names.where((string s) => s.startsWith("A"))   // -> T[]  (elements the predicate keeps)
names.iter((string s) => sys.output(s))        // -> void (forEach; runs for side effects)
scores.reduce(fn(int acc, int n): int { return acc + n }, 0)   // -> R (left fold; seed 0)
names.length()            // -> int
names.contains("Alice")   // -> bool
names.indexOf("Alice")    // -> int (-1 if absent)
names.first() / names.last() / names.reverse() / names.join(", ")
names.sort()              // -> T[]  (new sorted list; source unchanged)

.map, .where, and .iter take the element (and optionally its index) — the callback may be fn(T item) or fn(T item, int index). All list transforms (map/where/reduce) return a new list or value; only add/remove mutate in place.

Folding — .reduce

.reduce(fn(acc, elem): R, seed) folds a list left-to-right into a single value, threading an accumulator. The accumulator type R is the seed’s type and may differ from the element type:

int[] nums = [1, 2, 3, 4, 5]

int sum  = nums.reduce(fn(int acc, int n): int { return acc + n }, 0)   // 15
int prod = nums.reduce(fn(int acc, int n): int { return acc * n }, 1)   // 120

// accumulator type (string) differs from element type (int):
string csv = nums.reduce(fn(string acc, int n): string {
    if (acc == "") { return n.toString() }
    return acc + "," + n.toString()
}, "")                                                                  // "1,2,3,4,5"

An empty list returns the seed unchanged.

Sorting

.sort() returns a new sorted list and never mutates the receiver (same as .reverse()). It has three forms:

int[] nums = [3, 1, 4, 1, 5]

nums.sort()          // ascending  -> [1, 1, 3, 4, 5]
nums.sort(true)      // descending -> [5, 4, 3, 1, 1]

// custom comparator: fn(a, b): int — negative if a<b, 0 if equal, positive if a>b
Person[] byAge = people.sort(fn(a: Person, b: Person): int {
    return a.age - b.age
})

// Extract each key once; useful for struct lists and expensive key functions.
Person[] byAgeKey = people.sortBy(fn(p: Person): int { return p.age })

The no-arg and boolean forms work on any comparable element type — int, uint, long, float, decimal, num, byte, char, and string (lexicographic). The comparator form works on any element type, including structs. The sort is stable. To opt into mutation, pass an options object such as nums.sort({ inPlace: true, threads: 4 }); copy-on-sort remains the default.

The editor exposes the same built-ins through completion and hover. These are the current LSP metadata signatures:

SurfaceHover signature
string.repeat(int count): string
list.sort([bool desc \| fn comparator \| { inPlace: bool, threads: int }]): T[]
list.sortBy(fn key[, bool desc]): T[]
math.sort.radixfn math.sort.radix(numeric[] values[, int threadCount]): numeric[]
math.sort.countfn math.sort.count(numeric[] values): numeric[]
math.sort.timsortfn math.sort.timsort(numeric[] values): numeric[]
math.sort.introfn math.sort.intro(numeric[] values): numeric[]
math.sort.pdqfn math.sort.pdq(numeric[] values): numeric[]

Parallel processing

map, where, iter, and reduce each take an optional trailing int threadCount that runs the work across a real pool of OS threads. The sequential and parallel forms are the same method — adding the count is the only change:

int[] out    = nums.map(fn(int n): int { return heavy(n) }, 4)   // parallel map, 4 threads
int[] evens  = nums.where(fn(int n): bool { return n % 2 == 0 }, 4)  // parallel filter
nums.iter(fn(int n): void { log(n) }, 4)                         // parallel forEach
int total    = nums.reduce(fn(int a, int n): int { return a + n }, 0, 4)  // parallel fold

parallel(fn [, N]) is an explicit alias for the parallel map (default 2 threads) if you prefer to name the intent:

int[] squared = nums.parallel(fn(int n): int { return n * n }, 8)

Semantics:

  • map / where / parallel are order-preserving — the result is identical to the sequential form, just computed concurrently. iter runs the callback on every element but in no guaranteed order.
  • The callback runs on worker threads, so it must be synchronous (no await) and should not mutate shared outer state. Reading captured immutable values is fine; each worker allocates into its own private arena.
  • Parallel reduce requires the accumulator type to equal the element type (R == T) and an associative combiner — each thread folds its chunk and the partials are merged. Sum, product, min/max, and string concat qualify; a non-associative combiner gives an order-dependent result. The sequential reduce has neither restriction. A mismatched accumulator type is a compile error when a threadCount is present.
  • threadCount is clamped to [1, length]; 1 runs inline with no threads. Pick a count near your core count — more threads past that rarely helps.

Parallelism pays off when the per-element work dominates thread overhead (compute-bound transforms over large lists). For tiny lists or trivial callbacks, the sequential form is faster.

List comprehensions

Build a new list inline with [expr for (T name in source) where cond]. It reuses the for (T name in list) binding form; the where clause is optional:

int[] nums = [1, 2, 3, 4, 5, 6]

var squares = [x * x for (int x in nums)]                    // [1, 4, 9, 16, 25, 36]
var evens   = [x for (int x in nums) where x % 2 == 0]       // [2, 4, 6]
var labels  = ["n" + x.toString() for (int x in nums) where x > 4]  // ["n5", "n6"]
  • The result’s element type is the element expression’s type, which may differ from the source (e.g. int source → string result above).
  • The where predicate is evaluated first and guards the element expression — it runs only for kept items, so [100 / x for (int x in xs) where x != 0] never divides by zero.
  • Order-preserving. An empty source yields an empty list.

Range initializers

[start..finish] builds an inclusive integer list from start to finish:

var a = [1..5]      // [1, 2, 3, 4, 5]
var b = [0..3]      // [0, 1, 2, 3]
var c = [5..5]      // [5]
  • Both ends are inclusive. [1..5] has 5 elements; length is abs(finish - start) + 1.
  • Direction is automatic. When start > finish the range counts down: [5..1][5, 4, 3, 2, 1].
  • Bounds are any integer expressions — literals or variables: [lo..hi], [0..n - 1].
  • The element type is int, or long if either bound is long.
  • The result is an ordinary list, so it composes with everything else:
for (int x in [1..10]) { total = total + x }
var squares = [x * x for (int x in [1..4])]     // [1, 4, 9, 16]
var doubled = [1..3].map(fn(int x): int { return x * 2 })   // [2, 4, 6]

Lists are homogeneous — var[] (mixed element types) is rejected at compile time. A call that returns a list can be iterated or indexed directly:

for (string k in json.keys(node)) { ... }
string first = names.combine(other)[0]

Strings

s.length()  s.upper()  s.lower()  s.trim()
s.contains("x")  s.replace("a", "b")   // replace hits ALL occurrences
s.split(",")     s.startsWith("http")  s.endsWith(".json")
s.indexOf("x")   s.charAt(n)           s.slice(start, end)
s.padStart(n, "0")  s.padEnd(n, "0")
s.repeat(n)      "ha" * 3              // "hahaha"

repeat and string multiplication return one new string containing the original bytes n times. A zero or negative count returns ""; multiplication accepts either string * int or int * string.

Interpolation"{expr}" inside a double-quoted string, no prefix. Escape literal braces with \{ / \}.

string name = "Tim"
sys.output("Hello {name}, you are {age + 1} next year")

Error handling

throw Error("Not found", 404)

try {
    int result = riskyOp()
} catch (Error e) {
    sys.output(e.message)    // string
    sys.output(e.code)       // int
}

Implemented with setjmp/longjmp; throwing unwinds to the nearest try in the call stack. An uncaught throw aborts the process. Maximum try/catch nesting is 64 frames.

Namespaces & imports

Group related functions under a namespace; call with namespace.member().

ns greeter {
    fn hello(string name): string {
        return "Hello, " + name
    }
}

fn main() {
    sys.output(greeter.hello("Tim"))
}

Split code across files and pull it in with import:

import "utils.rad"              // namespaces stay qualified: utils.helper()
import * from "utils.rad"       // hoist namespace members too: helper()
import { helper } from "utils.rad"   // hoist just the named ones

Top-level declarations are exports. A file’s file-level (non-namespace) declarations — types, structs, unions, enums, schemas, interfaces, top-level functions and globals — are brought into scope by importing the file. They have no namespace to qualify behind, so a bare import "utils.rad" hoists them to the top level (a namespace in the same file still stays qualified). import { name } and import * resolve against these top-level items as well as namespace members.

// geometry.rad — no namespace needed
type Point { int x; int y }
fn distanceSq(Point a, Point b): int {
    int dx = a.x - b.x
    int dy = a.y - b.y
    return dx*dx + dy*dy
}

// main.rad
import "geometry.rad"
fn main() {
    Point a = Point(0, 0)
    Point b = Point(3, 4)
    sys.output(distanceSq(a, b))   // 25
}

Members prefixed priv are not exported (functions, types, and variables alike). A namespace compiled to a .ns.rad / .rlc library is imported the same way. Imports prefer .rlp, then .rlc, .ns.rad, and .rad. radlang export packages all .rlc files in the configured lib directory into one .rlp archive, preserving namespace metadata and native objects for consumers.

Nested namespaces & extensions across files

A namespace can be nested with a dotted name, and its members reach the enclosing namespace:

ns app {
    fn greet(): string { return "hi from app" }
}

ns app.ext {
    fn callParent(): string { return app.greet() }   // sees the parent
}

You can split a namespace and its nested extensions across separate files. An extension file declares ns <base>.<...> and needs no import of the base — the lib compiler finds every *.ns.rad under paths.namespace, folds each dotted extension into the base namespace it extends, and compiles the group into a single .rlc named after the base file:

src/namespace/app.ns.rad        // ns app { ... }
src/namespace/app-ext.ns.rad    // ns app.ext { ... }  — no import needed
// main.rad — one import pulls in the base and all its extensions
import "app"

fn main() {
    app.greet()             // from app.ns.rad
    app.ext.callParent()    // from app-ext.ns.rad, merged into app
}

Because the group compiles as one unit, an extension has the same access to the base as a same-file nested namespace — including the base’s priv members. Nesting can go deeper (ns app.ext.sub), and editing any file in the group rebuilds the shared .rlc. Keep each extension in its own file declaring only its dotted namespace; the base namespace (ns app) must exist in one of the sibling *.ns.rad files, or the build reports that the extension has no base.

Namespace-scoped variables

A variable declared at namespace scope is a namespace-level global. It can be read from inside the namespace by its bare name, and from outside with the qualified ns.name form:

ns config {
    string appName    = "MyApp"
    string[] routes   = ["/", "/about", "/contact"]

    fn banner(): string { return "== " + appName + " ==" }   // bare, internal
}

fn main() {
    sys.output(config.appName)          // "MyApp"
    sys.output(config.routes[1])        // "/about"
    sys.output(config.routes.length().toString())   // "3"
}

Prefix the declaration with priv to keep it scoped to its own namespace — an external read is then a compile error:

ns config {
    priv string secret = "…"            // internal-only
}
config.secret                           // error: 'secret' is private to namespace 'config'

Library note. A namespace’s variable initializers do run when it is compiled to a .ns.rad / .rlc library and linked into another program — the importing program’s entry point calls the namespace’s synthesized initializer at startup, so a function in the library reads the fully-initialized values. The one restriction is that a .rlc does not export namespace variables as external members: reading config.appName directly from outside the library is a compile error (namespace has no member 'appName'). To read a namespace global across a library boundary, expose it through a getter function, which returns the correctly-initialized value:

ns config {
    string appName = "RadChat"          // initializer runs, even from a .rlc
    fn getAppName(): string { return appName }
}

// in a separately-compiled program that imports the config.rlc library:
config.getAppName()   // "RadChat"   — works
config.appName        // compile error — variables aren't exported members

#ai mode

#ai is a file-level directive (like #safe) that turns on token-saving short spellings, meant for machine-authored code. Put it on the first line; it applies to the rest of that file only.

#ai

f add(n x, n y): n { r x + y }

Aliases (active only in an #ai file):

ShortExpands toShortExpands to
nnumvvoid
sstringaany
bboolffn
ccharrreturn

All the specific number types collapse to a single n (= num); the compiler coalesces num with any numeric context, so you rarely need int/long/float by name in #ai code.

Implicit return. A function with a declared, non-void return type returns its final expression automatically — no r/return needed. (A void function, or one with an inferred return type, does not, so a void helper whose last line is a void call is never mis-rewritten.)

#ai
f describe(n val): s { "value is {val}" }   // returns the string implicitly
f log(s msg): v { sys.output(msg) }         // v = void → no implicit return

Reserved letters. Because the aliases are keywords inside an #ai file, the single letters n s b c v a f r cannot be used as identifiers there. Name variables with longer words (val, msg, total), not v, n, s. Outside #ai files these letters remain ordinary identifiers.

radlang fmt expands #ai to canonical. Formatting an #ai file rewrites the short forms to full syntax, makes implicit returns explicit, and drops the directive — so humans always read canonical radlang and #ai stays a write-time convenience:

// before (as authored)          // after `radlang fmt`
#ai                              fn add(num x, num y): num {
f add(n x, n y): n { r x + y }       return x + y
                                 }

Conventions

These are style conventions, not compiler rules — the toolchain accepts other layouts — but following them keeps a project consistent and its main readable.

File naming

Name source files in kebab-case with a .type.rad suffix, never underscores. The middle token labels what the file is:

db-service.ns.rad     // a namespace (library)
chat-room.ns.rad      // a namespace (library)
uid.test.rad          // a test / scratch harness
main.rad              // the program entry point (no type token)

So user-store.ns.rad, not user_store.rad or UserStore.rad. Namespace files live under the configured paths.namespace directory (default src/namespace).

Keep intrinsics behind a service namespace

Prefer to wrap built-in / intrinsic calls (db.*, fs.*, http.*, crypto.*, …) inside a purpose-named namespace and call that from main, rather than calling the intrinsic directly in main.rad. Give the wrapper intention- revealing methods — init(), add(), list() — so main reads as domain steps, not plumbing.

// src/namespace/db-service.ns.rad
ns DbService {
    priv Database conn

    fn init(): void {
        conn = db.connect("sqlite://./dev.db")   // the only place the URL lives
        conn.generate(ChatTbl)
    }

    fn add(string convId, string user, string msg): void {
        conn.insert(ChatTbl, { conversationId: convId, createdBy: user, message: msg })
    }

    fn recent(): DbChat[] {
        return conn.read(ChatTbl)
    }
}

// src/main.rad
import "db-service"

fn main() {
    DbService.init()
    DbService.add("room-1", "ada", "hello")
    // main never touches db.connect / conn.* directly
}

Why: the connection string, schema wiring, and any backend swap live in one file; main stays declarative; and the seam is easy to stub in tests. This won’t always fit — a one-off script calling a single intrinsic doesn’t need a namespace — but reach for the wrapper whenever an intrinsic is used in more than one place or carries setup (connections, handles, config).