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:

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. The config.yaml env: block is baked into the binary and recoverable with strings, so it is for non-secret defaults only. Put passwords, API keys, and tokens in a gitignored .radenv instead — it loads at run time (not baked) and overlays the config defaults, so cfg.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

CommandDoes
radlang new <name>Scaffold a project (layout above)
radlang runCompile + run src/main.rad (debug profile)
radlang buildCompile 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

KindConventionExample
Types (struct, union, enum)PascalCasetype UserAccount { }
Functions & variablescamelCasefn totalPrice(), var itemCount
NamespacesPascalCase for services, lowercase for utilitiesns UserStore, ns greeter
ConstantscamelCase constconst int maxRetries = 3
Enum memberscamelCaseenum Color { red, green }
Fileskebab-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.