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
- Program structure
- Comments
- Automatic semicolon insertion
- Primitive types
- Declarations
- Operators
- Control flow
- Functions & closures
- Custom types
- Enums
- Lists
- Strings
- Error handling
- Namespaces & imports
#aimode
Built-in namespace references
| Doc | Covers |
|---|---|
| System & process | sys — I/O, args, exit, timing, system info, OS env |
| Project config | cfg — config.yaml values, cfg.env, .radenv, cfg.profile |
| Math | math — functions, rounding, random |
| File system | fs — read/write files, paths, dirs, metadata |
| JSON | json — parse, serialize, navigate |
| XML | xml — parse, build, and navigate XML with XmlNode |
| Maps | Map — key/value storage |
| HTTP | http client + httpserver (routing, middleware, CORS) |
| Networking | tcp, websocket, sse, Channel — sockets, events, long-poll |
| Cryptography & hashing | hash + crypto — digests, HMAC, base64, AES, bcrypt |
| Compression | compress — gzip / deflate (de)compression |
| Dates & time | Date — construction, components, formatting, parsing |
| Database | schema, Database, query builder, raw SQL |
| Built-in hover reference | Exact namespace and value-method signatures shown by LSP hover |
| Regular expressions | regex.from, RegEx matching and replacement |
| Desktop GUI | gui — native webview windows, HTML/CSS/JS UI, page↔radlang bridge |
| Native GUI | syn — 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
| Doc | Covers |
|---|---|
| Best practices | Project layout, config, tests, naming, idiomatic style |
| Defined semantics | Overflow, 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
| Type | Underlying | Notes |
|---|---|---|
int | i32 | Signed integer |
uint | u32 | Unsigned integer |
long | i64 | 64-bit integer |
float | f64 | Double-precision float |
decimal | f128 | Software float |
byte | u8 | Byte |
bool | i1 | true / false |
char | — | Single character |
string | — | Text; + concatenates, == compares contents |
num | f64 | Numeric supertype; widens/narrows with any numeric type |
void | — | No value (return type) |
null | — | The null value |
var | — | Inferred: 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 Unions — is 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:
| Surface | Hover 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.radix | fn math.sort.radix(numeric[] values[, int threadCount]): numeric[] |
math.sort.count | fn math.sort.count(numeric[] values): numeric[] |
math.sort.timsort | fn math.sort.timsort(numeric[] values): numeric[] |
math.sort.intro | fn math.sort.intro(numeric[] values): numeric[] |
math.sort.pdq | fn 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/parallelare order-preserving — the result is identical to the sequential form, just computed concurrently.iterruns 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
reducerequires 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 sequentialreducehas neither restriction. A mismatched accumulator type is a compile error when athreadCountis present. threadCountis clamped to[1, length];1runs 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.
intsource →stringresult above). - The
wherepredicate 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 isabs(finish - start) + 1. - Direction is automatic. When
start > finishthe 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, orlongif either bound islong. - 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/.rlclibrary 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.rlcdoes not export namespace variables as external members: readingconfig.appNamedirectly 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):
| Short | Expands to | Short | Expands to | |
|---|---|---|---|---|
n | num | v | void | |
s | string | a | any | |
b | bool | f | fn | |
c | char | r | return |
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
#aifile, the single lettersn s b c v a f rcannot be used as identifiers there. Name variables with longer words (val,msg,total), notv,n,s. Outside#aifiles 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).