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:

comments.adm
// 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.

doc-comments.adm
// 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

OperatorPurpose
+ - * / % 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).
isType 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:

multi_assign.adm
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:

unary.adm
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:

assign_expr.adm
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.

main.adm
@annotation()
application MainApp {

	def new(args string[]) int {
		print("Hello, World")
		return 0
	}

	def dispose() {
		cleanup()
	}
}
  • The constructor def new(args string[]) int is 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, where clauses, or infix methods, because they are never instantiated by the user.
  • Members cannot be marked internal; applications are always public compilation units.
  • Multiple application declarations in one workspace generate multiple binaries. An unnamed application inherits the filename and must be compiled explicitly - it cannot be spread via partial.
  • 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_id values 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 build to 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|patch bumps 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:

  • library is shared as source: adm build packs its exported modules into a signed .admlib, published through the registry and compiled together with the program that uses it. See Libraries.
  • plugin is packaged: adm build produces one .admplugin file 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.

geometry.adm
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:

imagetools.adm
@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.adm
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 module hides the module outside its package; partial module lets you split it across files in the same directory.
  • Module bodies may contain use statements, constants, functions, types, structs, enums, unions, interfaces, datatypes, services, components, views, and styles.
  • Modules cannot declare other top-level units - application, library, plugin, or nested module declarations 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:

gl.adm
@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

imports.adm
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.http imports the module itself; you reference its exports through the module name - http.Response, not the full dotted path.
  • use my.module as mod aliases 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 that reflection.attrs discovers 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:

primitives.adm
let x int = 2
let y = 2  // inferred as int

Strings (string) are UTF-8 and support interpolation and multi-line literals:

strings.adm
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 \}:

interpolation.adm
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:

char.adm
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:

integers.adm
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:

floats.adm
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:

duration.adm
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:

regex.adm
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:

casts.adm
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:

primitive_methods.adm
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:

string_index.adm
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 at i, s[a:b] selects code points, for let (i, ch) in s yields code-point indexes. Bytes are reached through s.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 and b <= a gives "". A string slice always copies.
  • Comparison. == and ordering are byte-wise (code-point order for valid UTF-8). No normalization, no case folding; upper/lower map 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:

array_literals.adm
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:

arrays.adm
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:

slicing.adm
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:

bulk_ops.adm
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:

matrix.adm
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.

maps.adm
let m = map<string, int>{
	"a": 1,
	"b": 2,
}

Reading and writing use the same bracket syntax as arrays:

access.adm
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:

methods.adm
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:

iterate.adm
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.

tuples.adm
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:

destructure_tuple.adm
let (name, amount, active) = ingredients
// name = "Sugar", amount = 25, active = true

Use _ to skip a position you don't need:

skip.adm
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:

destructure_struct.adm
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:

vectors.adm
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:

arithmetic.adm
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:

masks.adm
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:

structs.adm
@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:

new_struct.adm
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:

embedding.adm
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:

direction.adm
enum Direction {
	North, South, West, East,
}

Give members explicit values - including expressions that reference earlier members in the same enum:

flags.adm
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:

use_enum.adm
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.adm
union Size {
    A int
    B float
}

Variants are constructed like calls, and read like fields:

use_union.adm
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.adm
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.adm
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:

bounds.adm
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:

any.adm
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

type_anatomy.adm
@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:

generic_where.adm
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:

constructor.adm
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:

destructor.adm
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:

property.adm
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:

generic_method.adm
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:

infix.adm
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_else.adm
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:

when_unless.adm
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:

choice.adm
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:

junctions.adm
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:

junctions_regex.adm
let password = "secret123!"

if password := /:alpha/ && /:digit/ && /:punct/ {
	// ...
}

Loops

for takes a few different headers. The classic three-part form:

for_classic.adm
for let i = 0; i < 100; i++ {
	work(i)
}

Init and post can each carry more than one variable, sharing the clause:

for_multi.adm
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_cond.adm
for count < limit {
	count++
}

Drop the condition too and it loops forever, until something breaks it:

for_infinite.adm
for {
	if done() {
		break
	}
}

Four suffix blocks attach extra behavior to any for, in any combination:

  • every expr { ... } - runs after iterations matching expr (or after every iteration if expr is omitted).
  • break { ... } - runs if the loop exits via break.
  • empty { ... } - runs if the body never executes at all.
  • finish { ... } - runs after the loop completes normally.
for_suffixes.adm
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_range.adm
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:

for_in_array.adm
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:

for_in_string.adm
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:

break_continue.adm
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.adm
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:

iterator.adm
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.adm
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_cond.adm
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 ;:

match.adm
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:

match_union.adm
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:

match_interface.adm
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:

union_type.adm
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:

alias.adm
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:

optional.adm
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:

optional_match.adm
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:

anatomy.adm
@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 be infix, 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:

named_defaults.adm
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 ...:

variadic.adm
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:

param_annotation.adm
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:

foreign_bodies.adm
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.

kernel.adm
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:

device_buffer.adm
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:

anonymous.adm
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:

anonymous_recursive.adm
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:

lambda.adm
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:

defer.adm
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:

errorable_required.adm
// 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:

try.adm
// 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.adm
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:

onerror.adm
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:

onerror_block.adm
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:

contracts.adm
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:

assert_onerror.adm
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:

transaction.adm
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:

transaction_run.adm
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_basic.adm
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:

async_prefix.adm
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:

await_compose.adm
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:

future_timeout.adm
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:

channel_basic.adm
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:

select_basic.adm
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.

tags.adm
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:

propagation.adm
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.adm
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.

ServiceDescription
ApplicationApplication details (PID, executable path, start time, uptime) and lifecycle methods (exit/restart).
RuntimeThe ADM runtime itself - memory/CPU/OS/GC/heap info, environment variable reads.
StoragePermanent storage (disk) - open files, disk usage, read/write metrics.
NetworkAll networking - connections, metrics, firewalling.
CacheFast in-memory cache, Redis-like.
SchedulerCron-style scheduling of methods at configurable intervals or times.
LoggingLeveled logging, log rotation, log shipping.
MessagingIn-memory queue with topics.
DiagnosticsCreate and publish metrics.
I18NInternationalization and localization.
UIUI, windows, renderer, UI events.
DevicesWebcam, microphone, USB, and other devices.
IPCInter-process communication between ADM applications.
TelemetryTelemetry.
VaultEncrypted 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.adm
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.adm
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_forms.adm
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):

component_controller.adm
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.adm
@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.adm
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:

cfg.adm
@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:

env_flag.adm
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:

ModifierApplies toMeaning
internalmodules, members, services, unions, enums Limits visibility to the current package.
partialapplication/library/plugin/module/type/service/component Splits a declaration across multiple files in the same directory. See Program Structure.
atomicfields, functions, properties Locks per-instance around the call. See Params & Returns.
weakfields 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.
asyncfunctions, types Returns a future instead of running immediately. See Async / Await / Futures.
asm, cuda, sql, metafunctions Body is a foreign DSL, or (for meta) defines a new attribute. See Params & Returns.
infixmethods 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 ;:

ffi.adm
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:

ffi_module.adm
@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:

LayerLanguages
Native ABIC, C++, Rust, Zig, Go, Swift, Fortran, Nim, D, Pascal, Assembly
Embedded VMsPython, Node/JavaScript, Lua/LuaJIT, Ruby, PHP, Perl, R, Scheme/Lisp variants, TCL
Bridging layersJava/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_implicit.adm
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_explicit.adm
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:

benchmark.adm
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:

constants.adm
def whereAmI() string {
	return "{__file_name__}:{__line__} in {__module__}"
}
ConstantDescription
__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.