radlang — Best practices
This guide is opinion, not compiler law. The toolchain accepts many layouts and
styles; the ones here are the defaults that keep a radlang project consistent,
its main readable, and its diffs small. It has two parts:
- Project conventions — how a project is laid out, configured, tested, and driven from the CLI.
- Language style guide — how to write idiomatic radlang inside a file.
For the language reference see the Overview; for the defined edge-case behavior (overflow, division by zero, bounds) see Semantics.
Part 1 — Project conventions
Project layout
radlang new <name> scaffolds the canonical shape. Follow it:
myapp/
├── config.yaml # project metadata, paths, env
├── .gitignore # ignores output/
├── src/
│ ├── main.rad # entry point — fn main() lives here
│ ├── namespace/ # one namespace per file: *.ns.rad
│ │ └── app.ns.rad
│ └── test/ # *.test.rad files
└── lib/ # compiled .rlc libraries (stdlib + your namespaces) The entry point is always src/main.rad and holds fn main(). Everything else
that isn’t glue belongs in a namespace under src/namespace/. A *.ns.rad file
auto-compiles to lib/*.rlc on build/run, so you import "app" by name and
the compiler resolves the library.
config.yaml
The scaffold writes a minimal config; grow it as needed. The paths block tells
the compiler where things live, so the CLI’s file-less forms (radlang run, radlang rtest) just work:
project:
name: "myapp"
version: "0.1.0"
cores: 1 # HTTP prefork workers; omit for CPU count, 1 disables
paths:
build: "output"
lib: "lib"
tests: "src/test"
namespace: "src/namespace"
env:
PORT: 8085 Read these values at runtime through the compiler-injected cfg namespace — cfg.name, cfg.env("PORT"), cfg.buildDir. See Project config.
Keep secrets out of
config.yaml. Theconfig.yamlenv:block is baked into the binary and recoverable withstrings, so it is for non-secret defaults only. Put passwords, API keys, and tokens in a gitignored.radenvinstead — it loads at run time (not baked) and overlays the config defaults, socfg.env("API_TOKEN")still returns it. See Project config.
File naming — kebab-case.type.rad
Name files in kebab-case with a .type.rad suffix, never underscores or
PascalCase. 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 entry point (no type token) So user-store.ns.rad, not user_store.rad or UserStore.rad.
Keep intrinsics behind a service namespace
Built-in namespaces (db, fs, http, crypto, …) are powerful but low-level.
When one is used in more than one place or carries setup — a connection, a handle,
a base URL — wrap it in a purpose-named namespace with intention-revealing methods
and call that from main. Keep the raw intrinsic out of main.rad.
// src/namespace/user-store.ns.rad
type User { string name, int age }
ns UserStore {
priv User[] users = []
fn add(string name, int age): void {
users.add(User(name, age))
}
fn count(): int {
return users.length()
}
} // src/main.rad
import "user-store"
fn main() {
UserStore.add("Ada", 36)
UserStore.add("Alan", 41)
sys.output("users: {UserStore.count()}") // main reads as domain steps
} The connection string, schema wiring, and any backend swap then live in one
file; main stays declarative; and the seam is easy to stub in tests. A one-off
script calling a single intrinsic doesn’t need this — reach for the wrapper when
setup or reuse appears. (This mirrors the Conventions section of the overview.)
Tests
Put tests in src/test/ as *.test.rad files. A test file needs no main — it is a
set of test "name" { } blocks. Import the bundled rtest helper for
assertions:
// src/test/user-store.test.rad
import "rtest"
test "count reflects adds" {
rtest.assertEq(2 + 2, 4, "arithmetic")
rtest.assert("radlang".startsWith("rad"), "prefix check")
} rtest gives you assert(cond, name), assertEq(actualInt, expectedInt, name),
and assertStr(actualStr, expectedStr, name). Run the suite by pointing rtest at the directory — it finds every *.test.rad recursively:
radlang rtest src/test/ CLI workflow
| Command | Does |
|---|---|
radlang new <name> | Scaffold a project (layout above) |
radlang run | Compile + run src/main.rad (debug profile) |
radlang build | Compile a release binary to output/release/ |
radlang rtest src/test/ | Compile + run every test block under a path |
radlang fmt <file> | Format a source file in place (canonical style) |
run and build default to src/main.rad from config.yaml, so you rarely
pass a file. Run fmt before committing to keep diffs to real changes.
Part 2 — Language style guide
Naming
| Kind | Convention | Example |
|---|---|---|
| Types (struct, union, enum) | PascalCase | type UserAccount { } |
| Functions & variables | camelCase | fn totalPrice(), var itemCount |
| Namespaces | PascalCase for services, lowercase for utilities | ns UserStore, ns greeter |
| Constants | camelCase const | const int maxRetries = 3 |
| Enum members | camelCase | enum Color { red, green } |
| Files | kebab-case (see Part 1) | user-store.ns.rad |
Prefer var for obvious locals; name types at the boundary
Use var when the initializer already makes the type obvious, and write the
explicit type at API boundaries (parameters, return types, fields) where it
documents intent:
fn priceWithTax(float price, float rate): float {
var tax = price * rate // obviously float — let it infer
return price + tax
} Always write return
radlang has no implicit last-expression return. A trailing expression in a
function or closure body is not the return value — you must write return, or
the function silently yields the zero default:
fn triple(int n): int {
return n * 3 // required — `n * 3` alone would return 0
} A newline right after return ends the statement, so keep the returned
expression on the same line (or end the line on an operator / wrap in parens).
Control-flow bodies: one line, or braces
A brace-less control-flow body must stay on one line — ASI inserts a stray
semicolon if you split if (cond)⏎body across two lines. When a body needs more
than one line, always use braces:
if ready { start() } // ok — one line, no braces
if ready { // ok — multi-line, braced
prepare()
start()
} Error handling — throw Error, catch at the boundary
Signal failure with throw Error(message, code); the throw unwinds to the
nearest enclosing try. Let errors propagate through the internals and catch
them once at the boundary (a request handler, main) where you can actually
respond:
fn loadPort(string raw): int {
if raw == "" {
throw Error("PORT is not set", 500)
}
return Int(raw)
}
fn main() {
try {
int port = loadPort(cfg.env("PORT"))
sys.output("listening on {port}")
} catch (Error e) {
sys.output("startup failed: {e.message} ({e.code})")
}
} Don’t wrap every call in its own try — that scatters recovery logic and buries
the happy path. One try at the level that owns the response is usually right.
Nullable + ?? for defaults, narrow with a guard
Model “might be absent” with a nullable type and supply a fallback with ?? rather than sentinel values. Inside an if (x != null) guard, x narrows to its
inner type:
fn label(string? name): string {
string shown = name ?? "anonymous" // default without a null-check ladder
return "user: {shown}"
} match for dispatch, typed arrow lambdas for list work
Use match (subject) { } for multi-way dispatch instead of an if/else ladder; arms are newline-separated and _ is the catch-all:
fn describe(int n): string {
return match (n) {
0 => "zero"
1 => "one"
_ => "many"
}
} For list transforms, the (Type param) => expr arrow lambda is the concise form.
The parameter must be parenthesized and typed — (n) => … and n => … do
not parse:
fn upperAll(string[] names): string[] {
return names.map((string s) => s.upper())
} When to reach for a namespace
Put related functions and their shared state behind a namespace once they form a
unit — a service (UserStore), a group of pure helpers (greeter), or anything
wrapping an intrinsic (Part 1). A standalone helper called from one place can
stay a top-level fn; promote it to a namespace when it grows state or a second
caller. Mark internal members priv so the namespace’s surface is only what
callers should touch.
Format before you commit
radlang fmt is the single source of truth for layout — indentation, spacing,
the canonical type Name { } struct form. Run it (or wire it into a pre-commit
hook) so style never shows up in review.