CLEAR Language Walkthrough

· updated 2026-05-16

This guide showcases CLEAR: a memory-safe language that combines the ergonomics of scripting with the safety of affine types and the performance of hand-optimized C.

1. Immutability & Mutability

Bindings are immutable by default. Reassignment requires the MUTABLE keyword.

x = 5;                        # OKAY: Immutable binding (default)
name = "Alice";               # OKAY: Immutable string
pi = 3.14159;                 # OKAY: Immutable float

x = 6;                        # COMPILER ERROR: x is immutable

MUTABLE counter = 0;          # OKAY: Explicit mutability
counter = 1;                  # OKAY: can reassign mutable binding

2. Primitive Types

CLEAR provides a comprehensive set of primitives for precise control over memory.

TypeDescriptionExample
Int8 .. Int64Signed integers (8, 16, 32, 64-bit)42_i64, -1_i8
UInt8 .. UInt64Unsigned integers (8, 16, 32, 64-bit)42_u64, -1_u8
Float32, Float64IEEE-754 floating point3.14159_f64, 1.0_f32
BoolBoolean logicTRUE, FALSE
ByteRaw 8-bit data0x41_b
StringUTF-8 encoded text (affine)"hello"
VoidAbsence of a valueRETURN;

3. Types vs Capabilities

CLEAR distinguishes between what data is (Type) and how it is accessed (Capability). In Rust, capabilities like Arc, Rc, and Mutex are part of the type, leading to "function coloring" and high refactoring costs. In CLEAR, functions take Types, not Capabilities.

Capability Annotations

Capabilities are applied at the declaration site with @ suffixes:

CapabilityRust EquivalentCLEAR Syntax
Single-threaded shared ownershipRc<T>value @multiowned
Multi-threaded shared ownershipArc<T>value @shared
Mutex (exclusive lock)Arc<Mutex<T>>value @shared:locked
RwLock (read-write lock)Arc<RwLock<T>>value @shared:writeLocked
Heap pointerBox<T>value @indirect
Thread-local pointer(no direct equivalent)value @local

Zero Blast Radius Refactoring

In Rust, changing Rc<User> to Arc<User> means rewriting every function signature in the call chain. In CLEAR, functions take the plain type:

STRUCT User { name: String }

FN process(u: User) RETURNS Void ->
  print(u.name);
  RETURN;
END

FN main() RETURNS Void ->
  # At the call site, capabilities are unwrapped:
  shared_u = User{ name: "Alice" } @shared;
  WITH shared_u AS val { process(val); }
  RETURN;
END

If you change @shared to @multiowned, the process function remains untouched. The refactor is a one-line change at the declaration.

WITH Blocks for Capability Unwrapping

For multi-statement access, use WITH blocks:

MUTABLE counter: Int64@shared:locked = 0;

WITH EXCLUSIVE counter AS c {
    c += 1;
    print(c.toString());
}

4. Affine Ownership: GIVE & TAKES

CLEAR uses affine types by default. Every value has exactly one owner. When you assign a value, ownership is moved, not copied.

FN process(TAKES s: String) RETURNS Void ->
    print(s);
    # s is destroyed here (end of scope)
END

FN main() RETURNS Void ->
    msg = "Hello";

    process(msg);                   # OKAY: Implicit transfer (by FN signature)

    print(GIVE msg);                # COMPILER ERROR: USE AFTER MOVE: You can't GIVE `msg`.  `process(msg)` TOOK it away.
    print(msg);                     # COMPILER ERROR: USE AFTER MOVE: You can't use `msg`.  `process(msg)` TOOK it away.

    RETURN;
END

For more details, see docs/sharing-capabilities.md.

Parameter Ownership

Function parameters are implicit borrows by default. Only TAKES transfers ownership.

Param StyleOwnershipCan Move Into Container?Can Mutate?
v: ValueBorrowNoNo
MUTABLE v: ValueBorrowNoYes
TAKES v: ValueOwnedYesNo
TAKES MUTABLE v: ValueOwnedYesYes
UNION Value { Nil, Num: Float64, Lambda { body: Value @indirect } }

# Borrow: can read, cannot store or move
FN inspect(v: Value) RETURNS String ->
    MATCH v START
        Value.Num AS n -> RETURN n.toString();,
        DEFAULT -> RETURN "other";
    END
END

# Owned: can store into collections
FN store!(TAKES v: Value, MUTABLE map: HashMap<Value>) RETURNS Void ->
    map["item"] = v;                # OKAY: v is owned via TAKES
    RETURN;
END

FN bad!(v: Value, MUTABLE map: HashMap<Value>) RETURNS Void ->
    map["item"] = v;                # COMPILER ERROR: Cannot move borrowed value 'v'
    RETURN;
END

Zero implicit copies. Copy types (primitives, strings, enums) can be freely used after assignment. Non-Copy types (unions with @indirect or []T variants, structs with heap data) follow move semantics. There are never implicit deep copies.

5. Sharded Shared-Nothing Architecture

The @sharded capability partitions data across threads, enabling massive parallelism without lock contention by automatically pinning threads to specific data shards.

# A sharded map distributes keys across independent thread-local heaps
MUTABLE registry: HashMap<Int64>@sharded(8) = {};

# CLEAR automatically pins this fiber to the correct shard
BG {
    registry["key"] = 42;
}

# Sharding is also available for Pools and Lists
MUTABLE users: User[100]@pool:sharded(4) = [];
MUTABLE logs: String[]@list:sharded(2) = [];

# Note: Sharding provides peak throughput but carries a risk of
# data skew if keys/items are not uniformly distributed.

Note

Shared-nothing architectures present problems if your workloads can be heavily skewed.

6. Function Signatures

Functions support explicit types, failable returns (!T), and optional types (?T).

STRUCT Point { x: Float64, y: Float64 }

FN sum(p: Point) RETURNS Float64 ->
    RETURN p.x + p.y;
END

FN main() RETURNS Void ->
    p = Point{ x: 3.0, y: 4.0 };
    d = sum(p);
    RETURN;
END

Failable and optional returns:

FN findUser(id: Int64) RETURNS !?User ->
    IF id < 0 -> RAISE "Invalid ID";
    RETURN result;
END

Recursion

Recursive functions must be explicitly annotated with @reentrant:

FN fib(n: Int64) RETURNS Int64 @reentrant ->
    IF n <= 1 -> RETURN n;
    RETURN fib(n - 1) + fib(n - 2);
END

7. Basic Control Flow

CLEAR provides standard control flow constructs with support for one-line shorthands.

# 1. IF / ELSE_IF / ELSE
x = 75;
IF x > 100 THEN
    print("Large");
ELSE_IF x > 50 THEN
    print("Medium");
ELSE
    print("Small");
END

# 2. WHILE loops
MUTABLE i = 0;
WHILE i < 10 DO
    print(i.toString());
    i += 1;
END

# 3. FOR loops (Range iteration)
FOR j IN (1 ..= 5) -> print(j.toString());     # OKAY: Inclusive range
FOR j IN (1 ..< 5) -> print(j.toString());     # OKAY: Exclusive range

8. Enums, Unions, and Pattern Matching

Enums

Simple enumerations for discrete states:

ENUM Direction { North, South, East, West }

FN describe(d: Direction) RETURNS String ->
    MATCH d START
        Direction.North -> RETURN "up";,
        Direction.South -> RETURN "down";,
        Direction.East  -> RETURN "right";,
        Direction.West  -> RETURN "left";
    END
END

Tagged Unions (Sum Types)

Unions carry a payload per variant. Unit variants (no payload) are also supported:

UNION Result { Ok: Float64, Err: String, Empty }

FN main() RETURNS Void ->
    # Payload variant
    r = Result{ Ok: 42.0 };

    # Unit variant (no payload)
    e = Result.Empty;

    MATCH r START
        Result.Ok    -> print("success");,
        Result.Err   -> print("error");,
        Result.Empty -> print("nothing");
    END
    RETURN;
END

MATCH AS and Ownership

MATCH ... AS payload extraction follows the same ownership rules as parameters. The binding is a borrow of the source by default, or a move if the source is owned.

Source OwnershipMATCH AS ProducesCan Store in Struct?
Borrowed (parameter, map.get)BorrowNo
Owned (local, TAKES param)Move (consumes source)Yes
UNION Value { Nil, Num: Float64, List: Value[] }

# Borrowed source: MATCH AS produces a borrow
FN sum(v: Value) RETURNS Float64 ->
    MATCH v START
        Value.List AS items ->       # items is &[]Value (borrow)
            RETURN items.length();   # OKAY: reading a borrow
        DEFAULT -> RETURN 0.0;
    END
END

# Owned source: MATCH AS moves the payload
FN take!(TAKES v: Value) RETURNS Value[] ->
    MATCH v START
        Value.List AS items ->       # items is []Value (owned, v consumed)
            RETURN items;            # OKAY: returning owned data
        DEFAULT -> RETURN List[];
    END
END

Storing a borrowed MATCH AS binding into a struct or union variant is a compile error:

FN bad(items: Value[]) RETURNS Value ->
    # items is a borrowed parameter
    RETURN Value.Lambda{ params: items };  # COMPILER ERROR: Cannot store borrowed 'items'
END

Generics

Structs and unions support type parameters:

STRUCT Pair<T> { first: T, second: T }

p = Pair<Int64>{ first: 1, second: 2 };

9. Higher-Order Functions & Error Handling

CLEAR supports powerful functional pipelines via the Smooth operator |>.

# 1. Pipelines: Filter, Aggregate, Transform
alive = entities |> WHERE _.health > 0;
total = scores |> SUM _.value;
names = users |> SELECT _.name;

# 2. Side effects & Function Piping
entities |> EACH { _.x += _.vx; };
result = data |> process |> validate |> format;

# 3. Error Handling: Inline OR / OR RAISE
val = parseInt("abc") OR 0;                         # OKAY: Fallback value
content = readFile("config.json") OR RAISE;         # OKAY: Explicit propagation

# 4. Function-level CATCH
FN main() RETURNS Void ->
    result = loadConfig("config.json") OR RAISE;
    print("Config: ${result}");
    RETURN;
CATCH e
    print("Failed to load: ${e}");
    RETURN;
END

See docs/pipelines.md#operators for a full list of higher-order function operators.

Pipelines are automatically fused for efficiency.

10. Time as Tense (~T)

Tense represents a value that will exist in the future. CLEAR eliminates the complexity of Future/Promise/Observable with a single unified tense.

# 1. Promise (~T): A single future value
p: ~String = BG { sleep(100); RETURN "Data"; };
val = NEXT p;                                       # OKAY: Blocks until ready

# 2. Open Stream (~?T[]): Asynchronous generator
# NEXT on ~?T[] returns ?T, with NIL signaling exhaustion.
gen: ~?Int64[] = BG STREAM {
    YIELD 10;
    YIELD 20;
};
val = NEXT gen;                                     # OKAY: Returns ?Int64 (NIL when exhausted)

# 3. Infinite Stream (~T[INF]): Lazy rendezvous generator
counter: ~Int64[INF] = BG STREAM {
    MUTABLE i = 0;
    WHILE TRUE DO { YIELD i; i += 1; }
};
v1 = NEXT counter;                                  # OKAY: Returns Int64 (never NIL)

Stream Capabilities: @shared vs @split

Streams can also carry sharing capabilities:

source: ~?Int64[]@split = BG STREAM {
    YIELD 10;
    YIELD 20;
};

a: ~?Int64[]@split = CLONE source;
b: ~?Int64[]@split = CLONE source;

ASSERT NEXT a == 10, "first subscriber sees first value";
ASSERT NEXT b == 10, "second subscriber sees the same first value";
ASSERT NEXT a == 20, "order is preserved";
ASSERT NEXT b == 20, "order is preserved for all subscribers";

CLONE is mandatory for @split streams. Plain assignment moves the handle.

This is the model CLEAR intends for pub/sub style workloads. For a concrete benchmark in this area, see benchmarks/16_pubsub/bench.cht. That benchmark still uses the older sharing path today, but it is the right benchmark to watch as @split becomes the native pub/sub primitive.

11. Collections: Array, List, and Pool

All collections are automatically monomorphized -- the compiler generates zero-overhead, type-specific native code for every unique T.

SigilCollectionPurpose
T[N]ArrayFixed-size, stack-allocated (if small)
T[]@listListDynamic-size, heap-backed
T[N]@poolPoolFixed-capacity, generational handles
T[]@setSetDynamic-size, heap-backed, distinct items
T[]@ringRingCircular Ring Buffer (mainly for Streams - v0.2)
STRUCT User { name: String }

# 1. Fixed Array
vals = [10, 20, 30];

# 2. Dynamic List
MUTABLE items: Int64[]@list = [];
items.append(42);

# 3. Generational Pool
# Pools provide peak cache locality. Switching from List to @pool:soa
# (Structure of Arrays) is a one-line refactor for massive speed.
MUTABLE users: User[100]@pool = [];

id = users.insert(User{ name: "Alice" });           # Returns stable handle
user = users.get(id) OR RAISE;                      # Returns ?T (checks stale handles)

Element-Level Capabilities

Capabilities normally apply to the collection (T[]@shared = one shared list). For rare cases where each element needs its own capability, place the capability before the array suffix:

# Collection-level (common): one Arc wrapping the whole list
MUTABLE shared_list: User[]@list:shared = [];

# Element-level (rare): each element is individually Arc'd
MUTABLE arc_users: User@shared[]@list = [];

# Element-level with pool: each slot holds an Arc'd user
MUTABLE arc_pool: User@shared[100]@pool = [];

Element-level capabilities are primarily useful in struct fields where individual elements need independent lifetime management (e.g., a graph where each node is shared across multiple edges).

12. Strings and Buffers

Strings in CLEAR are Copy (like Rust's &str — a pointer + length). Assignment copies the slice header.

x = "hello";
y = x;                         # x is moved (strings are owned, non-Copy)
# z = x;                       # would be use-after-move error
z = COPY y;                    # OK: explicit deep-copy

# String concatenation
full = "foo" + "bar";          # "foobar" (single allocation, no intermediate)

Like arrays, Strings have capabilities:

13. Graphs, Cycles, and @indirect

For recursive or cyclic data structures, use @indirect (heap-allocated pointer, like Rust's Box<T>). Combined with @reentrant for recursive traversal:

# Recursive tree node using @indirect for child pointers
STRUCT Node {
    value: Int64,
    left: ?Node@indirect,     # Optional heap-allocated child
    right: ?Node@indirect
}

# Recursive traversal must be @reentrant
FN sumTree(n: Node) RETURNS Int64 @reentrant ->
    MUTABLE total = n.value;
    IF n.left -> total += sumTree(n?.left OR 0);
    IF n.right -> total += sumTree(n?.right OR 0);
    RETURN total;
END

@indirect gives the node a stable heap address, enabling graph structures. @multiowned (Rc) enables shared ownership for DAGs. For cyclic graphs, use @link -- CLEAR's weak reference.

?. is the safe navigate operator to peek into optional types. It combines with OR to handle missing data like an error.

@link creates a weak reference that does not keep the target alive. It works with both @multiowned (WeakRc) and @shared (WeakArc).

# Parent-child with back-pointer cycle
STRUCT Parent {
    name: String,
    child: ?Child@multiowned@indirect
}

STRUCT Child {
    name: String,
    parent: ?Parent@link          # weak back-pointer, breaks the cycle
}

FN main() RETURNS Void ->
    p = Parent{ name: "Alice", child: NIL } @multiowned;

    # LINK downgrades the strong Rc to a WeakRc
    weak_p = LINK p;

    # RESOLVE returns ?Parent@multiowned; bind first, then use ?. to safely access fields
    resolved = RESOLVE weak_p;
    name = resolved?.name OR "dropped";
    ASSERT name == "Alice", "resolved";

    RETURN;
END

Key rules:

14. Concurrency: BG & DO

CLEAR makes background tasks and fork-join parallelism trivial.

# BG: Background execution
p: ~Int64 = BG { RETURN slowComputation(); };

# DO: Fork-Join parallel execution
DO {
    step1(),
    step2()
}

# CONCURRENT: Parallel pipelines
results = items |> CONCURRENT(workers: 8) SELECT transform(_);

BG Stack Modifiers

ModifierStack SizeUse
@micro4 KBLightweight tasks
@standard16 KBDefault
@large64 KBFile I/O, deep recursion
@xl256 KBVery deep call stacks
@pinned(any)Pin to local scheduler
@arena(any)Thread-local arena; implies @pinned

15. Modules & Imports

CLEAR uses a simple namespace-based module system via REQUIRE.

REQUIRE "math_utils.cht" AS m;                      # Local file alias
REQUIRE "pkg:geometry";                             # Package import

FN main() RETURNS Void ->
    p = geometry.Point{ x: 1, y: 2 };
    print(m.square(10).toString());
    RETURN;
END

See docs/modules.md for visibility (PUB/PRIVATE) and build details.

16. FFI: Native Integration

CLEAR integrates directly with Zig and C libraries via EXTERN declarations. All EXTERN FN calls automatically trampoline to the OS stack (g0) for fiber safety.

Importing Functions and Types

# Import a struct type from a Zig module
EXTERN STRUCT Vec2 { x: Float64, y: Float64 } FROM "math_native";

# Import a free function
EXTERN FN computeDistance(v: Vec2) RETURNS Float64 FROM "math_native";

# Import with allocator injection (EFFECTS)
EXTERN FN parseJson(data: String) RETURNS !JsonDoc
    EFFECTS :alloc:heap FROM "json_native";

Local Struct Definitions (no FROM)

For passing default options or defining Zig-compatible layouts:

# Local struct (no external module)
EXTERN STRUCT ParseOptions {};
EXTERN STRUCT JsonRecord { id: Int64, data: Int64[] };

# Comptime type parameters for generic FFI
EXTERN FN parseFromSliceLeaky<T>(comptime: T, content: String, options: ParseOptions)
    RETURNS !T EFFECTS :alloc:heap FROM "std.json";

Method Calls on EXTERN Structs

EXTERN STRUCT Dir {} FROM "std.fs";
EXTERN FN cwd() RETURNS Dir FROM "std.fs";
EXTERN FN Dir.makePath(self: Dir, path: String) RETURNS Void FROM "std.fs";

# Chained method call (both trampolined to g0)
cwd().makePath("data");

EXTERN STRUCT CLOSE (RAII)

EXTERN STRUCT Buffer { data: String }
    CLOSE "deinit" FROM "native_resource";

# Buffer.deinit() is auto-called when buf goes out of scope
buf = createBuffer("hello");
# defer buf.deinit() emitted automatically

17. Memory Model

Arena Allocation

Every function has its own memory arena. Variables live as long as the function they were born in.

Escaping the Arena

Loop Arena Rewind

Inside loops, the frame arena is rewound each iteration to prevent unbounded growth. Mutable variables that accumulate across iterations (like string concatenation) are automatically detected and excluded from the rewind.

CLEAR additionally inserts cooperative yielding into loops to prevent head-of-line blocking, etc.

This destroys SIMD optimizations. CLEAR can detect when a SIMD optimization is possible and warn you to use TIGHT loops:

TIGHT FOR i IN (1..<1000) -> ...

CLEAR will also warn you when you're using a TIGHT loop and you should not.

Note

Misusing TIGHT loops can destroy your p99 latency.

18. The Reality of Concurrency

In an ideal world, every workload would distribute perfectly across available cores and memory. In reality, concurrency is difficult because of the "messy middle":

  1. Skew: Data is rarely uniform. Sharded collections can develop "hot shards" that bottleneck the system.
  2. Outliers: The 0.1% of workers that take 100x longer than typical (due to cache misses or deep recursion) destroy p99 response times.
  3. Non-Cooperation: Problems like head-of-line blocking and thundering herds can stall an entire thread pool.
  4. Variadic Depth: Real-world recursion and loops often exceed the fixed stack sizes of traditional fibers.

CLEAR handles these issues through its Control Plane -- an active runtime observer that uses live telemetry to manage execution.

See docs/control-plane.md for more on how CLEAR manages the reality of high-performance systems.

19. Function Lifetimes

Functions cannot return borrowed data freely, but can with a lifetime. This is simplified from Rust.

STRUCT Node { left: ?Node, right: ?Node }

FN grandChild(n: Node) RETURNS n.left:?Node ->
  RETURN n.left?.right;
END

Lifetimes are scoped with <param>.<path>:Type. In the example above, n.left is the path and ?Node is the return type.

This is a less restrictive lifetime than r:Bar.

Standard IMMUTABLE returned borrows just work:

bar = root.identity();
print(bar.index);

MUTABLE returned borrows require a scope - because the object you're borrowing from is restricted while you hold the borrow to prevent use-after-free:

WITH RESTRICT node AS n {
  MUTABLE gc = n.grandChild();
  gc.value = 0;
  node.left = Nil; # COMPIILER ERROR: `node` is RESTRICTED.  It's immutable in this scope.
  n.left = Nil;    # COMPIILER ERROR: `n` is RESTRICTED.  It's immutable in this scope.
}

20. STRUCT lifetimes

It's sometimes useful to define a STRUCT with a borrowed field (iterators are a prime example):

STRUCT SliceIter {
    source: BORROWED Int64[], # Allows `source` to be a BORROW, must be initialized in a scope.
    pos: Int64,
    len: Int64
}

FN hasNext(iter: SliceIter) RETURNS Bool ->
    RETURN iter.pos < iter.len;
END

FN iterExample() RETURNS Void ->
  WITH BORROWED data AS ref { # create a borrowed version
    MUTABLE iter = SliceIter{ source: ref, pos: 0, len: n };
    WHILE hasNext(iter) DO
      total += currentVal(iter);
      iter = advance(iter);
    END
    RETURN iter;       # COMPILER ERROR: You can't return `iter`.  It's BORROWED.  You don't own it.
  }
END

Quick Reference: Capabilities

AnnotationPurpose
@multiownedSingle-threaded shared ownership (Rc)
@sharedMulti-threaded shared ownership (Arc)
@shared:lockedArc + Mutex
@shared:writeLockedArc + RwLock
@lockedMutex (single-scheduler)
@writeLockedRwLock (single-scheduler)
@localThread-local heap pointer
@indirectExplicit heap allocation (Box)
@linkTo create cyclic graphs (WeakRef/Ref)
@sharded(N)Shared-nothing partitioned across N shards

Quick Reference: Sigils

SigilMeaningExample
@Capability / pipeline bindingvalue @shared, |> process AS @p
!Mutation suffixFN increment!(...)
|>Smooth operator (pipeline)items |> WHERE _ > 5
_Pipeline element placeholder|> SELECT _.name
!TError union typeRETURNS !Float64
?TOptional typeRETURNS ?User
~TPromise / stream typep: ~Int64 = BG { 42; }

Integer Overflow Operators

CLEAR uses fixed-width integers (Int64, Int32, etc.) that can overflow. Three tiers of arithmetic operators control what happens on overflow:

Default: +, -, *

Panics on overflow in debug builds, wraps silently in release builds. This matches Rust's semantics - catches accidental overflow during development, zero overhead in production.

a: Int64 = 9223372036854775807_i64;
b = a + 1_i64;  # debug: PANIC!  release: wraps to -9223372036854775808

Wrapping: %+, %-, %*

Always wraps on overflow in all build modes. Use for hash functions, random number generators, checksums, and any code that intentionally overflows.

# LCG random number generator (intentional overflow)
MUTABLE state: Int64 = seed;
state = state %* 6364136223846793005_i64 %+ 1442695040888963407_i64;

# Max + 1 wraps to min
max: Int64 = 9223372036854775807_i64;
min = max %+ 1_i64;  # always -9223372036854775808, never panics

Checked: !+, !-, !*

Always panics on overflow in all build modes, including release. Use for financial calculations, safety-critical code, and anywhere overflow indicates a logic error.

# Financial calculation: overflow means a bug, even in production
balance = balance !+ deposit;  # panics if result exceeds Int64 range
total = quantity !* price;     # panics on overflow, even in release

Summary

OperatorDebug buildRelease buildUse case
+, -, *panicwrapGeneral arithmetic
%+, %-, %*wrapwrapHash, RNG, checksum
!+, !-, !*panicpanicFinancial, safety

Float arithmetic (Float64) is unaffected - IEEE 754 handles overflow via infinity/NaN.

Source: docs/WALKTHROUGH.md