Language Fundamentals

Every Adama file is a document type. That's the first thing to internalize. You're not writing a program that runs and exits — you're defining a living, stateful thing that persists, accepts connections, and synchronizes its state to clients in real time.

With that mental shift out of the way, the actual syntax should feel pretty familiar if you've touched any C-family language.

Document Structure

Every Adama file (.a extension) represents a document type. When you deploy this file, the platform can create instances of this document type. Each instance is an independent, durable entity with its own state.

File Layout

An Adama file consists of top-level declarations in any order:

// Field declarations (become JSON document state)
public int score = 0;
private string secret;

// Type definitions
record Player {
  string name;
  int points;
}

// Tables
table<Player> players;

// Functions and procedures
function add(int a, int b) -> int {
  return a + b;
}

// Event handlers
@construct {
  // initialization code
}

@connected {
  return true;
}

// Message types
message SomeMessage { }

// Channels for client communication
channel doSomething(SomeMessage msg) {
  // handle message
}

Key Insight: The order of declarations does not matter. Adama resolves all references after parsing the entire file. I did this because forcing people to organize code in dependency order is annoying and I didn't want to deal with forward-declaration nonsense.

Code Organization with @include

For larger projects, you can split code across multiple files using the @include directive:

@include std;
@include std/foo;
@include mylib/utilities;

The include system resolves paths relative to your project's library directories. Share common types, organize code into logical modules, use the standard library — the usual stuff.

Field Declarations Become JSON

Document-level field declarations define the persisted state. This state is automatically serialized to JSON and forms the document that clients can subscribe to:

public int count = 0;           // Visible to authorized clients
private string adminSecret;     // Never sent to clients

When a client connects, they receive the JSON representation of all public fields they have access to:

{
  "count": 0
}

The beauty of this is that you never write serialization code. You declare fields, the runtime handles the rest.

Comments

Adama supports two styles of comments. Nothing revolutionary here.

Single-Line Comments

Use // for comments that extend to the end of the line:

// This is a single-line comment
int x = 42;  // This comment follows code

Multi-Line Comments

Use /* */ for comments spanning multiple lines:

/*
  This is a multi-line comment.
  It can span as many lines as needed.
  Useful for longer explanations.
*/
int y = 100;

Note: Multi-line comments do not nest. A /* inside a multi-line comment does not start a new comment block. I could have made them nest, but I didn't. (Sigh.)

Variables and Constants

Declaration with Explicit Type

Declare variables with their type followed by the name:

int x;              // Declared, default initialized to 0
int y = 5;          // Declared and initialized
double pi = 3.14159;
string name = "Alice";
bool active = true;

Type Inference with var, auto, and let

When the type can be inferred from the right-hand side, use var, auto, or let:

@construct {
  var x = 42;            // x is int
  auto y = 3.14;         // y is double
  let name = "Bob";      // name is string
  let items = [1, 2, 3]; // items is list<int>
}

All three (var, auto, let) behave identically — use whichever you prefer. I tend to reach for var most of the time, but I'm not going to fight you about it.

Readonly Variables

Use readonly to create variables that cannot be reassigned after initialization:

readonly int maxPlayers = 4;
readonly string version = "1.0.0";

Attempting to reassign a readonly variable results in a compile-time error:

readonly int x = 5;
x = 10;  // ERROR: Cannot assign to readonly variable

When to use readonly: Configuration values, computed constants, anything that should never change after initialization. The compiler catches mistakes so you don't have to debug them at 2 AM.

Operators

Arithmetic Operators

Operator Description Example
+ Addition 5 + 3 equals 8
- Subtraction 5 - 3 equals 2
* Multiplication 5 * 3 equals 15
/ Division 6 / 2 equals 3
% Modulo (remainder) 7 % 3 equals 1

String concatenation also uses +:

string greeting = "Hello, " + "World!";  // "Hello, World!"
string message = "Count: " + 42;         // "Count: 42"
string repeated = "x" * 3;               // "xxx"

Division Safety: Division returns a maybe<T> type because division by zero is possible. I made this decision early and I stand by it — silent NaN propagation sucks. Use .getOrDefaultTo(value) to handle the maybe:

@construct {
  let result = (10 / 2).getOrDefaultTo(-1);  // result is 5
  let bad = (10 / 0).getOrDefaultTo(-1);     // bad is -1
}

Comparison Operators

Operator Description Example
== Equal to x == y
!= Not equal to x != y
< Less than x < y
<= Less than or equal x <= y
> Greater than x > y
>= Greater than or equal x >= y
bool isEqual = (5 == 5);    // true
bool isLess = (3 < 10);     // true
bool notEqual = (1 != 2);   // true

Logical Operators

Operator Description Example
&& Logical AND a && b
|| Logical OR a || b
! Logical NOT !a
bool a = true;
bool b = false;

bool andResult = a && b;    // false
bool orResult = a || b;     // true
bool notResult = !a;        // false

Logical operators short-circuit: && stops if the left side is false, || stops if the left side is true.

Assignment Operators

Operator Description Equivalent
= Assignment x = 5
+= Add and assign x = x + 5
-= Subtract and assign x = x - 5
*= Multiply and assign x = x * 5
++ Increment x = x + 1
-- Decrement x = x - 1

Both prefix and postfix increment/decrement are supported:

int x = 5;
int a = x++;  // a is 5, then x becomes 6
int b = ++x;  // x becomes 7, then b is 7
int c = x--;  // c is 7, then x becomes 6
int d = --x;  // x becomes 5, then d is 5

Compound assignment example:

int score = 100;
@construct {
  score += 50;   // score is now 150
  score -= 25;   // score is now 125
  score *= 2;    // score is now 250
}

Control Flow

if / else / else if

The if statement executes code conditionally:

bool condition = true;
@construct {
  if (condition) {
    // executed when condition is true
  }
}

Add else for an alternative:

int score = 0;
string rank = "";
@construct {
  if (score > 100) {
    rank = "Gold";
  } else {
    rank = "Silver";
  }
}

Chain multiple conditions with else if:

int score = 0;
string grade = "";
@construct {
  if (score >= 90) {
    grade = "A";
  } else if (score >= 80) {
    grade = "B";
  } else if (score >= 70) {
    grade = "C";
  } else {
    grade = "F";
  }
}

Pattern Matching with maybe Types

The if statement has special syntax for unwrapping maybe<T> types — and this is one of the things I'm genuinely proud of in the language design:

maybe<int> value;
@construct {
  value = 42;

  if (value as v) {
    // v is an int with value 42
    int doubled = v * 2;
  } else {
    // value was empty (no value present)
  }
}

This if-as pattern is the idiomatic way to safely access optional values. No null pointer exceptions, no forgotten checks.

switch / case

Use switch for multi-way branching on integers, strings, or enums:

int dayOfWeek = 0;
string name = "";
@construct {
  switch (dayOfWeek) {
    case 0:
      name = "Sunday";
      break;
    case 1:
      name = "Monday";
      break;
    case 6:
      name = "Saturday";
      break;
    default:
      name = "Weekday";
  }
}

With strings:

string command = "";
bool running = false;
@construct {
  switch (command) {
    case "start":
      running = true;
      break;
    case "stop":
      running = false;
      break;
    default:
      // unknown command
      break;
  }
}

With enums, you can use wildcard patterns — this is a nice little feature:

enum Status { Active, PendingReview, PendingApproval, Archived, Deleted }
Status status;
bool showPendingUI = false;
bool showActiveUI = false;
bool showInactiveUI = false;

@construct {
  switch (status) {
    case Status::Pending*:
      // matches PendingReview and PendingApproval
      showPendingUI = true;
      break;
    case Status::Active:
      showActiveUI = true;
      break;
    default:
      showInactiveUI = true;
  }
}

Important: Always include break at the end of each case to prevent fall-through to the next case. Yes, I kept fall-through semantics. Yes, you need the break. I'm sorry.

Loops

for Loop

The classic C-style for loop with initializer, condition, and increment:

@construct {
  for (int i = 0; i < 10; i++) {
    // executes 10 times with i = 0, 1, 2, ..., 9
  }
}

Any part can be omitted:

@construct {
  int i = 0;
  bool done = false;
  for (; i < 10; i++) {
    // initializer omitted
  }

  for (int j = 0; j < 10; ) {
    // increment omitted, must handle in body
    j++;
  }

  for (;;) {
    // infinite loop - must break or return
    if (done) {
      break;
    }
  }
}

foreach Loop

Iterate over collections with foreach:

record Player {
  int score;
}
table<Player> players;

@construct {
  int sum = 0;
  int totalScore = 0;
  int distance = 0;

  // Iterate over an array literal
  foreach (item in [1, 2, 3, 4, 5]) {
    sum += item;
  }

  // Iterate over table rows
  foreach (player in iterate players) {
    totalScore += player.score;
  }

  // Iterate over anonymous objects
  foreach (point in [{x:1, y:2}, {x:3, y:4}]) {
    distance += point.x + point.y;
  }
}

Note: Use iterate tableName to convert a table into an iterable list. Tables aren't directly iterable — you need that iterate keyword to turn them into something you can loop over.

while Loop

Execute while a condition remains true:

@construct {
  int count = 0;
  while (count < 5) {
    count++;
  }
  // count is now 5
}

do-while Loop

Execute at least once, then continue while condition is true:

@construct {
  int count = 0;
  do {
    count++;
  } while (count < 5);
  // count is now 5
}

The difference from while: the body executes before the condition is checked, guaranteeing at least one execution. Useful sometimes; I don't use it much personally.

break and continue

Control loop execution with break and continue:

@construct {
  int sum = 0;

  // break exits the loop entirely
  for (int i = 0; i < 100; i++) {
    if (i == 10) {
      break;  // exits when i reaches 10
    }
  }

  // continue skips to the next iteration
  for (int i = 0; i < 10; i++) {
    if (i % 2 == 0) {
      continue;  // skip even numbers
    }
    // only odd numbers reach here
    sum += i;
  }
}

Functions and Procedures

Here's where Adama starts to diverge from what you might expect. I split callable code into two categories: functions and procedures. This distinction matters because it's how Adama manages state and reactivity.

Functions (Pure)

Functions are pure: they cannot modify document state and have no side effects. They can only compute and return values based on their inputs.

function add(int a, int b) -> int {
  return a + b;
}

function square(int x) -> int {
  return x * x;
}

function greet(string name) -> string {
  return "Hello, " + name + "!";
}

Call functions like any other language:

function add(int a, int b) -> int {
  return a + b;
}

function square(int x) -> int {
  return x * x;
}

function greet(string name) -> string {
  return "Hello, " + name + "!";
}

int sum = add(3, 4);        // sum is 7
int squared = square(5);    // squared is 25
string msg = greet("Alice"); // msg is "Hello, Alice!"

Functions can have multiple return points:

function absoluteValue(int x) -> int {
  if (x >= 0) {
    return x;
  } else {
    return -x;
  }
}

Why pure functions? Because formulas need them. Since functions have no side effects, Adama can safely call them whenever dependencies change without worrying about unintended state modifications. The purity constraint is the price you pay for reactivity — and I think it's worth it.

Procedures (Can Mutate State)

Procedures can modify document state. Use them for operations that need to update fields, insert into tables, or perform other mutations.

int counter;

procedure increment() {
  counter++;
}

procedure addToCounter(int amount) {
  counter += amount;
}

Procedures can also return values:

int counter;

procedure incrementAndGet() -> int {
  counter++;
  return counter;
}

Choosing Between Functions and Procedures

Use Function When Use Procedure When
Computing a value from inputs Modifying document fields
Called from formulas Inserting/updating/deleting table rows
No side effects needed Sending messages or transitioning state
Want reactivity support Need to perform I/O operations
record Player {
  public int id;
  public double balance;
}
table<Player> players;

// Function: pure calculation
function calculateDiscount(int price, double rate) -> double {
  return price * rate;
}

// Procedure: modifies state
procedure applyDiscount(int playerId, double rate) {
  // Find and modify the player's balance
  if ((iterate players where id == playerId)[0] as player) {
    player.balance *= (1.0 - rate);
  }
}

The readonly Modifier for Procedures

Procedures can be marked readonly to indicate they do not modify state, while still having access to document state for reading:

int score;

procedure getCurrentScore() -> int readonly {
  return score;  // reads document field but doesn't modify it
}

This is useful when you need to read document state but want to enforce that no modifications occur. It's a middle ground between functions (which can't see document state at all) and regular procedures (which can mutate everything).

The aborts Modifier for Procedures

Procedures that may call abort (to cancel the current transaction and rollback all changes) must be annotated with aborts:

record Slot {
  public int id;
  public bool taken;
}
table<Slot> _slots;

procedure reserveSlot(int slotId) -> bool aborts {
  if ((iterate _slots where id == slotId)[0] as slot) {
    if (slot.taken) {
      abort;  // cancel the transaction
    }
    slot.taken = true;
    return true;
  }
  return false;
}

You can combine both modifiers: procedure check(int id) -> bool readonly aborts { ... }

The aborts annotation exists so the compiler can track which code paths might abort. If you call an aborting procedure from a channel handler, the compiler knows and can guarantee proper rollback semantics. It's a small annotation burden for a big safety win.

Previous Language
Next Types