Docs
Language reference
The language itself, section by section. The other docs pages cover the memory model, the annotations shipped with the compiler and the standard library, the application home folder and migrations, the policy and permission model, and plugins.
Introduction
ADM is a statically-typed, multi-paradigm language for applications, services, and UI. Every program is one of four things - an application, a library, a plugin, or a module - and all four follow the same syntax and rules.
Three ideas carry through the rest of the language:
- Structured. Structs, enums, unions, interfaces, and datatypes describe data. Functions and transactions describe behavior.
- Expressive. Pattern matching, string interpolation, async/await, channels,
and error handling (
fail,onerror,expect/provide) are part of the core syntax. - Native. Applications, libraries, plugins, and modules all go through the same compilation pipeline.
Compiler
Everything goes through one CLI, adm.
ADM compiles for every platform and architecture its LLVM backend targets, not just the host
machine. adm build --os currently accepts linux, windows,
darwin, android, ios, and wasm; --arch
recognizes amd64, 386, arm64, arm,
riscv64, ppc64le, ppc64, s390x, and
mips64 (defaulting to the host's own when left unset). Cross-compiling to one of
these just picks the matching LLVM target triple.
Every command and subcommand, with its flags, is on the Command line
page; adm <command> --help prints the same for the installed version. In
short: build, check, run, test,
fmt and doc work on the source tree; app,
audit, lib and list on the project and its manifest;
get, install, publish, search and
vendor on packages; doctor, targets,
update and version on the installation itself.
Syntax Basics
Whitespace is insignificant except inside literals - no semicolons at the end of statements, no significant indentation.
Comments
Line comments run to the end of the line; block comments nest arbitrarily, so commenting out a region that already contains a comment works:
// single line comment
/*
* block comment
* spanning lines
*/
/* outer /* inner */ still commented */
A run of // lines directly above a declaration is its doc comment. Start it with the name and say what the thing does, what it takes, what it returns and when it fails. adm doc, the hover card and the IDE's Documentation tab show it, and the IDE renders it in place with the gutter pencil to switch back to the raw lines.
A fenced block, three backticks on their own comment line before and after, is a code block shown in a grey box with highlighting. Tags at the end of the comment, one per line, are shown as rows: every @author joined under Author, each @see a link to that symbol under See Also, @since, @deprecated, and any other @word under its own name. A tag's text runs on until the next tag; tags come after the description, never inside it.
// Groups the digits in threes: `1,234,567`; `-1,000`; `999` unchanged.
// `separator` replaces the comma.
//
// Example usage:
// ```
// comma(1234567) == "1,234,567"
// comma(-1234567, " ") == "-1 234 567"
// ```
// @author Ada Lovelace
// @see words
// @since 1.0
def comma(n int64, separator string = ",") string {
A block whose language is example is lifted out of the description instead, titled by the line above it, and listed under Examples by adm doc and the Documentation tab. A plain block is the better default.
Operators
| Operator | Purpose |
|---|---|
+ - * / % |
Arithmetic. + also concatenates strings and arrays, and merges maps (always into a new value). |
** | Exponentiation - a ** b raises a to the power b. |
.* | Element-wise matrix multiply (see Arrays & Slicing). |
+= -= *= /= %= |
Compound assignment - lhs = lhs op rhs. |
& | ^ ~ ! |
Bitwise AND / OR / XOR / NOT. Arrays reuse these as set operations. |
<< >> | Logical shifts; signedness is respected. |
<<< >>> |
Bit rotations, with compound forms <<<= and >>>=. |
&& || ! | Short-circuiting logical operators. |
< <= > >= == != |
Comparisons - work across numeric primitives, strings, chars, and enums. |
cond ?? (a, b) |
Conditional choice; only the taken branch is evaluated (see Conditionals). |
:= | Conditional junction - reuses the left operand across a chain (see Conditionals). |
is | Type test, used for narrowing (see Type Narrowing). |
A type can overload most of these for its own instances with an infix method -
see Types โ Methods.
Assignment
Several names can be assigned at once, which makes swapping values a one-liner - the right side is fully evaluated before anything is written:
let a = 1
let b = 2
a, b = b, a // a is 2, b is 1
++ and -- increment and decrement in place, and the usual unary operators apply to values:
let x int = 3
let y = -x // negate
let w = ^x // bitwise NOT
let ok bool = true
let nope = !ok
let counter = 0
counter++
counter--
Assignment is an expression, so it yields the value that was just written - handy inside a call or a condition. Compound assignment and slice assignment behave the same way:
let x int = 0
println((x = 7)) // 7
println((x += 1)) // 8
let a = [1, 2]
println((a[0] = 9)) // 9
println((a[:] += 1)) // every element incremented
Program Structure
Every ADM source file uses the .adm extension. A single file can hold any number
of applications, libraries, plugins, and modules - there's no one-file-one-thing rule.
Any of those four can be split across multiple files with the partial modifier,
so one large application or module doesn't have to live in one large file. All the fragments
for the same partial thing have to sit in the same directory; the compiler stitches
them back together before type checking runs.
The directory a file lives in has nothing to do with its namespace, though. A module's name
comes from its own declared identifier - module std.net.http is always
std.net.http, regardless of which folder or filename it's written in. Folders are
just how you organize files on disk.
Applications
An application block defines an executable target. Applications live at file
scope, support annotations (@trace, @flag, etc.), and may be split
with partial application as long as all fragments share the same identifier and
directory.
@annotation()
application MainApp {
def new(args string[]) int {
print("Hello, World")
return 0
}
def dispose() {
cleanup()
}
}
- The constructor
def new(args string[]) intis the entry point; the integer return value becomes the process exit code. def dispose()is optional and runs when the runtime shuts the application down.- Application bodies may declare properties and methods, like a regular
type- but applications cannot have generics,whereclauses, orinfixmethods, because they are never instantiated by the user. - Members cannot be marked
internal; applications are always public compilation units. - Multiple
applicationdeclarations in one workspace generate multiple binaries. An unnamed application inherits the filename and must be compiled explicitly - it cannot be spread viapartial. - Application names must be unique across the workspace, and are a single identifier - no dots, unlike a module's name.
A project can define any number of applications. adm build with no arguments
builds every application it finds; pass one or more names to build only those. A monorepo
with a CLI, a daemon, and a worker can build any one of them, several, or all of them, in a
single invocation.
An application's name is also the build output's filename, sanitized down to letters and
digits (with .exe appended on Windows).
adm.toml Manifest
An application can carry an optional adm.toml file - a TOML manifest at the
project root, keyed by the ADM application name. Run adm app init
to generate one: it writes the manifest and also creates an Ed25519 signing keypair, putting
the public key in the manifest and the private key in your config directory (or a path you
choose with --private-key-out). The private key should never be committed.
None of it is required. Without a manifest the application still has an identity:
__app_name__ and __app_id__ are the declared application name (the
file name for an anonymous application { }) and __app_version__ is
0.1; @manifest(name = "myapp", version = "1.2.0") on the
application block sets both without a file. The update constants resolve to
empty strings. A adm.toml wins over all of that; when present, it sets:
- Identity - the publisher's public key and a repo salt, combined with each
app's slug (or its application name) to derive stable
publisher_id/app_idvalues that survive renames. - Update metadata - a feed URL, channel, and artifact name, set globally or
per app. These only reach your program as compiler constants (
__update_feed__,__update_channel__,__update_artifact__) - nothing in the toolchain fetches or applies updates on its own. - Packaging - a version plus a list of files to bundle, consumed by
adm package buildto produce a release archive.
A family of commands works with the manifest once it exists, all on the Command line page:
adm app info/adm app id- print what the manifest configures and the identity it resolves to.adm app set/adm app unset- write or remove one value, keeping the rest of the file;adm app bump major|minor|patchbumps a version.adm app rekey- a new key pair and salt in place.adm app sign/adm app verify- sign a built artifact with the publisher key, then verify it later.adm app policy init|set|unset|get|log- the application's policy file and decision log.adm package build/adm package install- build a release archive from the packaging rules and install it locally.
Every value the manifest can set has a matching compiler constant: __app_name__,
__app_id__, __app_version__, __publisher_id__,
__publisher_pubkey__,
plus the three update fields above. There's also an __build_id__ constant that's
always set and changes on every build - don't use it as a stable identity.
Libraries & Plugins
Libraries and plugins share code without an entry point of their own - no def new,
nothing runs them directly. The difference is how they reach a program:
libraryis shared as source:adm buildpacks its exported modules into a signed.admlib, published through the registry and compiled together with the program thatuses it. See Libraries.pluginis packaged:adm buildproduces one.admpluginfile holding the plugin's metadata, its export index and a native shared object per platform, which a host loads at runtime through the plugin manager. See Plugins.
Both live at file scope, both can be partial, and neither can be marked
internal - like applications, an unnamed one inherits its filename. The block
name is the unit's identity and the module prefix it owns; what a package ships, what a
consumer may use, and how shared code is split are covered under
Libraries โ What ships.
library Geometry {
use std.math
def area(radius float) float {
return math.pi * radius * radius
}
}
A plugin states its public surface with an export list, using the same selectors
as use; everything else in it stays private. The compiler checks that every
exported symbol exists, is not internal, and that its signature only mentions
exported types. @manifest(version = ...) sets the plugin's version:
@manifest(version = "1.2.0")
plugin ImageTools {
use (
acme.imaging
acme.imaging.filters
)
export (
acme.imaging.filters // every non-internal symbol of the module
acme.imaging::(Image, decode) // a subset
)
}
adm build --platforms linux-amd64,linux-arm64,darwin-arm64,windows-amd64 builds
the plugin for each platform listed and bundles them all into the one package; without the
flag only the host platform is built. The Plugins page covers the
package layout, adm doc --plugin and the shared runtime.
adm build --libs or --plugins narrows a build to just those targets,
the same way naming an application does; adm lib manages libraries and
dependencies once you have some.
Modules & Namespaces
Modules are source containers that group symbols under a qualified name. They never produce a binary or library on their own - their contents are pulled into whichever application, library, or plugin imports them.
module my.module.name {
// module code
}
- Module names can be simple identifiers (
module utils) or dotted namespaces (module std.net.http). A file may host multiple modules. internal modulehides the module outside its package;partial modulelets you split it across files in the same directory.- Module bodies may contain
usestatements, constants, functions, types, structs, enums, unions, interfaces, datatypes, services, components, views, and styles. - Modules cannot declare other top-level units -
application,library,plugin, or nestedmoduledeclarations are forbidden inside a module block.
Modules are also how ADM talks to other languages. Annotate a module with an FFI attribute and every function declared without a body becomes an external symbol pulled in from that library instead of compiled ADM code. The standard library's OpenGL bindings do exactly this:
@link("GL")
module std.ui.renderer.opengl.gl {
const GL_TRIANGLES uint32 = 0x00000004
def glClear(mask uint32);
def glViewport(x, y, width, height int);
}
Everything in this module is a real GL symbol - glClear and glViewport
come straight from the system OpenGL library, not from ADM code. More on this under
Interoperability.
use Imports
use std.net.http
use my.module as mod
use some.module::(foo, bar as Baz)
use some.module::*
use (
std.io,
utils.math,
graphics.engine::(Renderer as Ren)
)
- A plain
use std.net.httpimports the module itself; you reference its exports through the module name -http.Response, not the full dotted path. use my.module as modaliases the whole module to a different name.use my.module as _links the module without a name. Nothing in the file can refer to it; it is there for what it registers, such as a renderer thatreflection.attrsdiscovers by its annotation. The linter leaves these alone.::(foo, bar as Baz)imports specific symbols by name, optionally renaming any of them. A module imported this way can't itself be aliased - there's nothing to alias, only the symbols it exposes are in scope.::*(or::(*)) imports every exported symbol from a module, including every overload of an imported function name.- Several imports can share one
use ( ... )block.
Module names aren't implicitly in scope - even after use std.net.http, you still
write http.Response, never std.net.http.Response.
use is legal almost anywhere a statement is: inside modules, inside type bodies
(so a method can pull in a helper without polluting the rest of the type), and inside function
bodies when scoping needs to stay tight. Wherever it's written, the import is scoped to that
block alone.
Data Types
Primitives
Values are declared with let, or const for ones that can't be
reassigned. A type annotation is optional - leave it off and ADM infers it from the value:
let x int = 2
let y = 2 // inferred as int
Strings (string) are UTF-8 and support interpolation and multi-line literals:
let a string = "Hello, ๐"
let b = "Hello {user}" // interpolation
let c = """
This is a
multi-line string
"""
Inside a string, {expr} interpolates any expression: a variable, a call, an
index. A lone { followed by an expression start (a letter, a digit, a quote, a
bracket) opens interpolation, so a literal brace is written \{ and
\}:
let name = "Ada"
let n = 3
println("{name} has {n} items ({n * 2} halves)") // Ada has 3 items (6 halves)
println("template: \{name}") // template: {name}
println("json: \{\"ok\": true\}") // json: {"ok": true}
Chars (char) are single-quoted and hold one Unicode code point:
let c char = 'a'
Booleans (bool) are true/false; none represents the absence of a value.
Integers come in a signed/unsigned family - int,
int8/16/32/64/128, uint, uint8/16/32/64/128 - and can be
written in decimal, hex, octal, or binary. Spaces (or underscores) can split up long digit runs
for readability; the compiler ignores them:
let x = 1000000
let x = 1 000 000
let a = 0xDEAD BEEF
let a = 0o142
let a = 0b1010 1010
A number too large for int, like 6277101735386680762814942322444851025767571854389858533375,
is simply written as a literal - ADM infers bigint.
Floats (float, float16/32/64/128) accept the same
space separators, a leading-dot shorthand, and exponents:
let y = 1000.0000001
let y = 1 000.000 0001
let y = .3
let x = 1e-3
complex32/64/128 use an i suffix for the imaginary part, e.g. 123i.
Durations (duration) are a number plus an SI-style suffix:
let a = 1ns
let a = 1us
let a = 1ยตs
let a = 1ms
let a = 1s
let a = 1m
let a = 1h
Regexes (regex) are slash-delimited, with an optional run of flags after the closing slash:
let a = /./
let a = /[a-z]{1,}+/mg
SIMD's vec4/8/16/32/64/128 and mask4/8/16/32/64/128 types get their
own section below, under Vectors (SIMD).
Converting between types uses the target type as a call - there are no implicit numeric conversions, so a narrowing or lossy conversion has to be written out:
let bytes = byte[]("test") // string -> byte[]
let str = string(bytes) // byte[] -> string
let i = int32(1.5) // numeric -> numeric
let letter = string('Z') // char -> string
Primitives aren't bare values either - they carry methods, so common operations read as ordinary method calls:
let s = " hello "
s.trim() // "hello"
let x = 3
x.max(5) // 5
let xs = [1, 2, 3]
xs.indexOf(2) // 1
xs.len() // 3
Strings index and slice like arrays do, including negative indexes - and because strings are UTF-8, indexing works on characters, not bytes:
let s = "abcd"
s[0] // "a"
s[:1] // "a"
s[1:3] // "bc"
s[-2:] // "cd"
Slicing mechanics are covered in full under Arrays & Slicing.
- Unit. Every index and length is a Unicode code point:
s.len()counts code points,s[i]is the code point ati,s[a:b]selects code points,for let (i, ch) in syields code-point indexes. Bytes are reached throughs.bytes(); grapheme clusters are not a unit the language knows. - Bounds. Negative indexes count from the end. An out-of-range
s[i]yields'\0'rather than panicking; slice bounds are clamped andb <= agives"". A string slice always copies. - Comparison.
==and ordering are byte-wise (code-point order for valid UTF-8). No normalization, no case folding;upper/lowermap ASCII only. - Ill-formed bytes. A string built from bytes decodes each ill-formed byte, overlong form, surrogate or out-of-range value as U+FFFD, counting one code point. A string cannot contain U+0000: bytes after an embedded zero are dropped.
- Cost.
len()is O(1) after the first call;s[i]is O(1) on ASCII strings and walks with a cursor otherwise, so sequential access stays linear.
Arrays & Slicing
Arrays are T[], dynamically sized, with a literal form - [1, 2, 3],
or Type[1, 2, 3] with an explicit element type.
An untyped literal takes its element type from the items, or from where it is used: a
declared any[], an assignment target or an any[] parameter push
their element type into the literal, and items of unrelated types make it an
any[] on their own, each item boxed:
let ints = [1, 2, 3] // int[]
let mixed = [4, "z", 5.5] // any[]
let row any[] = [1, "two"] // any[] by declaration
def log(fields any[]) { ... }
log([now(), "started", 200]) // the parameter type reaches the literal
Indexing supports negative indexes, which count from the end:
let a = int[1, 2, 3, 4]
let first = a[0] // 1
let last = a[-1] // 4
a[-2] = 10 // a is now [1, 2, 10, 4]
Slicing produces a view into the same backing storage - no copying:
let view = a[:] // full view
let prefix = a[:2] // first 2 elements
let suffix = a[-2:] // last 2 elements
let trimmed = a[:-1] // drops the last element
- Writes through a slice are visible through the original array (and any other slice) as long as they still share the same backing storage.
- A slice has its own
len/cap, independent of the original array. - If growing an array or slice needs more capacity, it detaches - allocates a new backing buffer and copies. Existing slices keep working but stop sharing storage with the grown one.
- Use
.clone()to force a copy instead of a view:let copy = a.clone().
Bulk operations treat arrays like sets, and apply arithmetic across every element at once:
let a = int[1, 2, 3, 4]
let b = int[3, 4, 5, 6]
let intersection = a & b // [3, 4]
let union = a | b // [1, 2, 3, 4, 5, 6]
let concat = a + b // [1, 2, 3, 4, 3, 4, 5, 6]
let diff = a - b // [1, 2]
let xor = a ^ b // [1, 2, 5, 6]
a[:] = 0 // mutate every element in place
a[:] /= 10 // divide every element in place
let scaled = a / 10
a += [7] // append; type-checked at compile time
a = a + ["oops"] on an int[] is a
compile-time error.
Matrices are arrays with row separators (;) and support matrix multiplication:
let m = int[1, 2, 3; 4, 5, 6] // two rows
let n = int[7, 8; 9, 10; 11, 12]
let product = m * n // standard matrix multiplication
let hadamard = m .* m // element-wise
Maps
Maps are map<K, V>, with a literal syntax that mirrors structs. Keys must
satisfy Serializable - primitives and enums qualify by default. (Today a key is
hashed and compared by its serialized bytes; a hashing/equality interface is planned to replace
that constraint.) a + b on two maps merges b into a copy of
a and returns the copy, like the array operators, neither operand changes;
merge(other) is the in-place form.
let m = map<string, int>{
"a": 1,
"b": 2,
}
Reading and writing use the same bracket syntax as arrays:
m["c"] = 3
let a int = m["a"]
let missing = m.get("missing") // ?int - none if the key is absent
Other lookups and mutations are plain methods:
m.hasKey("a") // true
m.delete("b")
m.len() // 1
Iterating a map yields key/value pairs; keys() and
values() return them as arrays on their own:
for let (k, v) in m {
println(k)
println(v)
}
let ks = m.keys() // string[]
let vs = m.values() // int[]
Tuples
Tuples group a fixed set of values without declaring a type: (T1, T2, ...) is
the type, and a parenthesized, comma-separated expression is the literal.
let ingredients = ("Sugar", 25, true)
let t (int, int) = (1, 2)
A single parenthesized expression stays just that expression - it takes at least one comma to make a tuple.
Destructuring pulls a tuple's values out positionally with let:
let (name, amount, active) = ingredients
// name = "Sugar", amount = 25, active = true
Use _ to skip a position you don't need:
let (_, amount, _) = ingredients
The same let ( ... ) syntax also destructures a struct - but there it matches by
field name instead of position, and any field can be renamed with as:
let (id as userId, address) = user
Tuples can also be unpacked with a match pattern - case (a, b): return a + b -
covered under Match.
Vectors (SIMD)
vec4/8/16/32/64/128<T> are fixed-width SIMD vectors over a numeric lane
type T - the number is the lane count, and operations run across all lanes at
once when hardware SIMD is available. mask4/8/16/32/64/128<T> hold the
per-lane result of a comparison.
A vector is built from a zero-valued instance, then loaded or splatted:
let data = float[1.0, 2.0, 3.0, 4.0]
let v vec4<float>
let a = v.load(data, 0) // 4 lanes starting at index 0
let w vec4<float>
let b = w.splat(2.0) // 2.0 broadcast to every lane
Arithmetic works lane-wise through the usual operators:
let sum = a + b // [3.0, 4.0, 5.0, 6.0]
let prod = a * b // [2.0, 4.0, 6.0, 8.0]
sum.store(data, 0) // writes the 4 lanes back into data
Comparisons return a mask instead of a bool, and a mask can pick lanes from either vector:
let hi = a.gt(b) // mask4<float>: true where a's lane > b's lane
let picked = hi.blend(a, b) // a's lane where hi is true, else b's
Structs
Structs are record-like: mutable fields, no methods or properties. Declare one with
struct; annotations work both on the struct itself and on individual fields:
@annotation()
struct User {
@json(omitempty=true)
name string
id int
age, height int // grouped: share one type
userType = "user" // default value, type inferred
a, b = 0, true // grouped defaults, types inferred
address struct { // anonymous inline struct
street string
city string
}
}
Instantiating a struct uses a literal; the type name can be dropped once it's already known from context:
let u User = {id: 1, name: "MyName", address: {street: "street"}}
let c = struct {x: 2, y: 3} // anonymous, one-off struct
Embedding a struct - writing its type name as a bare field - copies that struct's fields onto the outer one:
struct Account {
User // embedding: id, name, age... become Account's own fields
someModule.Properties // embedded types can be qualified too
balance float
}
Enums
Enums declare a fixed set of named constants. Members auto-increment from 0
unless given an explicit value, and a trailing comma is fine:
enum Direction {
North, South, West, East,
}
Give members explicit values - including expressions that reference earlier members in the same enum:
internal enum Color {
Red = 0xFF0000,
Green = 0x00FF00,
Blue = 0x0000FF,
}
enum Flags {
None,
Read,
Write,
ReadWrite = Read | Write,
}
internal enum keeps it visible only inside the declaring module, just like an
internal module.
Access a member through the enum's name, and compare with == like any other value:
let f = Flags.ReadWrite
let same = f == Flags.ReadWrite // true
Enums are declaration-only: they can't host methods, can't be partial, and can't
declare a constructor or destructor. Only plain identifiers are allowed as member names.
Unions
A union is a raw memory type: every member shares the same storage. Writing to
one member changes the bits for all of them - reading a different member reinterprets those
same bytes as its type. Unions don't track which variant was last written.
union Size {
A int
B float
}
Variants are constructed like calls, and read like fields:
let s Size = Size.A(10)
let a = s.B // 1.401298e-44 - the bits of 10 (int), reinterpreted as float
That reinterpretation is what a union is for. Since nothing tracks the active variant, reading the "wrong" member is legal and hands you back whatever bytes happen to be there.
Unions can be marked internal to be scope it to the declaring module. Unions can't be partial:
Interfaces & Datatypes
An interface declares behavior - a set of method signatures. Any type that has
all of them automatically satisfies the interface, no implements clause needed -
the same implicit satisfaction Go uses for its interfaces.
interface Animal {
Walk() !int
Eat<W>() ?bool
}
internal interface Dog {
Animal
someModule.SomeInterface
Bark()
}
Interfaces support embedding - Dog pulls in every method Animal
requires, plus its own Bark() - and generics in method signatures
(Eat<W>).
A datatype is the same idea applied to data instead of behavior: it describes a
required shape - which fields must exist, and their types - rather than which methods must
exist. Any struct or type exposing those fields satisfies it, implicitly, the same way:
datatype UserLike {
id int
email string
}
internal datatype Account {
UserLike
someModule.SomeDatatype
name string
}
Datatypes embed the same way interfaces do, and can be used anywhere a type constraint is
expected - including generic bounds. & combines constraints (a type must
satisfy all of them); | accepts any one of them:
def save<T: UserLike & Serializable>(value T) {
// UserLike ensures value exposes the right fields,
// Serializable ensures it has the methods needed to persist it.
}
any
any holds a value of any type at all. It's the escape hatch for cases where the
type genuinely isn't known ahead of time - and like a union, ADM won't let you use it as a
concrete type until you narrow it with is or match:
let x any = 123
x is int // true
let p = Person{first: "Ada"}
let a any = p
a is Named // true - interfaces work too
The value keeps its real type underneath, so std.reflection.typeName(x) reports
what it actually is. Reach for a union type instead
whenever the set of possibilities is known - it's checked at compile time, where
any pushes the check to a narrowing site.
Types
A type is ADM's stateful, instantiable building block - the closest thing to a
class. Unlike a struct, it can have methods, properties, generics, and a
constructor/destructor pair:
Declaring a Type
@controller()
partial type User<T> {
use std.crypto.hashing
internal atomic id T = defaultValue()
name string = "unknown"
balance float
where T is Account | Admin {
permissions string[]
}
def new(val string) {
name = val
}
def dispose() {
audit("disposing {name}")
}
infix def + (delta float) User<T> {
balance += delta
return self
}
}
Fields work like struct fields, but can take a default value (name string = "unknown"),
and the type itself can carry annotations and modifiers the same way a function can - this one
is partial, so the rest of it can live in another file, as long as every fragment
repeats the <T> generic parameter list. use inside a type is
scoped to that type only, and a type body can also nest its own struct,
enum, or union declarations.
internal type Cache { ... } hides the whole type outside its module - the usual
way to keep a helper type from leaking into your public API.
A field marked weak (weak parent ?Node) refers to another object
without owning it: the field must be optional, its target a struct, type, service or component,
and it reads none once the target has been released. Use it for back references so
parent and child form no reference cycle - see
Memory model โ Weak references.
A constructor with one parameter marked @implicit makes the type accept that
parameter's type directly: def send(to Secret<string>) is called as
send("hunter2") and the compiler inserts the constructor, the way a value
becomes a ?T. Inward only, one hop, never through an interface, and the
constructor cannot fail; wrappers that validate use a factory returning !T
instead. The language server shows such a parameter as
Secret<string>/string.
A where T is Constraint { ... } block adds members that only exist when the
generic parameter satisfies that constraint. Instantiate the type with a type argument that
doesn't match, and those members simply aren't there:
type Admin {}
type Account {}
type User<T> {
name string
where T is Admin | Account {
permissions string[]
def tag() string { return "privileged" }
}
}
let u1 User<Admin> = new(name="bob")
u1.tag() // fine - T is Admin, satisfies the where clause
let u2 User<string> = new(name="alice")
// u2.tag() would not compile - string doesn't satisfy Admin | Account
Constructors & Destructors
def new(...) runs right after allocation; def dispose() runs when
the runtime disposes the instance. Neither one declares a return type. Construction accepts
named arguments the same way any call does:
type Pair {
x int
y int
def new(x int, y int) {
self.x = x
self.y = y
}
def sum() int {
return self.x + self.y
}
}
let p = new Pair(y=2, x=3)
dispose is also a statement, for cleaning up before the runtime otherwise would:
type Payload {
id int
def dispose() {
// release whatever id owns
}
}
let a = Payload{id: 1}
dispose a
Properties
A property bundles storage with its accessor logic in one block. Every property gets an
implicit value storage slot, private to the block, that get/set
read and write:
type Box {
count int {
def get() int { return value }
def set(v int) { value = v }
}
}
let b = Box{}
b.count = 2
b.count += 1
print(b.count) // 3
Both accessors are optional: drop get for a write-only property, drop
set and it becomes computed (read-only) instead. Accessors are ordinary
defs and can be internal, atomic, asm,
cuda, or sql - but never async or infix. A
property itself can't be partial and can't declare its own where
block; specialize the surrounding type instead.
Methods
A method is just a def inside a type, with an implicit self
receiver. It can be generic on its own, even when the type isn't, and a call can pin the type
argument explicitly if it can't be inferred:
type Holder {
def mid<T>(x T) T { return x }
}
let h Holder = new()
h.mid(1) // T inferred as int
h.mid<string>("ok") // T pinned explicitly
infix turns a method into an operator overload - infix def + is what
makes a + b work for a type's own instances. Only type methods can be
infix; it can't be combined with async or internal:
type Bits {
value int
infix def <<< (count int) Bits {
return self
}
infix def & (other Bits) Bits {
return self
}
}
let a = Bits{value: 1}
let b = Bits{value: 2}
a <<< 1
a & b
Everything from Params & Returns applies to methods too -
modifiers, overloading, generics, annotations - plus infix, which only makes
sense here.
Control Flow
Branching, looping, and pattern matching, and the same three work on primitives, structs, tuples and every other type.
Conditionals
if/else work as usual, and can carry an
initializer before the condition, separated by ; - scoped to the whole chain:
if let x = test(); x == 1 {
x = 2
} else {
x = 3
}
when and unless turn a single statement into its own guarded
conditional - often more readable than a whole if block for a one-liner:
x = 1 when t
x = 3 unless t
callFunc() when ready
fail unless valid
The ?? choice operator picks between two expressions; only the taken branch is evaluated:
let cond bool = true
let x int = cond ?? (1, 2) // 1
Conditional junctions let := reuse the left operand across a
whole &&/|| chain, instead of repeating it:
let color = "white"
if color := "white" || "black" || "blue" {
// ...
}
// same as: if color == "white" || color == "black" || color == "blue" {}
The same trick works with regexes and && - each regex tests the same
subject, which reads nicely for validating a password against several patterns at once:
let password = "secret123!"
if password := /:alpha/ && /:digit/ && /:punct/ {
// ...
}
Loops
for takes a few different headers. The classic three-part form:
for let i = 0; i < 100; i++ {
work(i)
}
Init and post can each carry more than one variable, sharing the clause:
for let i, j = 0, 0; i < b.len(); i, j = i + 4, j + 3 {
b[i] = b[j]
}
Drop the init and post and only a condition is left:
for count < limit {
count++
}
Drop the condition too and it loops forever, until something breaks it:
for {
if done() {
break
}
}
Four suffix blocks attach extra behavior to any for, in any combination:
every expr { ... }- runs after iterations matchingexpr(or after every iteration ifexpris omitted).break { ... }- runs if the loop exits viabreak.empty { ... }- runs if the body never executes at all.finish { ... }- runs after the loop completes normally.
for let i = 0; i < 3; i++ {
sum += 1
break when i == 1010
} every i % 2 == 0 {
sum += 10
} break {
sum += 100
} empty {
sum += 1000
} finish {
sum += 10000
}
A range produces every value from the start up to (but not including) the end:
for let i in 0..10 {
sum += i
}
The same in form iterates an array - with the index if you ask for it - and a map, exactly like Maps showed earlier:
let values = [10, 20, 30]
for let (idx, v) in values {
println(idx)
println(v)
}
A string iterates the same way, yielding each character with its index:
let s = "ab"
for let (i, ch) in s {
println(i)
println(ch)
}
break exits a loop immediately; continue skips to the next
iteration. Both work in every loop form, and break is what triggers the
break { ... } suffix block:
for let i = 0; i < 3; i++ {
if i == 1 {
continue // skip the rest of this iteration
}
sum = sum + 1
}
for let j = 0; j < 3; j++ {
if j == 1 {
break // leave the loop entirely
}
sum = sum + 10
}
forall runs a range or array loop in parallel, spreading the work across as
many CPU cores as are available. Order isn't guaranteed - each worker just picks up the next
item as it finishes the last one:
forall let val in myArray {
compute(val)
}
The suffix blocks still work under forall: every still runs, just
out of order, and finish only runs once every worker has finished.
Any type can be iterated with for let v in value by implementing the builtin
Iterator<T> interface, a single method def iterate() ?T. The
loop calls it until it returns none; the type keeps its own cursor and resets it
after exhaustion so the value can be walked again. An Iterator<T> is a
real interface value, so it can be stored and passed around:
type Countdown {
from int
next int
def new(from int) {
self.from = from
next = from
}
def iterate() ?int {
if next == 0 {
next = from
return none
}
next -= 1
return next + 1
}
}
for let n in new Countdown(3) {
println(n) // 3, 2, 1
}
let it Iterator<int> = new Countdown(2)
for let n in it { ... }
Switch / Match
switch compares one value against several, with an optional initializer just
like if. Cases can list more than one value, and fall through explicitly with
continue instead of implicitly:
switch let x = classify(); x {
case 1, 2:
continue
default:
return 0
}
A condition-only switch, with no value to compare against, reads like a chain of if/else if:
switch {
case a == 1:
// ...
case a > 1:
// ...
default:
// ...
}
match goes further: it can destructure the value right in the case,
and each case can carry its own guard after a ;:
def classify(x int) int {
match x {
case 0:
return 0
case (n); n > 0:
return n
default:
return -1
}
}
def sum(t (int, int)) int {
match t {
case (a, b):
return a + b
default:
return 0
}
}
def userId(u User) int {
match u {
case {id int, email string}; id > 0:
return id
default:
return 0
}
}
classify matches a literal and binds a guarded name; sum
destructures a tuple positionally; userId destructures a struct's shape
(with a guard) the same way destructuring let does.
A case can also be a bare type instead of a pattern. On a union-typed value, that narrows
it: inside case string: the value really is a string; fall
through to default and it's narrowed to whatever's left of the union.
Multiple types can share one case, narrowing to that subset instead of just one:
def run(v string | int) int {
match v {
case string:
return 0
default:
return takesInt(v) // v is narrowed to int here
}
}
The same bare-type case works against an interface or datatype - including a generic parameter, which is how a function can behave differently depending on what constraint its type argument happens to satisfy:
interface Stringer {
def str() string
}
def useMatch<T>(x T) string {
match T {
case Stringer:
return x.str()
default:
return ""
}
}
Matching a value against none or error works the same way, and is
covered in more depth under Type Narrowing and
Error Handling.
Type Narrowing
Some values can legitimately be more than one shape: a union type is one of several types, an
optional is either a value or none. ADM won't let you use either as a single
concrete type until you narrow it - with is, match, or a
when/unless guard.
Union & Intersection Types
A union type, T1 | T2, accepts either shape, and is valid anywhere a type
annotation can appear. is checks which one you have; match does
the same thing case by case:
let width string | int = 100
let pixels int
if width is string {
pixels = parsePixels(width)
} else {
pixels = width
}
// or by pattern matching
match width {
case string:
pixels = parsePixels(width)
case int:
pixels = width
}
Nothing lets you read width as a plain value without picking a branch first -
unlike structurally typed languages such as TypeScript, there's no "anything goes" path
around it.
An intersection type, T1 & T2, requires both at once. It shows up mostly in
generic bounds, combining an interface and a datatype constraint the way
UserLike & Serializable did earlier.
A type alias can name any type expression, including a union, and reads exactly like the type it points to:
type Numeric = int | float
let score Numeric = 9.5
Aliases are always a single expression - they can't be partial.
Optional Types
?T marks a value that might be none instead of holding a
T. The same narrowing tools apply - is, match, and the
when/unless guards from Conditionals:
struct Box { value int }
def read(b ?Box) int {
return 0 when b is none
return b.value
}
The same value narrows just as well with match:
let o ?int = none
match o {
case none:
// nothing to read
case int:
println(o)
}
Functions
A function declaration can carry modifiers, generics, parameter defaults and annotations, and - for a few special cases - a foreign-language body instead of an ADM one:
@annotation()
internal atomic async def funcName<A, B: Serializable>(a int, b string = "default") !?Result {
let x, y = 1
return try process(a, b) onerror recover fallback()
}
Params & Returns
A function can carry any of these modifiers:
internal- visible only inside the declaring module, same as everywhere else.atomic- wraps the call in a per-instance lock, so only one caller runs it at a time; everyone else blocks until it's done. More under Concurrency.async- the function returns a future instead of running immediately. Covered in depth under Async / Await / Futures.infix- turns a method into an operator overload (a + b). Only type methods can beinfix, so it's covered under Types โ Methods.meta- turns the function itself into a reusable annotation, the way@trim(below) is defined. Covered in depth under Attributes & Modifiers.asm,cuda,sql- the body is a foreign-language DSL instead of ADM statements (below).
Parameters can share a type across grouped names, take a default value, and a call can name any argument regardless of position - arguments after a named one just can't go back to being positional:
def add(a int, b int = 5) int {
return a + b
}
let s = add(a=2, b=3)
let t = add(a=2) // b defaults to 5
A trailing ...T parameter collects any number of extra arguments into an array, and a matching array can be spread back out at the call site with the same ...:
def sum(nums ...int) int {
let total = 0
for let (_, n) in nums {
total = total + n
}
return total
}
let xs = [1, 2, 3]
let a = sum(...xs)
let b = sum(0, ...xs)
Parameters take annotations too, and an annotation can rewrite the value before the body
ever sees it. @trim() does exactly that - it reads the argument, trims it, and
writes the trimmed value back:
def isAlpha(@trim() txt string) bool {
// txt has already been trimmed by the time the body runs
// ...
}
Annotation arguments can be named too, the same way call arguments can -
@validate(min=-10, max=10) is the general shape. Annotations aren't limited to
compile time either: they're inspectable at runtime through reflection, the same mechanism
behind @flag, @json, and @controller.
Functions and methods can share a name as long as their full signatures - parameter types and count, plus the receiver type for methods - differ. Overload resolution happens entirely at compile time; a call must match exactly one candidate or it's a compile error, never a runtime guess.
asm, cuda, and sql functions carry a foreign-language
body instead of ADM statements. {name} inside one of these bodies works like
string interpolation, substituting any identifier visible in scope:
asm def func(dst int, src int) int {
mov {dst}, {src}
ret
}
cuda def vectorAdd(a, b, out float[]) {
__global__ void kernel(int* buffer) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
buffer[idx] = 1;
}
kernel<<<1, 64>>>({out});
}
sql def getUser(email string) !User {
SELECT * FROM users WHERE email = {email} LIMIT 1;
}
Everything outside a {name} placeholder is plain text in the target language -
the compiler doesn't try to understand it, just records the identifiers it needs to
substitute before handing the body to the target toolchain.
A cuda def body is one CUDA kernel. Its ADM parameters are the kernel's
parameters: a numeric scalar arrives as the matching C type, and an array T[]
arrives as T* name plus int64_t name_len, copied to the device
before the launch and back after it. The result is always !none: a launch can
fail (no driver, rejected kernel), and values come back through the arrays. The grid is one
thread per element of the first array parameter, in blocks of @launch(block = N)
threads (default 256), so a body guards with i < name_len;
@launch(x = "w", y = "h") names integer parameters holding the thread count per
axis instead.
use std.gpu
cuda def scale(values float32[], factor float32) !none {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < values_len) values[i] *= factor;
}
def new(args string[]) int {
let samples = float32[1, 2, 3, 4]
try scale(samples, 2.5)
println(samples) // [2.5, 5, 7.5, 10]
println(gpu.available()) // true when a driver loaded
return 0
}
The kernel is compiled to PTX at build time and also as plain C, so the same program runs
without a GPU: the CPU path runs the blocks in parallel. ADM_CUDA=gpu or
=cpu forces either, ADM_CUDA_DEVICE picks the device, and
gpu.available() / gpu.deviceName() report the path in use. A body
that needs device-only features (__shared__, __syncthreads, warp
intrinsics) declares @launch(cpu = false) and fails without a GPU.
Data that many launches share stays on the device in a std.gpu.DeviceBuffer<T>:
gpu.upload(data) copies once, the buffer goes wherever the kernel takes a
T[], download and write move data explicitly, and
dispose frees it. async cuda def returns a future, so the scheduler
keeps other tasks running while the driver works:
use std.gpu
async cuda def step(cells byte[], width int, height int) !none { ... }
def simulate(initial byte[], width int, height int, generations int) !byte[] {
let cells = try gpu.upload(initial)
for let g in 0..generations {
try await step(cells, width, height) // no copies between launches
}
let out = byte[]
out.len(cells.len())
try cells.download(out)
cells.dispose()
return out
}
Closures
A def block can be declared inline as a value - with the same parameter and
return syntax as a top-level function - and called immediately, or stored and called later:
let value = def () int {
let start = now()
defer log("elapsed", now() - start)
return compute()
}()
It closes over surrounding variables by reference, the same way a deferred call does - give it a name and it can even call itself, which is otherwise the only reason to name one:
let fib = def fib(n int) int {
return n when n < 2
return fib(n-1) + fib(n-2)
}
An anonymous function can still have an asm, sql, or cuda
body - but it can't be meta or infix, and doesn't accept
internal or atomic; those only make sense on a named declaration.
A lambda is sugar over the same thing: param => expr expands to an anonymous
function that returns expr. The parameter type can be dropped when it's already
known from context, like a callback argument:
let a = int[1, 2, 3]
let b = a.mapElem(v => v + 1)
let c = a.mapElem((v int) => v + 1)
Defer
defer schedules a call to run at the end of the current function's scope -
typically cleanup, like closing a resource opened earlier in the same function:
def run() {
let f File = new()
defer f.close()
defer print("second")
print("body")
}
Multiple deferred calls run in reverse order - last deferred, first run - so the code above
prints body, then second, then finally closes f.
Error Handling
Errors are values, not exceptions. A function that can fail says so in its return type, and a failure propagates up the call stack on its own until something tests for it, recovers it, or the program crashes.
Errorable Types
!T marks a value that might be a T or might be an error. If a
function's body contains fail, its return type has to be errorable - the
compiler checks this, not just a convention:
// compile-time error: fail requires an errorable return type
def name(a int) int {
if a == 0 {
fail "invalid"
}
return a**a
}
// compiles: the ! marks the return type errorable
def name(a int) !int {
if a == 0 {
fail "invalid"
}
return a**a
}
An errorable value has to be dealt with before you can use it as a plain value - either test
it, or use try to propagate the error to whoever called you instead of handling
it here:
// open returns !File - f might be a File, or an error
let f !File = open("file")
// try unwraps it to a plain File, or bails out of this function
// with the same error if open failed
let f File = try open("file")
fail / onerror
fail mirrors return, but returns an error instead of a result.
Failing with a string is shorthand for building the builtin Error struct;
failing with any struct that has the same shape works too, so you can attach extra fields:
fail "reason"
// same as
fail Error{message: "reason"}
// a custom error struct, as long as it has a message field
fail MyCustomStruct{message: "reason", traceid: id}
onerror attaches a handler to a call. recover stops the error there
and supplies a fallback - a value, another call, or a whole block if you need more logic -
instead of letting the error keep propagating:
def mightFail(flag bool) !int {
if flag {
fail "boom"
}
return 7
}
// recover with a plain value
let a = mightFail(false) onerror recover 99
// recover with access to the error itself
let b = mightFail(true) onerror (err error) recover 99
// bail out of the surrounding function instead of recovering a value here
let c = mightFail(false) onerror return 0
A block form works when recovering takes more than one expression:
let h = open("file") onerror (err error) {
log("Error opening file: {err}")
recover try open("default") unless error.critical
}
Contracts
expects and provides wrap a function with assertions - preconditions
checked before the body runs, postconditions checked after, no matter which return
path was taken. Because a failed assertion is itself a failure, a function using either block
has to return an errorable type:
def guarded(x int) !int {
expects {
assert x > 0
}
let y int = x + 1
return y
provides {
assert y > x
}
}
assert also works on its own, outside expects/provides,
anywhere a boolean check makes sense. Either way, a failed assertion can have its error message
overridden with onerror:
def createUser(name, email string) !User {
expects {
assert name != "" onerror fail "Name is required"
assert email != "" onerror fail "Email is required"
} onerror (err error) fail ExpectationErrors{message: "Invalid input", error: err}
// save user to db and create a User instance
return user
provides {
assert user != none
assert user.name == name
}
}
Transactions
begin and rollback blocks declare how a function saves and restores
state, but they only actually run when that function is called from inside a
transaction { ... } region - outside one, they're skipped entirely:
let state int = 0
def someFunction(x int) {
begin {
state = state + x
}
rollback {
state = state - x
}
state = state + 1
}
Inside a transaction block, any fail triggers every available
rollback automatically; rollback can also be called directly to trigger it by
hand. onerror on the transaction itself handles whatever error comes out the
other side:
def run() !none {
transaction {
someFunction(5)
rollback
} onerror (err error) {
recover none
}
return none
}
Concurrency
Three primitives cover concurrency: futures
for a single background result, channels for a pipe between workers, and select
for waiting on several channels at once.
Async / Await / Futures
An async function returns a future - async T - instead of a
T directly. await unwraps it; using an async T without
awaiting it (or propagating it onward as async) is a type error:
async def fetch() int { return 41 }
def new(args string[]) int {
let a async int = fetch()
let b int = await a
print(b)
return 0
}
Any call - async function or not - can run in the background just by prefixing it with async:
def compute(x int) int {
return x + 1
}
let f async int = async compute(41)
let v int = await f
await composes with &&/||, using the same
short-circuit logic as booleans but over completion state instead. await (a || b)
resumes on whichever finishes first and cancels the other (unless it called
.keepAlive()); await (a && b) waits for both:
async def slowInt() int {
// ... slow work ...
return 7
}
async def fastString() string {
return "ok"
}
let a = slowInt()
let b = fastString()
let v = await (a || b)
print("string") when v is string
print("int") when v is int
A future exposes .running()/.done()/.canceled() to
check its state, .cancel(?reason) to request cancellation, and
.timeout(duration) to set a deadline:
let homepage async string = load("https://adm-lang.dev")
homepage.timeout(3s)
try and await compose in either order on an errorable future -
try await f() and await try f() both work.
Channels
A channel is a typed pipe between workers - chan <- value sends,
<-chan receives. An unbuffered channel (the default) blocks the sender until
a receiver is ready; a buffered one has room for values to queue up:
let ch channel<int> = new(1) // buffered, room for 1
println(ch.len())
ch <- 7
let v = <-ch
println(v)
new channel<T>(size=?, mode=?) is the full constructor - size
defaults to 0 (unbuffered), and mode (FIFO/LIFO/priority) defaults
to FIFO. A channel exposes a closed property, and the range form of
for drains it until the producer closes it: for let value in channel { ... }.
Select
select waits on several channels at once, running whichever case is ready first,
or default if none are:
let ch channel<int> = new(1)
ch <- 42
select {
case ch:
let v = <-ch
println(v)
default:
println(0)
}
There's no fall-through, so continue isn't allowed inside a select
case - every case either handles its event or control leaves the select entirely.
Value Tags
Every reference value carries an optional set of string tags, reached through
.tags(). Tags are metadata for tracing, ownership tracking and policy checks: a
request marked as coming from the primary network, a string that has been signed, a record
holding personal data. They are not part of the value's type.
Tagging Values
value.tags() returns the value's TagSet, creating an empty one on
first use. The set offers add, remove, toggle,
has, list, len, clear and
set(spec), where the spec is whitespace-separated: +tag adds,
-tag removes, a bare tag adds, and the empty string clears
everything. Mutators return the set, so calls chain.
let request = new Request(7)
request.tags().add("Network/primary").add("pii")
let message = "hello"
message.tags().set("+signed")
if request.tags().has("pii") {
redact(request)
}
request.tags().set("-pii +audited") // remove one, add another
request.tags().set("") // clear
Strings, arrays, maps, channels, errors, any, closures and instances of declared
types carry their tags on the object itself - struct instances included, since
they are heap-backed. Numbers, bool, char and enums have no
identity, so their tags belong to the
variable holding them: let x = 5; x.tags().add("trusted") tags
x's storage, and the compiler moves the tags with the value through bindings,
arguments and returns. A copy shares the set until its first mutation, then gets its own -
tagging a copy never changes the original. Only named storage can be tagged:
5.tags() and (a + b).tags() are compile errors. Tags do not cross
indirect calls, and scalar array elements or struct fields do not carry tags yet.
Propagation Rules
A tag set belongs to the value's identity. It follows the value wherever the value itself goes: assignments, function arguments and returns, container slots, channel sends, futures and errors. A callee can therefore check what it was handed:
def send(message string) !none {
fail "refusing an unsigned message" unless message.tags().has("signed")
// ...
return none
}
let items Request[]
items.push(request)
items[-1].tags().has("pii") // true: the same object sits in the slot
let ch channel<Request> = new(size=1)
ch <- request
let got = <-ch // got.tags() is request.tags()
A copy is a new value and starts with an empty set: a + b on strings,
clone(), slicing, map.values() and a freshly created error inherit
nothing from their inputs. The set lives exactly as long as the value and is released with it.
Services
A service behaves like an OS service: the ADM service manager starts it before
application.new runs and stops it on shutdown, and it owns exclusive access to
one slice of the platform - networking, storage, the UI surface, whatever it's responsible for.
A service method is one of three kinds: @query() reads a snapshot of its state,
@commit() mutates it and emits a typed Event<T> that other code
can react to, and @emit() only exists to describe that event's shape - it can't be
called directly.
Those annotated methods are the whole public surface. Fields are always private to the
service, and a def without an annotation is internal: callable from the service's
own methods, refused from anywhere else (method "helper" is internal to service
Counter). The manager's members are the exception: the ServiceDescriptor
fields (description, dependencies, lifecycle,
permissions, status) and the ServiceHooks methods (config,
start, stop, restart) stay reachable. @commit() may be async; @query() and
@emit() may not.
service Network {
connections Connection[]
@query()
def connectionCount() int {
return connections.len()
}
@commit()
async def open(address string) !Connection {
let con = try dial(address)
connected(address)
connections += [con]
return connections[-1]
}
@emit()
def connected(address string) string {
return address
}
}
@on(Network.connected)
def log(evt Event<string>) {
println("Connected to {evt.result}")
}
Network.open("...") calls straight into the service like any other function. The
handler subscribed with @on(Service.method, ?filter) takes exactly one argument -
Event<T>, where T is that method's return type - and an optional
filter to only react to some events. What the event carries, when handlers run,
and how the manager starts, restarts and reports services is covered under
Services โ Events and observers.
Services can't be constructed with new and can't declare their own
new/dispose - the manager owns that lifecycle entirely.
@commit() methods are implicitly atomic; @query() and
@emit() can never be async; and infix is rejected outright,
since nothing ever operates on a service like a value. A service can still be internal
or partial like anything else.
ADM ships a set of builtin services that cover the platform surface every application needs. Every method signature and every annotation they provide is listed in the services reference.
| Service | Description |
|---|---|
| Application | Application details (PID, executable path, start time, uptime) and lifecycle methods (exit/restart). |
| Runtime | The ADM runtime itself - memory/CPU/OS/GC/heap info, environment variable reads. |
| Storage | Permanent storage (disk) - open files, disk usage, read/write metrics. |
| Network | All networking - connections, metrics, firewalling. |
| Cache | Fast in-memory cache, Redis-like. |
| Scheduler | Cron-style scheduling of methods at configurable intervals or times. |
| Logging | Leveled logging, log rotation, log shipping. |
| Messaging | In-memory queue with topics. |
| Diagnostics | Create and publish metrics. |
| I18N | Internationalization and localization. |
| UI | UI, windows, renderer, UI events. |
| Devices | Webcam, microphone, USB, and other devices. |
| IPC | Inter-process communication between ADM applications. |
| Telemetry | Telemetry. |
| Vault | Encrypted store for secrets. |
UI
ADM's UI is built from three pieces bound together into a component: a view (structure), a style (appearance), and a controller (behavior) - all written in plain ADM, not a separate template language.
Views & Styles
A view body is a regular ADM block. Components are instantiated like function calls
(Button("Logout", onClick=doLogout())), children go in a trailing
{ ... } block, and ordinary control flow - if/else,
for, match, the when guard - works right inside it:
view MyView {
Button("Logout", onClick=doLogout()) when user.isLoggedIn
if user.isLoggedIn {
Text("Welcome back, {user.name}")
for let (_, item) in messages {
MessageCard(item)
} empty {
Text("You have no messages")
}
} else {
VStack() {
Row() {
Text("Welcome", mode="heading")
TextField(email)
PasswordField(password, placeholder="Please enter your password")
Button("Login", onPressed=submit())
}
}
}
}
A style body is the same idea applied to appearance: a regular block, run in a scope that
predeclares context variables - centered, box, on,
children, plus layout references like root/parent/prev/next.
Assigning a struct literal to one overrides the base style; let introduces a
reusable snippet:
style MyStyle {
let width = 100
centered = {
align: root.center,
size: width,
}
box = {
padding: width / 10,
backgroundColor: prev.backgroundColor,
right: min(next.left - 10, root.right - 30),
}
if parent.width >= 100 {
centered.box.backgroundColor = Colors.alert
}
// per-state overrides
on = {
hover: { cursor: "pointer" },
pressed: { transform: scale(0.97) },
}
// style child components by name
children = {
Button: { ...loginButton },
VStack: { spacing: 20 },
}
}
Views and styles can't be partial or annotated - they're pure templates. Declare
one standalone with a name, like MyView above, and a component can reference it
by that name; or write it anonymously, inline inside the component itself.
Components
A component binds a view, a style, and a controller type together.
The UI service asks for a window or surface and renders the component tree - the same
component source targets a desktop window or a WASM/web canvas; the backend decides which.
Reference named view/style/type declarations, or write every piece anonymously inline - both are the same construct:
component myComponent {
use lib
view lib.MyView
style lib.MyStyle
type lib.MyType
}
component myComponent {
view { // anonymous view
}
style { // anonymous style
}
type { // anonymous controller
}
}
The controller type is where state and event handling live - a regular
type, following every rule from Types (imports, annotations,
internal members, infix methods, all of it):
type {
let state = struct {
label = "Submit",
loading = false
}
@on(UI, "click", "someButtonId")
def onClick(event UI.Event) {
state.loading = true
// do some work here
}
}
A component can be partial, but its embedded view and style can't be - only the
component wrapper splits across files. UI events are delivered through the
UI service; a controller method opts in by annotating itself, the way
onClick does above.
Attributes & Modifiers
Attributes (@name(...), or just @name when no arguments are
passed) attach metadata to a declaration. Modifiers
(internal, partial, ...) change how a declaration behaves. Both have
appeared in earlier sections; this one is the reference for each.
Every attribute the compiler and the standard library ship is catalogued on the
Annotations page.
Attributes
An attribute's arguments can be positional or named, same as a regular call, and every attribute is preserved in compiled metadata, where tooling and runtime reflection can read it:
@flag("version", "Show application version", default=true)
internal const version string
meta def is how an attribute like this gets defined in the first place - it's
what @trim from Params & Returns actually is:
meta def trim<string>(target reflection.ParameterMetaContext) {
let v = string(target.get())
target.set(v.trim())
}
The context parameter's type is what limits where an attribute can be used - one that takes
a ParameterMetaContext only makes sense on a parameter, one built around a
service-specific context only makes sense on a service. Overload the same
meta def with a different context type to let it target more than one kind of
declaration.
A handful of attributes are built into the compiler itself. @cfg(...)
conditionally compiles a declaration; an excluded copy is not even required to parse:
@os("linux")
const SEPARATOR = "/"
@os("windows")
const SEPARATOR = "\\"
@os, @arch, @build, @feature, and
@version are sugar over the full @cfg(os=, arch=, build=, features=, version=)
form. Keys within one @cfg all have to match (AND); stacking more than one
@cfg on the same declaration means any one of them matching is enough (OR).
os/arch come from ADM_OS/ADM_ARCH (or the
host by default); build/features come from adm build
flags like --release --features simd,fastmath.
@embed(path) is the other built-in: it stages a file's bytes (or a whole
directory) into a const at compile time - byte[] for a file,
std.os.FileSystem for a directory. The standard library also reserves names for
annotations like @derive, @mock, and @sqlschema, built
on the same compile-time mechanism.
Two more fill in a declaration's value from the outside world. @env(name, ?default)
reads an environment variable, and @flag(name, description, ?default) wires a
declaration up to a command-line flag:
module demo.globals {
@env("ADM_TEST", "fallback")
const tmp string
@flag("debug", "Enable debug mode", default=true)
const debug bool
}
Modifiers
Every modifier in the language, and the section that covers it:
| Modifier | Applies to | Meaning |
|---|---|---|
internal | modules, members, services, unions, enums | Limits visibility to the current package. |
partial | application/library/plugin/module/type/service/component | Splits a declaration across multiple files in the same directory. See Program Structure. |
atomic | fields, functions, properties | Locks per-instance around the call. See Params & Returns. |
weak | fields | A non-owning optional reference (weak parent ?Node): never keeps its target alive, reads none once the target is gone. For back references, so parent and child form no cycle. See Memory โ Weak references. |
async | functions, types | Returns a future instead of running immediately. See Async / Await / Futures. |
asm, cuda, sql, meta | functions | Body is a foreign DSL, or (for meta) defines a new attribute. See Params & Returns. |
infix | methods | Operator-style invocation (a + b). Only on type methods - see Types โ Methods. |
Interoperability
ADM can call into any language that exposes a C-compatible ABI directly, and reach a few more
through embedded VMs or bridging layers. Only a module body
can host a foreign declaration - a function signature with no block body, ending in
;:
module ffi.math {
// Windows dynamic link libraries
@external.native("kernel32.dll", convention="stdcall")
def Beep(freq, duration uint32) bool;
// Linux shared objects
@external.native("libc.so.6")
def getpid() int32;
// a C header
@external.c("math.h")
def calc(a int, b int) int;
// other languages
@external.python("logger.py")
internal async def log(message string);
@external.node("file.js")
def log(message string);
}
A few rules keep these declarations honest: only module bodies can have a body-less
def - applications, types, and services always need a real one. They can't be
asm, sql, cuda, meta, or infix,
and every parameter/return type has to be representable in the target language - no channels,
optionals, errorables, or ADM-only composites. atomic/async still work
as wrappers around the call even though the body itself is foreign.
For binding a whole library at once instead of one function at a time, annotate the module
itself - this is the same @link("GL") mechanism Modules & Namespaces
already showed for the OpenGL bindings:
@c("libm.so")
module Math {
def sin(x float64) float64;
def cos(x float64) float64;
}
Every def inside becomes an implicit FFI declaration, without repeating @external on each one.
The native ABI reaches systems languages directly; two more layers reach interpreted and VM languages:
| Layer | Languages |
|---|---|
| Native ABI | C, C++, Rust, Zig, Go, Swift, Fortran, Nim, D, Pascal, Assembly |
| Embedded VMs | Python, Node/JavaScript, Lua/LuaJIT, Ruby, PHP, Perl, R, Scheme/Lisp variants, TCL |
| Bridging layers | Java/JVM, Kotlin, Scala, Clojure, C#/.NET/Mono, WASM, Erlang/Elixir |
Testing
check blocks keep tests next to the code they verify, with no separate test
framework. They're parsed and type-checked with the production code, so a broken test
surfaces immediately, but the compiler drops them from the final binary
unless adm test is what's asking for them.
Unit Tests
A check "name" { ... } block is legal at file scope and inside modules, types,
services, components, and applications - just not inside a function body. If it has no
explicit @test() function, its top-level statements run as one synthetic test:
check "luhnCheck" {
use std.testing
for let (_, t) in string["79927398713", "49927398716"] {
assert luhnCheck(byte[](t))
}
for let (_, t) in string["123", ""] {
assert !luhnCheck(byte[](t))
}
}
Name individual cases with @test() instead, and each one runs - and reports - separately:
check "Path" {
@test()
def TestExtension() {
assert extension("/path/to/file.txt") == "txt"
assert extension("file") == ""
}
@test()
def TestBase() {
assert base("/path/to/file.txt") == "file.txt"
}
}
A suite can also declare use imports, constants, structs, and helper functions -
all scoped to that suite, never exported - plus lifecycle hooks from std.testing
(beforeAll, beforeEach, afterAll, afterEach),
and can nest other check blocks to group related tests. Files named
_test.adm or .test.adm are discovered automatically; run everything
with adm test.
Benchmarks
@benchmark() marks a suite function as a benchmark instead of a test - same
check block, same scoping rules:
check "UUID" {
use std.testing
@benchmark()
def newUUID() {
let uuid = new UUID()
}
}
Run benchmarks with adm test --bench. A few other stdlib attributes layer onto
the same check/def shape without needing any new grammar:
@fuzz(), @skip(), @parallel(), and
@timeout(5s).
Magic Constants
A set of double-underscore constants is filled in by the compiler and folded into the binary at build time. They're usable anywhere an expression is, which makes them handy for logging, diagnostics, and build metadata:
def whereAmI() string {
return "{__file_name__}:{__line__} in {__module__}"
}
| Constant | Description |
|---|---|
__compiler_version__ | The compiler's semantic version string. |
__target__ | Full target triple: CPU-Vendor-OS (e.g. "x86_64-unknown-linux-gnu"). |
__os__ | OS of the active compilation target ("linux", "windows", "macos", "android", "wasm"). |
__arch__ | Architecture of the active target ("x86_64", "arm64", "wasm32"). |
__build_timestamp__ | ISO-8601 timestamp of when the build ran. |
__app_name__ | The application's name: from adm.toml, else @manifest, else the declared application name. |
__app_id__ | Stable identity derived from the manifest's publisher key and slug; the application name when there is no manifest. |
__app_version__ | The application's version: from adm.toml, else @manifest, else "0.1". Drives the home folder migrations. |
__endianness__ | "little" or "big", per the target architecture. |
__ptr_width__ | Pointer width in bytes for the target. |
__int_width__ | Size of the default integer type, in bytes. |
__word_size__ | Native register width, in bytes. |
__stack_alignment__ | Required stack alignment for the target. |
__abi__ | Active ABI (e.g. "sysv", "msvc"). |
__debug__ | True when compiling in debug mode. |
__release__ | True when compiling in release/optimized mode. |
__in_test__ | True when compiling in test mode. |
__git_commit__ | Git commit hash of the build (empty outside a Git repository). |
__git_branch__ | Git branch name of the build (empty outside a Git repository). |
__git_tag__ | Git tag name of the build (empty outside a Git repository). |
__file_name__ | Basename of the current source file. |
__file_path__ | Absolute path to the current source file. |
__dir__ | Absolute directory path of the current file. |
__file_hash__ | Compiler-generated hash of the file contents (used for caching). |
__line__ | Current line number, 1-based. |
__module__ | Fully-qualified module of the current file. |
__name__ | Current symbol name - function, type, method, and so on. |
__build_id__ also exists and changes on every build - see
adm.toml Manifest, which covers it alongside the
identity constants a manifest fills in.