Overview
Conjure is a statically typed, compiled language designed for games and systems programming. It prioritizes fast compilation, predictable performance, and straightforward C interop. The language sits close to C in its memory model (explicit allocation, no garbage collector) while providing modern ergonomics: type inference, UFCS method syntax, built-in dynamic arrays, pattern matching, string interpolation, and compile-time execution.
Conjure’s official toolchain currently compiles to C as a portable representation, allowing it to target any platform with a C compiler. The compiler is designed for whole-program compilation with incremental analysis, and the same frontend powers both the build tool and the language server. Conjure’s toolchain may still employ a linear IR before lowering that to C (and eventually to LLVM and native code in the future).
Design principles:
- Structs are data. Behavior lives in free functions.
- Conjure promotes data-driven programming with batch allocations and cache efficiency over object encapsulation and dynamic dispatch.
- One way to do things. If two features overlap, pick the simpler one.
- Diagnostic messages are designed to teach users how the language works (and programming in general), not just report errors.
- Cross-platform by default. Platform-specific code is explicit and conditional.
Types
Primitive types
| Type | Kind | Size | Notes |
|---|---|---|---|
i8, i16, i32, i64 | Signed int | 1/2/4/8 | i32 is the default integer type |
u8, u16, u32, u64 | Unsigned int | 1/2/4/8 | |
isize, usize | Platform int | 4/8 | Word-sized; 4 bytes on 32-bit targets, 8 bytes on 64-bit |
f32, f64 | Float | 4/8 | |
bool | Boolean | 1 | true / false literals |
string | String | 16 | UTF-8, tagged ownership (see below) |
cstring | C string | 8 | Alias for *u8, NUL-terminated (C interop) |
rawptr | Raw pointer | 8 | Alias for *u8; explicit cast required to typed ptrs |
void | Unit | 0 | Return type only |
never | Bottom | 0 | Return type for diverging functions; has no values |
String type
string is a built-in value type representing an immutable UTF-8 byte sequence. On 64-bit targets it is 16 bytes; on 32-bit targets it is 12 bytes (the pointer field is 4 bytes). The layout is a 2-bit ownership tag, a 62-bit byte length, and a pointer to the data.
Layout: { tag u8 : 2, len u64 : 62, data rawptr }Ownership tags:
String literals contain a 2-bit tag that indicates the ownership and memory management strategy tied to that string’s data pointer. The tag determines how the string’s memory is allocated, whether it is NUL-terminated, and what operations are valid on it. The compiler (and debug runtime) uses the tag to enforce safety rules and optimize common cases.
| Tag | Name | Meaning |
|---|---|---|
| 0 | literal | Points into static .rodata. NUL-terminated. Never freed. |
| 1 | heap | Heap-allocated via alloc. NUL-terminated (allocations include a trailing NUL byte after len bytes). Must be freed. |
| 2 | borrowed | Suffix view ending at the source string’s NUL terminator (inherits NUL termination). Created by s.suffix(start) or s[a:]. |
| 3 | substring | Interior view that does not reach the source’s end. Not NUL-terminated. Created by s.prefix(end) or s[a:b] where b < s.len. |
String literals in source code ("hello") produce tag-0 strings that point directly into the binary’s read-only data section. They are NUL-terminated, never freed, and have static lifetime. The empty string literal "" has a null data pointer and length 0. This is identical to the zero-initialized state of a string, so var s string and var s = "" produce the same value.
Heap strings (tag 1) are created by string.copy(), string interpolation, or any operation that produces a new string at runtime. Heap allocations include a trailing NUL byte after len bytes, so they are always NUL-terminated. The caller owns the allocation and is responsible for freeing it. There is no automatic reference counting or garbage collection on strings.
Borrowed strings (tag 2) are suffix views into another string’s data, created by s.suffix(start) or suffix slicing (s[a:]). Because they extend to the end of the source string, they inherit the source’s NUL terminator. The backing data must outlive the borrowed string.
Substrings (tag 3) are interior views that do not reach the end of the source string, created by s.prefix(end) or interior slicing (s[a:b] where b < s.len). They are not NUL-terminated. The backing data must outlive the substring.
The compiler determines tags statically when possible. s[a:] and s.suffix(n) produce tag 2 (borrowed). s[a:b] where b is statically less than s.len produces tag 3 (substring). When the relationship between b and s.len is not statically known, the compiler defaults to tag 3 as the conservative choice.
Built-in operations:
var s = "hello"
var n = s.len // 5 (read-only property, u64)
var b = s[2] // byte at index 2 (u8)
var c = s.cstr() // *u8 pointer for C interop (tags 0, 1, 2 only)
var t = s.toCstring() // NUL-terminated heap copy (any tag, caller must free).cstr() returns the underlying *u8 pointer directly, with no allocation. It is available on tags 0, 1, and 2 because all three are NUL-terminated. Calling .cstr() on a value statically known to be tag 3 (substring) is a compile error. When the tag is not statically known, debug builds insert a runtime check that panics if the tag is 3; release builds skip the check.
1 | var c = sub.cstr() | ^^^^^^^^^^ Cannot call '.cstr()' on a substring. Substrings are interior views and are not NUL-terminated. Use '.toCstring()' to get a NUL-terminated heap copy.
.toCstring() returns a NUL-terminated heap copy of the string data. Available on any tag. The caller owns the returned allocation.
Equality:
Two strings are equal when they have the same length and identical bytes. The ownership tag is not considered. == and != perform byte comparison.
var a = "hello"
var ptr = [5]u8{0x68, 0x65, 0x6C, 0x6C, 0x6F} // same bytes as "hello"
var b = string.copy(ptr, 5) // heap copy of same bytes
if a == b { ... } // true: same contentSlicing:
Strings support slicing to create borrowed or substring views. The tag of the result depends on whether the slice reaches the end of the source string:
var s = "hello"
var tail = s[1:] // "ello", tag 2 (borrowed, inherits NUL terminator)
var sub = s[1:4] // "ell", tag 3 (substring, not NUL-terminated)Iteration:
for c in s iterates UTF-8 code points as u32 values. Use .bytes() for a [:]u8 byte-level view:
var s = "héllo"
for c in s { ... } // c is u32: 'h', 'é', 'l', 'l', 'o'
for b in s.bytes() { ... } // b is u8: raw bytesByte indexing and character access:
Strings support indexing by byte position. Use string.charAt() via UFCS for UTF-8-aware character access, which returns a u32 code point:
var s = "héllo" // 6 bytes: 'h', 'é' (2 bytes), 'l', 'l', 'o'
var c1 = s[0] // 'h' (0x68)
var c2 = s[1] // 'é' byte 1 (0xC3)
var c3 = s[2] // 'é' byte 2 (0xA9)
var c4 = s.charAt(1) // 'é' as u32 code point (0xE9)1 | s[0] = 'H' | ^^^^ Cannot assign to a string's bytes directly. Strings are immutable. To build a string from parts, use `strings.Builder` to create a new string.
Compound types
| Type | Syntax | Size | Notes |
|---|---|---|---|
| Typed pointer | *T | 8 | Single-item pointer, non-null, supports auto-deref |
| Reference | &T | 8 | Borrowed, mutable, non-null, auto-deref |
| Optional | ?T | varies | Nullable wrapper; ?*T and ?&T are pointer-sized, values receive a tag byte |
| Fixed array | [N]T | N * sizeof(T) | Value type, bounds-checked |
| Dynamic array | []T | 24 | Owned, growable (data + len + cap) |
| Slice | [:]T | 16 | Borrowed view (data + len), not growable |
| Struct | struct { fields } | sum of fields | Named, data-only (no methods inside) |
| Enum | enum { variants } | backing int | Backed by integer type (default i32) |
| Data enum | enum { Variant Type } | tag + max | Tagged union with per-variant payloads |
| Union | union { fields } | max field | Untagged, all fields at offset 0 |
| Function | func(T...) R | 8 | First-class function pointer |
| Generator | gen[T] | varies | Stackless coroutine yielding values of type T (see Generators section) |
Container types
Conjure has three contiguous container types with distinct ownership semantics.
[N]T fixed-size array
Value type with size known at compile time. Stored inline (stack or struct field). No heap allocation. Bounds-checked in debug builds. If array is too large to fit on the stack, the compiler emits an error and suggests using heap allocation via var arr = try alloc([N]T{}) instead; auto-free releases the allocation at scope exit.
var nums = [5]i32{1, 2, 3, 4, 5}
var first = nums[0] // 1
nums[2] = 99
for n in nums { ... } // iterate values
for i, n in nums { ... } // iterate with indexArray initialization forms:
var a = [3]i32{10, 20, 30} // explicit size, positional values
var b [4]bool // zero-initialized (all false)
var c = [...]i32{10, 20, 30} // inferred size from initializer (becomes [3]i32)The [...]T{...} form infers the array length from the number of initializer elements. This is useful when the length is determined by the data rather than by contract, such as lookup tables that track an enum’s variants.
Array literals support designated initializers that assign values to specific indices using constant expressions in brackets. Unspecified positions are zero-initialized. Designated and positional initializers can be mixed, but each index can only be assigned once:
var d = [...]string{
[Kind.Illegal] = "illegal",
[Kind.EOF] = "eof",
[Kind.Ident] = "ident",
}
var e = [8]i32{
[0] = 100,
[7] = 200, // indices 1-6 are zero-initialized
}For [...]T with designated initializers, the inferred length is one greater than the highest designated index. The designator expression must be a compile-time constant (a literal, a const value, or an enum variant).
[:]T slice
Borrowed view into existing contiguous memory: a (data, len) pair. Not growable. Created by slicing an array, dynamic array, or another slice. Mutation through a slice is visible in the original. Does not own its backing storage; the backing must outlive the slice. Attempts to free() a slice results in a compile-time error.
var arr = [5]u8{0, 1, 2, 3, 4}
var s [:]u8 = arr[1:4] // s = {1, 2, 3}, points into arr
s[0] = 10 // arr is now {0, 10, 2, 3, 4}[]T dynamic array
Owned, growable contiguous array: a (data, len, cap) triple. The backing buffer is heap-allocated on demand. A freshly declared var items []i32 has a null backing pointer with len = 0 and cap = 0. The first mutation that requires storage (such as .append()) allocates the backing buffer. Despite the nullable backing pointer, []T itself cannot be assigned null and is not an optional type. It is always a valid (possibly empty) dynamic array. Supports .append(), .popFront(), .pop(), .insert(), .remove(), etc. The .len and .cap properties are read-only.
var items = []i32{1, 2, 3} // heap-allocated, len=3, cap>=3
items.append(4) // grow if needed
items.pop() // remove last element
var n = items.len // 3
items.free() // release backing storage (just UFCS free(items))Dynamic arrays are a compiler-built-in type. The compiler generates type-erased runtime code for growth and manipulation, with a thin typed wrapper that provides compile-time type safety. This avoids monomorphization bloat while maintaining full type checking.
Dynamic arrays can be created directly from fixed arrays or slices, though this requires a heap allocation and copy (TBD: syntax not finalized).
Built-in methods on []T:
| Method | Description |
|---|---|
.append(value T) | Append a value, growing if needed |
.append(...values T) | Append multiple values (with single re-allocation if needed) |
.pop() T | Remove and return the last element |
.popFront() T | Remove and return the first element |
.insert(index u32, value T) | Insert at index, shifting elements right |
.remove(index u32) T | Remove at index, shifting elements left |
.clear() | Remove all elements, keeping capacity |
.resize(newLen u32) | Resize the array, growing and truncating if needed |
.reserve(additional u32) | Ensure capacity for at least len + additional elements |
.len (property) | Number of elements (read-only) |
.cap (property) | Current capacity (read-only) |
.sort() | In-place sort using natural ordering |
.contains(value T) bool | Linear search for value |
.reverse() | Reverse elements in-place |
.fill(value T) | Set all existing elements to value |
Because slices and dynamic arrays look similar, the compiler provides specific diagnostics to help users understand the difference:
1 | mySlice.append(42) | ^^^^^^^^^^^^^^^^^^ Cannot append to a slice. Slices are borrowed views and cannot grow. If you need a growable container, use a dynamic array `[]T` instead.
Bounds checks should be performed at compile-time when possible. Otherwise, they only exist in debug builds unless the user opts in to bounds checks in release builds via the --always-check-bounds CLI option or alwaysCheckBounds in the config.
1 | nums[10] = 0 | ^^^^^^^^ Index 10 is out of bounds for array of length 5.
Untyped literals and uint, int, float
Numeric literals in Conjure are untyped by default. An untyped integer literal has the abstract type uint (if non-negative) or int (if negative). An untyped floating-point literal has the abstract type float. These are not keywords and cannot be used as type annotations. They exist only as the compiler’s internal representation of a literal that has not yet been assigned a concrete type.
When the assignment context provides a concrete type, the literal adapts to that type:
var x i32 = 255 // literal adapts to i32
var y f32 = 3.14 // literal adapts to f32
var z u64 = 1000 // literal adapts to u64When no type context is available, the compiler selects the smallest concrete type that fits the value:
var x = 255infersu8.var x = 256infersu16.var x = 65536infersu32.var y = -120infersi8.var y = -1infersi8.var z = 200 + 200infersu16(folded to 400 at compile time).var w = 3.14infersf64(float literals default tof64).
This allows concise code when the specific size is not important, while still providing the option to use explicit types when needed. The abstract literal types also participate in type checking: passing uint to a function expecting i32 automatically narrows the literal, but passing int (negative) to a function expecting u32 produces a compile error.
Special literals
42 // integer (decimal)
0xFF // hexadecimal
0b1010 // binary
0o777 // octal
3.14 // float (f64)
1.5e-10 // scientific notation
'A' // character literal (u8)
'é' // UTF-8 character literal (u32)
"hello" // string literal
"${expr}" // string interpolation
true false // boolean
null // null pointer
Infinity // positive infinity (float)
-Infinity // negative infinity (float)
NaN // not-a-number (float)Never type
never is the bottom type: a type with zero values. No expression produces a never, and no value of type never exists at runtime.
The compiler treats never as a subtype of every other type. A function returning never is type-compatible at any call site, which allows diverging branches to compose with surrounding code:
func parse(s string) i32 {
if s.len == 0 {
panic("Empty input.", 0) // type is `never`, compatible with `i32`
}
return strconv.parseInt(s)
}Functions that always diverge declare never as their return type:
func panic(msg cstring, code u32 = 0) never
func exit(status i32) neverThe following expressions have type never because they unconditionally transfer control away from the current evaluation point:
- Calls to functions returning
never(panic(...),exit(...)). unreachable(keyword; seepanic,assert, andunreachable).return(with or without a value).breakandcontinue.- Expressions the compiler proves diverge (infinite loops with no
break).
Because never is a subtype of every type, these expressions are valid anywhere a value is expected. This is what allows diverging branches to compose naturally with surrounding code (see Optionals for the else operator, where this property is especially useful).
Restrictions:
- No literal expression has type
never. Only the forms listed above producenever-typed expressions. nevercannot appear as the type of avarorconstwhose initializer yields a value.- Casting a value to
neveris rejected. Casting fromneverto any type is vacuously valid.
Optionals
?T wraps any type to indicate it may be absent. The zero value of ?T is null.
For pointer types (?*T, ?&T), the optional is pointer-sized because the null bit pattern represents absence. For value types (?i32, ?bool), the optional adds a boolean tag byte. For nested optionals (??T), each layer adds a tag byte.
Optional values must be unwrapped before use. Conjure provides several mechanisms for unwrapping.
else operator (null coalescing)
The else infix operator unwraps an optional, providing a fallback value when the optional is null:
var name = user.nickname else user.firstName
var port = config.get("port") else 8080
var node = findNode(name) else Node{}The type rule is that ?T else T produces T. The right-hand side must be assignable to T (the unwrapped type). The right-hand side is only evaluated when the left-hand side is null (short-circuit evaluation).
else binds lower than all other operators except assignment. It is right-associative, which allows chaining:
// Right-associative: parses as lookupA() else (lookupB() else defaultValue)
// lookupB() else defaultValue → T, then lookupA() else T → T
var x = lookupA() else lookupB() else defaultValueBecause never is a subtype of every type, diverging expressions like panic(...), return, break, and continue are valid on the right-hand side of else. This produces unwrap-or-diverge patterns:
var cfg = loadConfig(path) else panic("Missing config at ${path}.")
var node = findNode(name) else return null
var item = queue.pop() else break
var port = config.get("port") else return .Err("Missing 'port' in config.")In each case, the result type is T (the unwrapped optional) because never satisfies the T requirement.
1 | var x = count else 0 | ^^^^^ 'else' operator requires an optional type on the left-hand side, but found 'i32'. The 'else' operator unwraps optional types (?T) and provides a fallback value when null.
1 | var x = name else 42 | ^^ Type mismatch in 'else' expression. The left-hand side is '?string' (unwraps to 'string'), but the right-hand side is 'i32'.
! operator (force unwrap)
The ! postfix operator unwraps an optional, panicking at runtime if the value is null:
var name = user.nickname! // panics if null
var n = generator()! // unwrap ?u32 from generator! is equivalent to expr else panic("..."). The compiler generates a panic message that includes the source location and the expression text. In release builds, the panic is a trap instruction.
1 | var n = ptr! | ^ Force-unwrap '!' requires an optional type, but found '*Node'. '*Node' is already non-null. Remove the '!' operator.
?. operator (optional chaining) (PROPOSED)
if narrowing
When an optional is used as the condition of an if statement, the compiler narrows the type to the unwrapped type inside the truthy branch. In the else branch, the value remains null:
var node ?*Node = findNode(name)
if node {
// node is narrowed to *Node here
node.process()
} else {
// node is null here
println("not found")
}if narrowing is the recommended approach when multiple operations depend on the same optional being non-null, since the narrowed type inside the block is non-optional and requires no further unwrapping.
Optional truthiness is built into the language for ?T types only. Non-boolean, non-optional types cannot be used as conditions (use explicit comparisons like x != 0 instead).
Variables and Constants
var Declarations
var introduces a mutable binding. It accepts an optional explicit type and an optional initializer. When the latter is omitted (just var name Type), the variable is zero-initialized to the type’s default value. When an initializer is provided without a type annotation, the type is inferred from the expression.
var x i32 = 0 // explicit type + initializer
var name = "Conjure" // type inferred from initializer
var flag bool // zero-initialized (false)
var counts [4]i32 // zero-initialized array
var p ?*Point = null // explicit optional pointer, initialized to nullZero-initialization defaults by type:
| Type category | Zero value |
|---|---|
| Integer | 0 |
| Float | 0.0 |
| Bool | false |
| String | "" (empty string) |
?T | null |
*T | Non-nullable; initializer required (see Pointer types) |
?*T, ?&T | null |
rawptr | null |
| Slice | { ptr = null, len = 0 } |
| Dynamic array | { ptr = null, len = 0, cap = 0 } |
| Struct | All fields zeroed |
| Array | All elements zeroed |
| Enum | First variant (0) |
A var without a type annotation requires an initializer so the compiler can infer the type. Omitting both is a compile error:
var x // error: cannot infer type without an initializer or type annotationconst Declarations
const introduces an immutable binding whose value is fixed at compile time. The initializer is required and must be a compile-time constant expression (literals, arithmetic on literals, other const values, embed, or built-in constants like OS). Like var, the type annotation is optional when it can be inferred.
const PI f32 = 3.14159 // explicit type
const MAX = 100 // type inferred (u8)
const GREETING = "hello" // string literal
const MASK = 0xFF << 8 // constant folded to 0xFF00const is also used for type aliases and symbol aliases:
const FileDesc = i32 // type alias
const print = io.print // symbol alias (binds a name to an imported function)
const entityPool = pool[Entity] // template module instantiation aliasAssigning to a const binding is a compile error. const values that are simple scalars or string literals lower to the equivalent of static const in C and occupy no runtime storage beyond read-only data.
Type aliases
A const declaration whose right-hand side is a type expression creates a type alias:
const FileDesc = i32
const ReadFunc = func(state rawptr, dst [:]u8) isizeType aliases are fully interchangeable with the original type. They exist for readability and do not create distinct types.
Top-level (module) variables
Variables and constants declared at the top level of a module (outside any function body) are module-scoped globals. They are visible to all functions in the module, and to importers unless the name starts with _.
// file: game.cjr
var score i32 = 0 // public global, mutable
var _frameCount u64 = 0 // private global (underscore prefix)
const MAX_ENTITIES = 1024 // public constantInitialization order
Top-level var initializers can execute arbitrary code and are sorted into an initialization phase that runs before main. The order is based on source order of the declarations within a module, and the compiler ensures that all dependencies are initialized before a variable’s initializer runs (otherwise it reports a circular dependency error).
Within a single module, top-level variables are initialized in source order (top to bottom). Across modules, initialization follows import order: a module’s globals are initialized before the globals of any module that imports it. This means a module can rely on its dependencies’ globals being initialized by the time its own initializers run, but it cannot depend on the initialization state of modules that import it.
For the entry-point module (the one containing main), all transitively imported modules are initialized first, in depth-first import order. If module A imports B and C (in that order), and B imports D, the initialization order is: D, B, C, A.
Circular imports do not affect initialization order. When two modules import each other, each module’s globals are initialized exactly once. The compiler breaks the cycle by ensuring that whichever module is reached first in the depth-first traversal initializes first; the other module sees the already-initialized globals when it runs.
Mutable globals and thread safety
Top-level var bindings are shared mutable state. In multi-threaded programs, concurrent access to mutable globals must be synchronized by the programmer (e.g. with mutexes or atomics). The compiler does not insert synchronization automatically.
Fallible initialization
Top-level var initializers must not have type !T. Module-scope initialization runs before main and has no try context to propagate faults to. If initialization may fail, force-unwrap with ! to panic on failure at startup (var db = openDb()!), or declare the variable as ?T and initialize it lazily. Top-level initializers that panic abort the process before main runs.
Compile-time constants
The following constants are always available without imports:
| Name | Type | Description |
|---|---|---|
OS | string | "linux", "darwin", "windows", etc. |
ARCH | string | "x86_64", "aarch64", etc. |
DEBUG | bool | true in debug builds, false in release |
The standard library (std/runtime) provides convenience boolean constants for common platform checks:
const IS_LINUX = OS == "linux"
const IS_MACOS = OS == "darwin"
const IS_WINDOWS = OS == "windows"
const IS_X86_64 = ARCH == "x86_64"
const IS_ARM64 = ARCH == "aarch64"
const IS_64_BIT = PTR_SIZE == 8These are compile-time evaluated and the compiler eliminates dead branches gated on them.
Other compile-time constants, called “build variables”, can be defined via the CLI or config file and are accessible as const values through extern "$<name>":
// Define a build variable via CLI: --define API_LEVEL=30
extern "$API_LEVEL" const API_LEVEL = 0 // API_LEVEL is now a compile-time constantOnly string, integer, and boolean constants are supported as build variables. If a build variable is not defined, the constant’s default value is used instead. extern constants can be initialized without an initializer, in which case they default to zero (for integers), empty string (for strings), or false (for booleans).
Visibility
Underscore prefix makes symbols private to the module:
var _internal = 42 // private to this file
func _helper() { ... } // private to this filePublic symbols (no underscore prefix) are visible to any module that imports this one.
Variable shadowing
Redeclaring a variable in the same scope is an error:
var x = 1
var x = "now a string" // error1 | var x = "now a string" | ^ Cannot redeclare 'x' in the same scope. The previous declaration at line 1 is still in scope.
Shadowing a variable from an outer scope is allowed but produces a warning:
var x = 1
if true {
var x = "inner" // warning: shadows outer x
}1 | var x = "inner" | ^ Declaration of 'x' shadows a variable from an outer scope. The outer variable declared at line 1 is hidden within this block. Consider using a different name to avoid confusion.
Shadowing a language built-in name (string, alloc, free, print, etc.) produces a warning:
var string = "oops"1 | var string = "oops" | ^^^^^^ Declaration of 'string' shadows the built-in type name 'string'. This may cause confusing errors elsewhere in this scope.
Distinct types
The distinct keyword creates a new type that has the same representation as an existing type but is not interchangeable with it. This prevents accidental mixing of semantically different values that share a representation:
distinct UserId = u32
distinct GroupId = u32
var user UserId = 42
var group GroupId = 7
// user = group // error: cannot assign GroupId to UserId
user = group.(UserId) // ok: explicit castDistinct types inherit the operations of their underlying type (arithmetic, comparison, etc.) but are type-checked separately. Operations on distinct types produce values of the distinct type: UserId + UserId produces UserId, not u32. Mixing a distinct type with its underlying type in an expression requires an explicit cast in either direction. Comparisons between values of the same distinct type produce bool. Distinct types are useful for ID types, unit types (pixels vs meters), and any domain where two values with the same representation should not be mixed.
distinct UserId = u32
var a UserId = 10
var b UserId = 20
var c = a + b // UserId (30)
var d = a + 5 // ok: untyped literal adapts to UserId
// var e = a + someU32 // error: cannot mix UserId and u32
var f = a + someU32.(UserId) // ok: explicit cast
if a == b { ... } // ok: produces bool1 | var e = a + someU32 | ^^^^^^^ Cannot mix distinct type 'UserId' with its underlying type 'u32' in arithmetic. Cast one operand to match: 'someU32.(UserId)' or 'a.(u32)'.
Distinct types also support UFCS. Functions declared in the same module whose first parameter is the distinct type can be called with method syntax:
distinct UserId = u32
func isAdmin(id UserId) bool { ... }
var user UserId = 42
if user.isAdmin() { ... } // UFCS call to isAdmin(user)1 | user = group | ^^^^ Cannot assign a value of type 'GroupId' to a variable of type 'UserId'. These are distinct types even though both are backed by 'u32'. Use an explicit cast if this is intentional: value.(UserId)
Functions and UFCS
Conjure functions are free-standing declarations. There are no methods inside type bodies. Behavior is associated with types through two mechanisms: UFCS (Uniform Function Call Syntax) and type-namespaced functions.
Function declarations
func add(a i32, b i32) i32 {
return a + b
}
func noop() { } // void return
func greet(name string, greeting string = "Hi") // default parameter
func sum(...values i32) i32 // variadic parameter
func swap(a, b f32) // shorthand same-type paramsParameters are declared as name Type. Multiple parameters of the same type can share a type annotation: func f(x, y, z f32).
Default parameters use = value after the type. Only trailing parameters may have defaults. When calling the function, arguments for defaulted parameters can be omitted. Default parameters must be compile-time constants.
1 | greet("Alice", "Hello", "extra") | ^^^^^ Function 'greet' expects 1 to 2 arguments, but 3 were provided.
Variadic parameters use ...name Type. The function receives the variadic values as a slice [:]Type. Only the last parameter can be variadic. At the call site, individual values or a spread slice (...mySlice) can be passed. Spread syntax is supported for fixed arrays, dynamic arrays, and slices.
Variadic arguments are passed as a [:]T slice constructed from a stack-allocated temporary array whose lifetime equals the call. Because slices follow reference escape rules, the variadic slice cannot escape the function. When the call site uses spread syntax (...mySlice), the existing slice header is reused directly without copying.
Return types follow the parameter list. void is implied when no return type is present. Functions return exactly one type.
1 | func add(a i32, b i32) i32 {\n if a > 0 { return a + b }\n } Missing return value. Function 'add' is declared to return 'i32', but this code path does not return a value.
Named arguments
Arguments can be passed by name to improve readability at the call site. Named arguments can appear in any order, but all positional arguments must come before any named arguments:
func createWindow(title string, width i32, height i32, fullscreen bool = false)
createWindow("My Game", width = 1280, height = 720)
createWindow("My Game", height = 720, width = 1280) // reordered, same result1 | createWindow(titel = "My Game", width = 1280, height = 720) | ^^^^^ Named argument 'titel' does not match any parameter of 'createWindow'. Did you mean 'title'?
Type properties (compiler-reserved)
The Type.property namespace is reserved for compiler-provided properties and functions. Users cannot declare functions under a type name. Constructors, factories, and utilities are module-level functions accessed via the module namespace or UFCS:
// vec3.cjr
struct Vec3 { x f32, y f32, z f32 }
func zero() Vec3 { return { x = 0, y = 0, z = 0 } }
func one() Vec3 { return { x = 1, y = 1, z = 1 } }
func up() Vec3 { return { x = 0, y = 1, z = 0 } }import ./vec3
var v = vec3.zero() // module-level function call
var u = vec3.one()Because Type.name is reserved, the compiler can use it for numeric type constants (i32.MAX, f32.EPSILON) and built-in math functions (f32.sqrt, i32.min) without ambiguity. Type introspection uses typeinfo(T) instead (see Type Introspection). See Numeric Math for the full list of type-namespaced operations.
UFCS (Uniform Function Call Syntax)
Any function whose first parameter’s underlying type matches a value’s type can be called with method syntax. The compiler rewrites the call, passing the receiver as the first argument with automatic referencing as needed. The type of the first parameter and the function must be declared in the same module as the type definition for cross-module UFCS to work.
// vec3.cjr
struct Vec3 { x f32, y f32, z f32 }
func length(v &Vec3) f32 {
return f32.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
}
func normalize(v &Vec3) Vec3 {
var l = v.length() // UFCS: desugars to length(&v)
return Vec3{ x = v.x / l, y = v.y / l, z = v.z / l }
}From another module:
import std/vec3
var v = vec3.zero()
var l = v.length() // UFCS: resolves to vec3.length(&v)
var n = v.normalize() // UFCS: resolves to vec3.normalize(&v)
var d = vec3.length(&v) // direct call also worksUFCS resolution order
When the compiler sees x.foo(args):
- Field check. If
foois a field of the type ofx, it is a field access. If the field is a function pointer, the(args)is a call through that pointer. - Defining module. Check if
foois a function in the type’s defining module whose first parameter’s underlying type matchesx. If so, rewrite tomodule.foo(x, args). - Error. If no match, report a diagnostic.
1 | var l = v.lenght() | ^^^^^^ No field or function 'lenght' found for type 'Vec3'. Did you mean 'length'?
Step 2 enables cross-module method calls without special syntax. The type’s defining module is its “method set.” Conjure’s only mechanism of encapsulation and associating behavior with types is through modules and UFCS or overloads.
Auto-ref and auto-deref
UFCS matches on the underlying type, ignoring pointer and reference wrappers. The compiler inserts & (referencing) or * (dereferencing) as needed:
func length(v &Vec3) f32 { ... } // takes &Vec3
func reset(v *Vec3) { ... } // takes *Vec3
func copy(v Vec3) Vec3 { ... } // takes Vec3 by value
var myVec = Vec3{ x = 1, y = 2, z = 3 }
var pVec = alloc(Vec3{})
myVec.length() // myVec is Vec3, param is &Vec3 → auto-ref: length(&myVec)
myVec.reset() // myVec is Vec3, param is *Vec3 → auto-ref: reset(&myVec)
myVec.copy() // myVec is Vec3, param is Vec3 → direct: copy(myVec)
pVec.length() // pVec is *Vec3, param is &Vec3 → compatible: length(pVec)
pVec.copy() // pVec is *Vec3, param is Vec3 → auto-deref: copy(*pVec)The rule: strip * and & wrappers from both the receiver and the parameter to find the underlying type. If they match, the call is valid. The compiler inserts the necessary conversion.
Regular UFCS (.) does not auto-unwrap optional types. Calling x.foo() where x is ?*T is a compile error. Unwrap first with if narrowing, else, or !.
UFCS does not apply to global intrinsic functions like alloc(), free(), min(), max(), etc., since they are not declared in the type’s defining module.
1 | myRef.reset() | ^^^^^^^^^^^^^ Cannot call 'reset' on a value of type '&Vec3'. 'reset' requires a pointer '*Vec3', but '&Vec3' is only a reference. Consider using a '*Vec3' pointer instead.
LSP support
The language server builds a UFCS index per module: a mapping from type to the list of functions whose first parameter matches. This powers autocomplete (myVec. shows length, normalize, dot, cross), go-to-definition (resolves to the function declaration), and find-references (captures both UFCS and direct call forms).
Extern functions
Functions implemented externally (in C or another language) are declared with extern:
extern func puts(s cstring) i32
extern "malloc" func allocate(size usize) ?rawptr // C pointers may be NULL
extern "SDL_Init" func sdlInit(flags u32) i32 // external library functionExtern functions that return pointer types must use nullable types (?*T, ?rawptr) to accurately represent C’s nullable pointer semantics. The compiler enforces this: an extern function declaration that returns a non-nullable pointer type (*T) produces a compile error, since the compiler cannot verify that the C implementation upholds the non-null guarantee.
1 | extern "SDL_CreateWindow" func createWindow(...) *Window | ^^^^^^^ Extern function 'createWindow' returns non-nullable pointer type '*Window', but extern functions cannot guarantee non-null returns. Use '?*Window' instead and unwrap the result at the call site.
The string after extern is the linker symbol name. When omitted, the Conjure function name is used as the symbol name.
Extern functions can have no body. When no body is provided, they still participate in type checking but the compiler does not generate code for them; the linker resolves them from linked libraries. When a body is provided for an extern function, the compiler generates a wrapper that calls the body and exposes it under the specified symbol name. This allows you to write Conjure code that is callable from C or other languages by exposing it with a C-compatible ABI.
Special compiler intrinsic externs use a : prefix in the symbol name and are handled specially by the compiler. See alloc, free, and realloc (compiler intrinsics) for details on compiler intrinsics.
Extern functions with a body (which export Conjure code under a C symbol name) restrict their parameter and return types to C-compatible types: primitive numerics, bool, cstring, rawptr, typed pointers (*T), nullable pointers (?*T, ?rawptr), fixed arrays of C-compatible types, and structs containing only C-compatible fields. Types that have no C equivalent are rejected at the declaration site: string, [:]T, []T, ?T (for non-pointer T), !T, references (&T), data enums with payloads, and any struct containing these.
1 | extern "my_callback" func onEvent(name string) void | ^^^^^^ Parameter 'name' has type 'string', which is not C-compatible. Extern functions with a body can only use C-compatible types in their signature. Use 'cstring' for string parameters in C-facing APIs.
1 | puts(myString) | ^^^^^^^^ Extern function 'puts' was called with argument type 'string', but the parameter expects 'cstring'. Strings are not automatically converted to C strings in function calls. Use '.cstr()' to get the underlying NUL-terminated pointer. = hint: try puts(myString.cstr())
Overload Declarations
The overload keyword declares a function that extends the behavior of a built-in operation for user-defined types. Overloads cover arithmetic operators, comparison, indexing, resource cleanup, hashing, and string formatting.
Overloads are free functions declared at module scope, not inside struct bodies. The overload keyword replaces func in the declaration. The name after overload must be from the fixed set the compiler recognizes. The compiler validates that the signature matches the accepted patterns for that operator (arity, return type, etc.) and produces diagnostics if not.
Operator overloads
// vec3.cjr
struct Vec3 { x f32, y f32, z f32 }
overload add(a Vec3, b Vec3) Vec3 {
return { x = a.x + b.x, y = a.y + b.y, z = a.z + b.z }
}
overload mul(a Vec3, s f32) Vec3 {
return { x = a.x * s, y = a.y * s, z = a.z * s }
}
overload mul(s f32, a Vec3) Vec3 {
return { x = a.x * s, y = a.y * s, z = a.z * s }
}
overload neg(v Vec3) Vec3 {
return { x = -v.x, y = -v.y, z = -v.z }
}
overload equal(a Vec3, b Vec3) bool {
return a.x == b.x and a.y == b.y and a.z == b.z
}
overload indexGet(v &Vec3, i u32) f32 {
return (&v.x)[i]
}
overload indexSet(v &Vec3, i u32, val f32) {
(&v.x)[i] = val
}Usage:
import std/vec3
var v = vec3.zero() + vec3.one() // calls overload add
var w = v * 2.0 // calls overload mul(Vec3, f32)
var u = 0.5 * v // calls overload mul(f32, Vec3)
var n = -v // calls overload neg
if v == w { ... } // calls overload equal
if v != w { ... } // auto-derived from overload equal
var x = v[0] // calls overload indexGet
v[1] = 3.0 // calls overload indexSetOperator name table
| Name | Symbol | Arity | Auto-derives |
|---|---|---|---|
add | + | Binary | += |
sub | - | Binary | -= |
mul | * | Binary | *= |
div | / | Binary | /= |
mod | % | Binary | %= |
neg | - | Unary | |
equal | == | Binary | != |
compare | Binary | <, >, <=, >=, ==, != | |
band | & | Binary | &= |
bor | | | Binary | |= |
bxor | ^ | Binary | ^= |
bnot | ~ | Unary | |
shl | << | Binary | <<= |
shr | >> | Binary | >>= |
indexGet | [] | Binary | Read access |
indexSet | []= | Ternary | Write access |
slice | [:] | Ternary |
Operator overload rules
Overload error diagnostics
If an operator is used with operand types that do not have a matching overload, the compiler produces a diagnostic explaining that the operation is not supported for those types. The exact diagnostic message can vary based on the operator and types involved.
1 | var r = myVec + "hello" | ^^^^^^^ Addition is not supported between 'Vec3' and 'string'. No matching overload 'add(Vec3, string)' found in the type's defining module.
Signature rules
The compiler enforces specific signature patterns for each overload name. For example, overload add must be binary and return a value; overload neg must be unary; overload indexSet must be ternary with a void return type. If the signature does not match the expected pattern for that operator, the compiler produces a diagnostic.
Same-module rule
overload declarations must be in the same module as the first operand’s type definition. This prevents “orphan overloads” that could cause behavior to change depending on which modules are imported.
1 | overload add(a Vec3, b f32) Vec3 { ... } | ^^^^^^^^ Cannot declare 'overload add' for types 'Vec3' and 'f32' here. Neither 'Vec3' nor 'f32' is defined in this module. Overload declarations must be in the same module as at least one operand type.
Auto-derivation
Compound assignments (+= from +) are auto-generated as a = overload_add(a, b).
Comparison derivation
Declaring equal auto-derives != as !(a == b). Declaring compare auto-derives all six comparison operators from the i8 return value: == (result == 0), != (result != 0), < (result < 0), > (result > 0), <= (result <= 0), >= (result >= 0). Declaring both equal and compare for the same type is a compile error.
Asymmetric operands
Each operand-type combination requires its own declaration. overload mul(Vec3, f32) and overload mul(f32, Vec3) are separate declarations. If only one direction is defined, using the other direction is a compile error with a specific diagnostic.
Precedence
Overloaded operators keep their built-in precedence. There are no user-defined precedence levels and no custom operators.
Index overloads
overload indexGet(c &T, i U) V provides read access. overload indexSet(c &T, i U, v V) provides write access. Read-only containers declare only indexGet. Mutable containers declare both. The presence of indexSet is what makes c[i] = x valid; without it, the compiler reports an error explaining that the container is read-only.
1 | grid[0] = 42 | ^^^^^^^ Cannot write to index on type 'Grid'. To support writes, declare an 'indexSet' in the type's defining module.
Name mangling
Overloads are mangled with their operand types to distinguish them: module__ov_add__Vec3__Vec3 for overload add(Vec3, Vec3). The exact mangling is an implementation detail.
overload compare
overload compare must return i8. The compiler interprets the return value as follows:
| Value | Meaning | Derived behavior |
|---|---|---|
< 0 | Less than | < is true, <= is true, > is false, >= is false |
0 | Equal | == is true, != is false, <= is true, >= is true |
> 0 | Greater than | > is true, >= is true, < is false, <= is false |
The compiler derives all six comparison operators (<, >, <=, >=, ==, !=) from the single compare overload. For floating-point types, the built-in compare handles NaN by returning a value that makes all comparisons false, matching IEEE 754 semantics.
overload compare(a Vec3, b Vec3) i8 {
var la = a.length()
var lb = b.length()
if la < lb { return -1 }
if la > lb { return 1 }
return 0
}Declaring both compare and equal for the same type is a compile error. Use equal when only equality testing is needed (derives !=). Use compare when ordering is also needed (derives all six operators).
overload free (resource cleanup)
overload free is the user-extension point for cleanup at an alias set’s free point. It defines the cleanup logic; the compiler invokes it during auto-free at scope exit (see Automatic free at scope exit), during an explicit free() call (see Explicit early free), and when a containing value frees one of its fields. The declared form and the call form share the same name: overload free is what you write to define cleanup, free() is what you call to invoke it.
Default behavior
Every owning type has a compiler-synthesized default free. The default walks the type’s owning fields in declaration order and frees each one using its own free logic. For built-in owning types the compiler ships a known free: *T deallocates the pointee (after freeing it), []T frees each element of the backing buffer (if T is an owning type) then releases the buffer, string releases its heap bytes when the ownership tag indicates a heap allocation. For structs and data enums, the default free is the field walk.
This means a type with owning fields does the obvious cleanup without writing anything:
struct World {
entities []Entity
systems []System
name string
observers [:]Observer // slice (borrow), not in the field walk
}
func play() {
var world = World{ ... }
// ...
// At scope exit the compiler-synthesized free runs:
// - world.entities → free each Entity, then release the []Entity buffer
// - world.systems → free each System, then release the []System buffer
// - world.name → release heap bytes if string tag is heap-owned
// - world.observers → skipped (slices are borrows, not owners)
}Overriding the default
Declare overload free to replace the synthesized walk:
overload free(world &World) {
log.info("World ${world.name} shutting down")
free(world.entities)
free(world.systems)
free(world.name)
}overload free takes its parameter as &T (a reference), not *T. The reference is a borrow into the value being freed. When the overload returns, the compiler proceeds with whatever post-overload cleanup the dispatch table prescribes (deallocate the pointee for *T, release the backing buffer for []T, no further work for value types).
A declared overload free replaces the default field walk. If the override forgets a field, that field is not freed. Mix the two by calling free(field) explicitly inside the override, which dispatches to that field’s own free.
Dispatch rules
Frees happen at three call sites: implicit auto-free at scope exit, explicit free(), and field-walk during a parent’s free. All three dispatch the same way:
| Argument type | overload free runs? | Post-overload behavior |
|---|---|---|
*T | Yes (default or user) | Deallocates the pointed-to memory |
?*T | Yes if non-null | Deallocates, then the binding is gone |
Value type T | Yes (default or user) | No deallocation (the value lives in its enclosing scope) |
[]T | Yes (default or user) | Releases the backing buffer; the default frees elements first if T is owning |
string | Yes (built-in) | Releases heap bytes when the tag indicates heap ownership |
rawptr | No | Releases raw bytes |
Containers of owning elements
A []*Entity is “owns each Entity, transitively.” When the dynamic array is freed, the default free walks each slot, frees each *Entity (which runs Entity’s own free and then deallocates), and finally releases the (data, len, cap) buffer. Same for []string, []NamedPoint, and any other []T where T is owning. The user does not need to write a for e in arr { free(e) } loop (that is the compiler’s job).
var entities []*Entity
entities.append(alloc(Entity{...})!)
entities.append(alloc(Entity{...})!)
// scope exit:
// default free of entities → free each *Entity → release bufferIf a struct has []*T as a field, the compiler-synthesized free walks into that array and frees each element. Same answer and same machinery.
Opting fields out of the cascade
Some data layouts deliberately store owning-shaped values that are actually borrowed (ECS component arrays point into archetype storage; nodes in an arena-backed graph share buffers). The right type for those slots is the borrowing form: &T instead of *T, [:]T instead of []T, &string instead of string. Borrowing types are not owning and do not participate in the field walk.
Where the field-walk rule would still be wrong (rare), declare overload free for the containing type and omit the field from the explicit teardown. The overload replaces the default, so anything you do not call free() on stays untouched.
Calling free() directly
free(x) is the same operation the compiler synthesizes at scope exit. It runs the overload free (default or user) and then performs the post-overload deallocation if the argument is heap-owning. After free(x), the alias set is ended (see Explicit early free); subsequent use of any member is a compile error.
var content = readFile("data.txt") // string
process(&content)
free(content) // explicit early free
// content is no longer usableDebug-mode leak detection
In debug builds, the memory sanitizer tracks all allocations. If a synthesized free is suppressed by a user-declared overload free that fails to release an owning field, the leak detector reports the leaked backing storage at program exit with the allocation site of the leak and the override that skipped it.
overload format (string formatting)
Types can customize how they appear in string interpolation by declaring overload format:
struct Vec3 { x f32, y f32, z f32 }
overload format(v &Vec3, spec string) string {
return "(${v.x}, ${v.y}, ${v.z})"
}var pos = Vec3{ x = 1.5, y = 2.0, z = 0.0 }
println("Position: ${pos}") // "Position: (1.5, 2.0, 0.0)"
println("Position: ${pos:.2f}") // spec = ".2f" passed to overloadWithout overload format, the compiler generates a default formatter that prints TypeName{field1 = value1, field2 = value2, ...}.
The spec parameter receives the format specifier string from the interpolation expression (the part after : inside ${expr:spec}). When no specifier is present, spec is the empty string.
overload hash (hash computation)
Types can customize their hash behavior for use with hashof() and hash-based data structures:
struct MyKey {
data []u8
id u32
}
overload hash(k &MyKey) u64 {
// Only hash the id field, ignore the data
return hashof(k.id)
}Without overload hash, the compiler generates a default hash function that recursively combines all fields in declaration order. See the Compiler Intrinsics section for hashof() details.
overload cast (custom type conversions)
overload cast defines a custom conversion from one type to another. The cast takes its source by value: for owning types the alias set extends into the cast’s parameter (the source binding remains usable in the caller’s scope), and for value types the source is copied. Casts require explicit value.(TargetType) syntax at the call site; the overload defines what happens, not when.
struct Vec3 { x f32, y f32, z f32 }
struct Vec4 { x f32, y f32, z f32, w f32 }
overload cast(v Vec3) Vec4 {
return Vec4{ x = v.x, y = v.y, z = v.z, w = 1.0 }
}
var v3 = Vec3{ x = 1.0, y = 2.0, z = 3.0 }
var v4 = v3.(Vec4) // calls overload cast to convert Vec3 to Vec4The same-module rule applies: overload cast must be declared in the same module as the source type. The compiler verifies that no implicit conversions are generated from overload cast declarations.
Structs
Structs are data-only aggregate types. They contain field declarations and nothing else. There are no methods, constructors, or destructors inside a struct body. Behavior is added through free functions and UFCS (see Functions and UFCS). Resource cleanup is the compiler-synthesized field walk by default and can be customized with overload free (see overload free (resource cleanup)).
Declaration and construction
struct Point {
x f32
y f32
}
struct Color {
r u8
g u8
b u8
a u8
}Structs are constructed using named field literals. All fields must be initialized either explicitly or through zero-initialization:
var p = Point{ x = 1.0, y = 2.0 } // named fields
var q Point = { x = 3.0, y = 4.0 } // type on the left, fields inferred
var r = Point{} // zero-initialized (x = 0.0, y = 0.0)When the type can be inferred from context (return type, assignment target, function argument), the type name can be omitted:
func origin() Point {
return { x = 0, y = 0 } // type inferred from return type
}
var p Point = { x = 1, y = 2 } // type inferred from declarationField order in the literal does not need to match declaration order. Omitted fields are zero-initialized.
1 | var p = Point{ z = 1.0 } | ^ Unknown field 'z' in struct 'Point'. 'Point' has fields: x, y.
1 | var p = { x = 1, y = 2 } | ^^^^^^^^^^^^^^^^ Cannot infer the type of this struct literal. The compiler needs a type annotation or a context that determines the type (return type, parameter type, or explicit variable type).
Field access
Fields are accessed with dot syntax. For pointer and reference receivers, the compiler auto-dereferences:
var p = Point{ x = 1.0, y = 2.0 }
var x = p.x // 1.0
var ptr = alloc(Point{ x = 3.0, y = 4.0 })
var px = ptr.x // auto-deref: (*ptr).x = 3.0
ptr.x = 5.0 // auto-deref write@protected fields
The @protected annotation makes a field readable from outside the defining module but not writable. Within the defining module, the field is fully accessible:
// player.cjr
struct Player {
name string
@protected health i32 // readable everywhere, writable only in this module
@protected score u32
}
func heal(p &Player, amount i32) {
p.health = min(p.health + amount, 100) // ok: same module
}// From another module:
import ./player
var p = player.new("Alice")
var h = p.health // ok: read access
println("HP: ${p.health}") // ok: read access
p.health = 999 // error: protected field1 | p.health = 999 | ^^^^^^^^ Cannot write to field 'health' of type 'Player' from outside its defining module. The field is marked as 'protected', which allows reads but restricts writes to the module where the type is defined.
Bitfields
Struct fields can specify a bit width using : width after the type. The compiler manages the packing and unpacking.
Bitfields are laid out least-significant-bit first within their backing storage unit. The backing storage unit is the declared field type. Multiple consecutive bitfields with compatible backing types pack into the same unit until full; the next bitfield begins a new unit. Cross-platform byte order of the unit follows the target’s native endianness:
struct PoolRef {
poolId u8 : 4 // 4 bits (0..15)
index u32 : 28 // 28 bits
}
struct Flags {
visible bool : 1
active bool : 1
collides bool : 1
}Bitfield values are read and written as their declared type. The compiler truncates on write and masks on read. Writing a value that exceeds the bit width silently truncates in release builds and produces a warning in debug builds:
1 | ref.poolId = 20 | ^^ Value 20 does not fit in 4-bit field 'poolId' (max value 15). The value will be truncated to 4 in release builds.
Struct embedding
A struct can embed another struct’s fields using the ...Type syntax. All fields from the embedded type are copied into the embedding struct as if they were declared directly. Field name collisions with the embedding struct or other embedded types are compile errors:
struct Point2D {
x f32
y f32
}
struct Point3D {
...Point2D // copies x and y into Point3D
z f32
}
var p = Point3D{ x = 1, y = 2, z = 3 }
var px = p.x // works: x is a direct field of Point3DFunctions that accept &Point2D do not automatically accept &Point3D. Embedding copies fields; it does not create a subtype relationship.
1 | ...Point2D\n x f32 Field name collision in struct 'Point3D'. Field 'x' is provided by the embedded type 'Point2D' but is also declared directly.
Inline unions
A struct can contain an anonymous union block. All fields within the union share the same memory offset. This is useful for result types, variant payloads, and memory-efficient storage:
struct FileResult {
ok bool
union {
file File // valid when ok is true
errno i32 // valid when ok is false
}
}
var result = FileResult{ ok = true, file = myFile }
if result.ok {
result.file.read(buf)
} else {
println("Error: ${result.errno}")
}The union fields are accessed directly on the struct without an intermediate field name. The compiler does not enforce which union field is active; this is the programmer’s responsibility, similar to C unions.
Anonymous structs and unions
Both struct and union can be used without a name as the type of a variable, field, or parameter. Anonymous types are useful for one-off groupings where naming a type would add noise:
var point = struct { x f32, y f32 }{ x = 1.0, y = 2.0 }
func processEvent(event struct { kind u8, data u64 }) {
when event.kind {
case 1: handleKey(event.data)
case 2: handleMouse(event.data)
}
}Anonymous types are structurally typed: two anonymous structs with the same field names and types in the same order are the same type. Named structs are nominally typed: two named structs with identical fields are distinct types.
Anonymous unions follow the same rules:
var val = union { i i64, f f64 }{ i = 42 }Anonymous structs and unions are most commonly used inline within named struct bodies (see Inline unions). Overusing them in function signatures reduces readability; prefer named types for any struct that appears in more than one place.
Struct layout annotations
By default, struct fields are laid out in declaration order with padding inserted to satisfy each field’s alignment requirement. This matches C struct layout and is predictable for FFI.
@packed removes all inter-field padding. Fields are stored at consecutive byte offsets with no alignment gaps. Accessing misaligned fields may be slower on some architectures:
@packed
struct Header {
magic u8
size u32 // offset 1, no padding after magic
flags u16 // offset 5
}
// sizeof(Header) == 7 (no padding)@optimize allows the compiler to reorder fields for minimum total size (smallest padding). The compiler chooses the field order that minimizes wasted space while respecting alignment constraints. This is incompatible with any struct whose layout must match a C definition:
@optimize
struct Entity {
active bool // 1 byte
id u64 // 8 bytes
health u16 // 2 bytes
}
// compiler may reorder to: id, health, active (12 bytes instead of 24)Using @optimize on a struct that appears as a parameter or return type of an extern function with a body is a compile error, since the C caller expects a specific layout:
1 | extern "update" func update(e Entity) void | ^^^^^^ Parameter 'e' has type 'Entity', which is marked @optimize. Structs used in extern function signatures must have a stable layout for C interop. Remove @optimize from 'Entity' or use a different type for the extern boundary.
Enums
Plain enums
Enums define a set of named integer constants backed by the smallest integer type that can hold all variant values. Each variant is assigned a sequential value starting from 0 unless explicitly overridden:
enum Color { Red, Green, Blue }
enum HttpStatus { OK = 200, NotFound = 404, ServerError = 500 }
enum Flags { CanJump = 1 << 0, CanRun = 1 << 1, CanFly = 1 << 2 }For a simple case like Color, the compiler chooses u8 as the underlying type since 3 variants fit within 8 bits. For HttpStatus, the compiler chooses u16 since 500 fits within 16 bits but not 8 bits. For Flags, the compiler chooses u32 since the largest value is 4 (1 << 2) which fits in 8 bits, but we may want to support up to 32 flags.
You can also explicitly specify the underlying type for an enum:
enum Color i8 { Red, Green, Blue }Enum shorthand (.Variant)
When the expected enum type is known from context (comparisons, assignments, function arguments, when/case arms), variants can be written with the shorthand .Variant syntax instead of the full EnumName.Variant:
var c = Color.Red // full form
var c Color = .Red // shorthand: type inferred from declaration
if c == .Green { ... } // shorthand: type inferred from LHS of ==
func paint(c Color) { ... }
paint(.Blue) // shorthand: type inferred from parameterThe compiler resolves .Variant by examining the expected type at the expression site. If the expected type is not an enum, or the variant name does not exist on the expected enum, a diagnostic is produced:
1 | if c == .Purple { ... } | ^^^^^^^ Cannot resolve '.Purple' in this context. The expected type is 'Color', which has variants: Red, Green, Blue.
1 | var x = .Red | ^^^^ Cannot use enum shorthand '.Red' here. The type of this expression cannot be inferred. Use the full form 'Color.Red' instead.
Data enums (tagged unions)
Enum variants can carry per-variant data. Each variant declares its payload type after the variant name. The enum is stored as a tag (the variant discriminator) plus enough space for the largest payload:
enum IntOrStr {
Int i32
Str string
}
enum Shape {
Circle f64
Rect { width f64, height f64 }
Point
}Data enum values are constructed by naming the variant and providing the payload:
var x = IntOrStr.Int(42)
var s = Shape.Rect{ width = 4.0, height = 6.0 }
var p = Shape.PointPayloads are extracted with pattern matching in when statements (see Control Flow section):
when x {
case .Int n: println("Integer: ${n}")
case .Str s: println("String: ${s}")
}
when shape {
case .Circle r: println("Circle with radius ${r}")
case .Rect{ width, height }: println("Rect ${width}x${height}")
case .Point: println("Point")
}1 | when shape { | ^^^^ Non-exhaustive match. The 'when' statement does not cover all variants of 'Shape'. Missing: 'Point'. Add a 'case .Point:' arm or an 'else:' arm to handle remaining variants.
Enum introspection
Enums support compile-time access to their variant count and names via typeinfo() (see Type Introspection):
const colorCount = typeinfo(Color).variantCount // 3Unions
Standalone unions define a type where all fields share the same memory address. The size of the union is the size of its largest field. Unlike data enums, standalone unions carry no tag and the compiler does not track which field is active:
union Value {
i i64
f f64
p rawptr
}
var v Value
v.i = 42
var bits = v.f // reinterpret the bits as f64 (no conversion)Unions are primarily used for low-level memory reinterpretation, FFI compatibility with C unions, and as anonymous inline members inside structs (see Inline unions). For type-safe variant types, use data enums instead.
1 | var bits = v.f | ^^^ Reading field 'f' of union 'Value' after writing to field 'i'. Union fields share memory, so this reads the bit pattern of 'i' reinterpreted as 'f64', which may not be meaningful. If this is intentional (e.g., bit-level type punning), this warning can be suppressed.
Control Flow
If / else
if takes an expression (no parentheses required) followed by a block. else and else if chains are optional:
if x > 0 {
println("positive")
} else if x < 0 {
println("negative")
} else {
println("zero")
}The condition must be a bool or ?T expression. Optional types (?T) are implicitly checked for null: if the value is non-null, the condition is true. Inside the truthy branch, the variable is narrowed to the unwrapped type T (see Optionals for details). All other non-boolean types are not implicitly converted to bool. Use explicit comparisons instead:
1 | if x { | ^ Condition must be a boolean expression, but found type 'i32'. Use an explicit comparison like 'x != 0' instead.
While
while loops repeat as long as the condition is true. Omitting the condition creates an infinite loop:
while running {
update()
render()
}
while { // infinite loop
if done { break }
}For loops
for loops iterate over ranges, collections, and generators. The loop variable is scoped to the loop body and is immutable by default.
Range iteration (exclusive upper bound):
for i in <5 { // i = 0, 1, 2, 3, 4
println("${i}")
}Range iteration (inclusive upper bound):
for i in 5 { // i = 0, 1, 2, 3, 4, 5
println("${i}")
}Bounded range:
for i in [5:10] { // i = 5, 6, 7, 8, 9, 10 (inclusive)
println("${i}")
}
for i in [1:<6] { // i = 1, 2, 3, 4, 5 (exclusive upper)
println("${i}")
}
for i in [10:0:-1] { // i = 10, 9, 8, ..., 1, 0 (step -1)
println("${i}")
}Collection iteration:
for item in array { // iterate values
println("${item}")
}
for i, item in array { // iterate with index
println("${i}: ${item}")
}Collections that support for iteration: fixed arrays ([N]T), dynamic arrays ([]T), slices ([:]T), and strings (iterates bytes as u8).
1 | for x in 42 { | ^^ Cannot iterate over type 'i32'. The 'for ... in' construct accepts arrays, slices, dynamic arrays, strings, and integer ranges. = hint: for a range from 0 to 42, use 'for x in <42' or 'for x in 42'
When (pattern matching)
when is Conjure’s pattern matching construct. It matches a value against a series of case arms and executes the first matching arm. Each arm does not fall through to the next unless fallthrough is explicitly used:
when status {
case .OK: println("success")
case .NotFound: println("not found")
else: println("other error")
}when used as an expression requires an else arm and all arms must produce a value of the same type:
var name = when status {
case .OK: "success"
case .NotFound: "not found"
else: "unknown"
}Data enum destructuring:
when value {
case .Int n: println("integer: ${n}")
case .Str s: println("string: ${s}")
case .Rect{ width, height}: println("${width}x${height}")
case .Point: println("point")
}Multiple values per arm:
when direction {
case .North, .South: println("vertical")
case .East, .West: println("horizontal")
}unreachable in when
When the programmer knows that certain values or variants are impossible at a given point, the else arm can use unreachable to communicate this to the compiler. In debug builds, reaching an unreachable arm panics with a diagnostic. In release builds, the compiler uses this as an optimization hint:
when direction {
case .North: moveUp()
case .South: moveDown()
case .East: moveRight()
case .West: moveLeft()
else: unreachable // no other variants exist
}Exhaustiveness
When matching on an enum, the compiler checks that all variants are covered. Missing variants produce an error unless an else arm is present.
Fallthrough
By default, each case arm is self-contained and does not fall through. Use fallthrough to explicitly continue to the next arm’s body:
when level {
case 3: unlockBonus()
fallthrough
case 2: addPowerup()
fallthrough
case 1: startLevel()
}1 | when direction { | ^^^^^^^^^^^^^^ Non-exhaustive match. The 'when' statement does not cover all variants of 'Direction'. Missing: 'East', 'West'. Add the missing cases or an 'else:' arm to handle remaining variants.
Defer
defer delays execution of a statement until the enclosing scope exits. It is a compile-time transformation: the compiler inlines the deferred statement at every exit point of the enclosing block. There is no runtime defer stack, no closures, and no heap allocation.
Deferred statements cannot directly contain control flow like return, break, or continue since they are inlined at those points. Deferred statements run after the current statement completes but before the control transfer occurs.
defer is for cleanup that auto-free (see Automatic free at scope exit) does not handle: releasing locks, restoring global state, logging, flushing buffers, calling foreign APIs whose cleanup is not a free(). Memory cleanup is handled implicitly, meaning defer free(...) is not idiomatic in normal Conjure code (it is only required inside @noautofree scopes, see Disabling auto-free (@noautofree)).
func criticalSection(state &State) {
state.mutex.lock()
defer state.mutex.unlock() // unlocks at every exit point
if failed() {
return // mutex.unlock() runs here
}
state.update()
// mutex.unlock() runs here (end of scope)
}Multiple defers in the same scope execute in LIFO order (last deferred = first executed):
defer println("third")
defer println("second")
defer println("first")
// prints: first, second, thirddefer is scoped to the enclosing block, not the function. Deferred code runs at the end of if, while, for, or any { } block:
for entry in entries {
entry.handle.acquire()
defer entry.handle.release() // released each iteration
process(&entry)
}Exit points where deferred code is inlined:
returnstatementsbreakstatementscontinuestatements- Natural end of the block
1 | defer println("hello") | ^^^^^ 'defer' cannot be used at the top level of a module. Deferred statements must be inside a function or block scope.
Break, continue, return
break // exit innermost loop
continue // skip to next iteration
return <value> // return from function
return // return voidThese interact with defer: any deferred statements in the current scope execute before the control transfer occurs.
1 | break | ^^^^^ 'break' used outside of a loop. 'break' can only be used inside 'for' or 'while' loops.
1 | return 42 | ^^^^^^ 'return' with a value in a function declared to return 'void'. Either remove the return value or add a return type to the function.
Error Handling
Conjure uses faults for error handling. A fault is a named tag that represents a specific failure condition. Functions that may fail declare !T as their return type, meaning “T or a fault.” Faults propagate freely across function boundaries because all faults share a single global tag space.
There are no exceptions, no stack unwinding, and no hidden control flow. Error handling is always explicit and visible at the call site.
Faults
A fault declaration introduces a named error tag at module scope:
// std/fs
fault FileNotFoundErr
fault PermissionDeniedErr
fault DiskFullErrFaults are globally unique tags. Each fault declaration across the entire program gets a distinct identity at compile time. Faults are zero-sized at runtime (they are stored as integer tags, not heap-allocated values). They are namespaced by their declaring module: importing std/fs makes them available as fs.FileNotFoundErr, fs.PermissionDeniedErr, etc.
Faults are not enums and do not belong to a group. Each fault is its own independent declaration.
Fallible return type (!T)
A function that may fail uses !T as its return type:
func readFile(path string) !string {
if !fileExists(path) {
return FileNotFoundErr
}
if !hasPermission(path) {
return PermissionDeniedErr
}
return contents // auto-wraps in success
}!T means “T or any fault.” The caller must handle the potential failure. Returning a bare value (not a fault) from a !T function auto-wraps it as the success case. Returning a fault tag produces the failure case.
!void is valid for functions that may fail but produce no value on success:
func deleteFile(path string) !void {
if !fileExists(path) {
return FileNotFoundErr
}
// ... delete the file
}try (error propagation)
The try prefix keyword unwraps a !T value. If the value is a success, try produces the inner T. If the value is a fault, try immediately returns that fault from the enclosing function:
func loadConfig(path string) !Config {
var data = try readFile(path) // readFile returns !string
var cfg = try parseConfig(data) // parseConfig returns !Config
return cfg
}try works across any fault types because all faults share the global tag space. A function returning !Config can propagate faults from readFile (which may return fs.FileNotFoundErr) and parseConfig (which may return parse.InvalidSyntaxErr) without any type conversion.
The enclosing function must return !T for some type. The outer type does not have to be the same as the inner type of the try expression, it just needs to be fallable.
main can return !void allowing try <expr> to be populated throughout the entire app. When the propagation reaches main, it will result in a non-zero exit code
Using try in a function that does not return a fallible type is a compile error:
1 | var data = try readFile("config.toml") | ^^^ 'try' can only be used in a function that returns a fallible type ('!T'). Function 'main' returns 'void', not '!void'. = hint: use 'readFile("config.toml")!' to panic on failure,
Handling faults
! postfix (force unwrap)
Panics if the value is a fault. The panic message includes the fault name and source location:
var data = readFile("config.toml")! // panic if fault
var ptr = alloc(MyStruct)! // panic if OutOfMemoryelse (default on fault)
Provides a fallback value when the expression is a fault, discarding the fault:
var data = readFile("config.toml") else ""
var port = parsePort(s) else 8080As with optionals, else works with never expressions:
var data = readFile(path) else panic("Config required at ${path}.")
var file = openFile(path) else return FileNotFoundErrwhen (pattern matching)
Match on specific faults or the success value:
when readFile("config.toml") {
case fs.FileNotFoundErr: println("config file missing")
case fs.PermissionDeniedErr: println("access denied")
case string data: process(data)
else: println("unexpected failure")
}The success case uses case <type> <ident>: where the named type must match the success type of the !T being matched. The compiler verifies this at the arm. Fault names appear directly as case arms (qualified by their module when imported). else: catches any unmatched faults. To bind the unmatched fault, use else err:.
The success arm does not have to be last, but each fault case must precede any else: arm.
1 | case i32 val: process(val) | ^^^ Success case binding declares type 'i32', but the matched expression has type '!string'. The success type must match the right-hand side of the '!' return type.
This named-type form (case <type> <ident>:) applies only to fault matching. In when over plain types (enums, integers, etc.), case <ident>: remains a catch-all wildcard binding pattern.
Built-in faults
The compiler provides the following fault for use by built-in operations:
fault OutOfMemory // returned by alloc() on allocation failurealloc returns !*T
The alloc intrinsic returns a fallible pointer. On allocation failure, it returns the OutOfMemory fault instead of panicking:
var ptr = alloc(MyStruct)! // common: panic on OOM
var ptr = try alloc(MyStruct) // propagate OOM to caller
var ptr = alloc(MyStruct) else return OutOfMemoryThe ! postfix on alloc is the idiomatic pattern for game code and other contexts where OOM should be a fatal error. try alloc is used in library code that wants to let the caller decide how to handle allocation failure.
!T and ?T are distinct
!T (fallible) and ?T (optional) represent different concepts:
| Type | Meaning | Zero value | Unwrap mechanisms |
|---|---|---|---|
?T | Value may be absent (null) | null | else, !, if narrowing |
!T | Operation may have failed (fault) | Not applicable | try, else, !, when |
?T represents expected absence (a lookup that returns nothing). !T represents unexpected failure (an I/O operation that encounters an error). They should not be nested (!?T and ?!T are compile errors). If a function needs to express both absence and failure, use a data enum.
Data-carrying faults (PROPOSED)
Imports and Modules
Each Conjure source file ( .cjr ) is a module. Modules are the unit of compilation, namespace, and visibility.
Import
import brings another Conjure module into scope. Paths are unquoted literals using / as the separator:
import std/io // namespace: io.print, io.println
import std/memory as mem // renamed namespace: mem.alloc, mem.free
import ./pool // relative: ./pool.cjr
import ../util/helpers // relative: ../util/helpers.cjrAn import makes the module’s public symbols accessible through the module namespace. The default namespace name is the last component of the path that is safe to use as an identifier (std/io -> io). You can use as to rename it to a custom alias.
Selective imports are allowed for “non” template modules.
import std/io as (print, println)Circular imports are supported. The compiler processes all modules in a multi-pass architecture: it forward-declares all types and function signatures before checking function bodies. Two modules can import each other as long as there are no circular dependencies in type definitions (a struct in module A cannot contain a value of a struct in module B that also contains a value of a struct in module A).
Module resolution
When the compiler encounters an import path, it resolves it using the following search order:
- Relative paths (
./or../) resolve relative to the importing file’s directory or fails immediately if not found. - Standard library (
std/prefix) resolves to the compiler’s bundled standard library. - Project vendor resolves to
<project>/vendor/<path>.cjror<project>/vendor/<path>/index.cjr. - Cached vendor resolves to
<project>/.conjure/vendor/<path>.cjror<project>/.conjure/vendor/<path>/index.cjr.
The compiler copies std/ into the project cache on first use, so subsequent imports of std/ modules are resolved from <project>/.conjure/vendor/std/<path>.cjr.
The compiler also checks for precompiled module caches at <project>/.conjure/cache/<path>.bin before parsing source. If a cached module’s fingerprint matches (source hash + dependency fingerprints + compiler version), the cached analysis is reused.
The compiler may provide some custom diagnostics for common resolution errors (like referring to a non-existent module or mistyping a standard library path), but in general, module resolution errors are reported as “module not found” with the attempted search paths. With “module not found” errors, we may want to show the attempted search paths and potentially some hints if the path looks similar to an existing module (e.g., typo in std path):
1 | import std/networking | ^^^^^^^^^^^^^^ Module 'std/networking' not found. The compiler searched: - <stdlib>/networking.cjr - <project>/vendor/networking.cjr - <project>/.conjure/vendor/networking.cjr Did you mean 'std/tcp' or 'std/udp'?
1 | children []Branch // Branch contains Node by value | ^^^^^^^^ Circular type dependency between modules. Type 'Node' in 'tree.cjr' contains a value of type 'Branch' from 'branch.cjr', which contains a value of type 'Node'. Use a pointer (*Node or &Node) to break the cycle.
Module conventions
One primary type per module
The convention (not enforced) is that a module defines one primary type. The module file is named after the type in lowercase (vec3.cjr for Vec3). This makes imports readable and keeps UFCS resolution simple: the module is the type’s method set.
Module-level functions
Functions that do not take the module’s primary type as their first parameter serve as the module’s “static” API, called through the module namespace:
import ./vec3
var v = vec3.zero() // module-level function, no Vec3 param
var l = v.length() // UFCS: first param is &Vec3Link (external dependencies)
link declares external libraries, frameworks, or source files that the linker should include. These are separate from import because they operate at the linker level, not the module level:
link "GL" // system library
link "framework:Cocoa" if OS == "darwin" // conditional on platform
link "pkgconfig:gtk+-3.0" if OS == "linux" // pkg-config dependency
link "source:vendor/stb_image.c" // compile and link C sourcelink statements are top-level declarations. They can have conditional guards using if with compile-time boolean expressions. The goal of link to allow modules to declare their external dependencies in a way that tooling can understand and manage without needing external build scripts or manual linker flags. Link flags are only collected from the modules that are actually imported, so unused dependencies do not affect the build.
Link kinds:
| Syntax | Meaning |
|---|---|
link "name" | Link against system library name (e.g., -lGL) |
link "pkgconfig:name" | Use pkg-config to resolve flags for the named package |
link "framework:name" | Link against a macOS/iOS framework (e.g., -framework Cocoa) |
Other linkage types will likely be added in the future, like link "static:name" for static libraries, link "dynamic:name" for dynamic libraries, link "dllimport:name" for Windows DLL imports, etc.
Custom link types may eventually be supported for things like compiling and link C source files directly. For now, compile-time execution could be used to invoke a C compiler as a workaround.
1 | link "GL" | ^^^^ Linked library 'GL' could not be found by the linker. On Linux, install the OpenGL development package: apt install libgl-dev (Debian/Ubuntu) dnf install mesa-libGL-devel (Fedora)
Project layout
Every Conjure project has a .conjure/ directory at its root that holds build artifacts, caches, and downloaded dependencies:
my-project/
├── src/
│ └── main.cjr
├── vendor/ # user-managed dependencies (optional)
├── conjure.toml # project configuration
└── .conjure/
├── cache/ # per-module precompiled snapshots
├── vendor/ # downloaded dependencies (managed by tooling)
└── build/ # intermediate build artifactsThe project root is determined by walking upward from the source file looking for a conjure.toml manifest. If no manifest is found, the source file’s directory is used as the root.
Template Modules
Conjure uses whole-file parameterization instead of per-declaration generics. A file becomes a template by placing a template declaration at the top. Every declaration in the file is parameterized by the template arguments. There are no generic structs or generic functions as individual declarations; the file is the unit of parameterization.
Declaring a template module
// pool.cjr
template[T type]
struct Pool {
data []T
freeList []u32
}
func new(cap u32) Pool { ... }
func alloc(p &Pool, value T) u32 { ... }
func get(p &Pool, id u32) &T { ... }
func release(p &Pool, id u32) { ... }Template parameters can be types or compile-time constants:
// grid.cjr
template[T type, W i32, H i32]
struct Grid {
cells [W * H]T
}
func get(g &Grid, x i32, y i32) &T { ... }Importing and instantiating
Template modules are imported like any other module. Instantiation happens at the point of use with [] syntax. The compiler memoizes instantiations by (file, arguments) tuple so each unique combination produces exactly one concrete module:
import std/pool
import std/map
// Alias the instantiated module for convenience
const entityPool = pool[Entity]
const particlePool = pool[Particle]
// Call module functions through the alias
var enemies = entityPool.new(256)
var sparks = particlePool.new(4096)
var scores = hashmap[string, i32].new()
// Or use inline without aliasing
var enemies2 = pool[Entity].new(256)Named parameters are supported for multi-parameter templates:
import std/pool
const entityPool = pool[T = Entity, Cap = 1024]Template and non-template modules use identical import syntax. Whether a module is a template is only relevant when [] is used on it. Importing a template module without instantiating it is valid; it simply makes the template available for later instantiation.
Selective imports (import ... as (name1, name2)) are not allowed for template modules because the module’s symbols are not concrete until instantiated.
Default symbol
A template module can designate one of its module-level symbols as the default symbol. When the instantiation expression module[args] appears in a position that expects a value rather than a namespace, the expression resolves to the default symbol of the instantiated module. This removes the need to repeat the symbol name when the module is built around a single primary type.
A symbol is the module’s default when its name matches the module file’s identifier under a case-insensitive comparison. Conjure module files are named in lowercase (map.cjr, pool.cjr); the primary type inside is typically PascalCase (Map, Pool). A module may declare at most one default symbol. A template module is not required to have one; if none is declared, every reference must use the full module[args].symbol form.
// std/map.cjr
template[K type, V type]
// The struct name `Map` matches the module file `map.cjr` under
// case-insensitive comparison, so `Map` is the module's default symbol.
struct Map {
buckets []Bucket[K, V]
size u64
}
func new() Map { ... }
func get(m *Map, key K) V { ... }
func insert(m *Map, key K, value V) { ... }At the call site, the instantiation expression resolves to the default symbol when used as a type, value, or constant. The fully qualified form remains available:
import std/map
// `map[string, u32]` resolves to `map[string, u32].Map` because `Map` is
// the default symbol of the `map` module.
var myMap map[string, u32] = map[string, u32].new()
// Equivalent fully qualified form:
var myMap2 map[string, u32].Map = map[string, u32].new()The default symbol can be any module-level declaration: a struct, enum, type alias, constant, or function. The instantiation expression is still a module namespace in expression positions where a namespace is expected (such as the receiver of a function call), so map[string, u32].new() continues to call the module’s new function regardless of whether a default symbol is declared. The default symbol only takes effect where a namespace alone would be a type or value error.
If more than one module-level symbol shares the module’s identifier, the compiler reports a duplicate-default-symbol error.
Template argument inference
When calling a function through a template module namespace without explicit [] parameters, the compiler infers template arguments from the function’s call-site argument types:
import std/json
var user = User{ firstName = "Alice", age = 30 }
var s = json.serialize(&user) // infers json[User].serialize(&user)
json.deserialize(s, &user) // infers json[User].deserialize(s, &user)The inference rule: the compiler sees json.serialize(u), knows json is template[T type], finds the function signature func serialize(value &T) string, and unifies T = User from the argument type.
Inference applies only to function calls where the template parameter appears in the function’s parameter list. For types, constants, and module-level aliases, explicit [] is required:
const userPool = pool[User] // explicit: no function call to infer from
var m = hashmap[string, i32].new() // explicit: new() has no args that carry K or V1 | var p = pool.new(256) | ^^^^ Cannot infer template arguments for 'pool'. The function 'new' has signature 'func new(cap u32) Pool', but no parameter references the template type 'T'. Provide explicit arguments: 'pool[MyType].new(256)'.
Compilation model
The compiler processes template modules in five steps:
- Parse once. The template file’s AST is parsed once and shared across all instantiations.
- Resolve arguments. Each use site provides concrete arguments. The compiler forms a memoization key from the canonical file path plus the resolved argument values.
- Check memo table. If an instantiation with the same key already exists from another import in the same build, the existing analyzed module is reused.
- Analyze on first use. On a memo miss, the compiler analyzes the template AST with concrete arguments substituted for the template parameters, producing a fully typed concrete module.
- Share results. Two files that both write
pool[Entity]get the same concrete module. Alias names can differ; only the(file, arguments)key matters.
Code de-duplication
The compiler automatically identifies symbols within a template module that do not depend on any template parameter and emits them only once, regardless of how many instantiations exist:
// pool.cjr
template[T type]
// Does not reference T: emitted once across all instantiations
const DEFAULT_CAP u32 = 64
// Does not reference T: emitted once
func roundUpPow2(n u32) u32 {
var v = n - 1
v = v | (v >> 1)
v = v | (v >> 2)
v = v | (v >> 4)
v = v | (v >> 8)
v = v | (v >> 16)
return v + 1
}
// References T: emitted per instantiation
func alloc(p &Pool, value T) u32 { ... }For templates with multiple parameters (template[K type, V type]), de-duplication is per-symbol based on which parameters the symbol actually uses. A function that only references K is shared across all instantiations with the same K, regardless of V.
This is useful if a template has shared internals that rely on type-erasure with the templated functions serving as type-specific wrappers around the shared implementation. For example, a pool template might have a type-agnostic free list implementation that is shared across all instantiations, while the alloc and get functions that reference the templated type are emitted per instantiation.
Template constraints
Template parameters can declare constraints using assert() in the template header to restrict what types can be used:
// small_pool.cjr
template[T type]
assert(sizeof(T) <= 64, "T must fit in a cache line")
struct Pool {
data []T
freeList []u32
}When a constraint is violated, the diagnostic points to the instantiation site and explains which constraint failed, rather than producing an error deep inside the template body.
1 | const p = small_pool[42] | ^^ Cannot instantiate 'small_pool[42]'. Template 'small_pool.cjr' is parameterized by [T type], but '42' is not a type.
1 | const p = small_pool[LargeStruct] | ^^^^^^^^^^^ Template constraint failed for 'small_pool[LargeStruct]'. Assertion 'sizeof(T) <= 64' is false: sizeof(LargeStruct) is 128. T must fit in a cache line.
1 | const p = pool[Entity] | ^^^^^^^^^^^^ Template instantiation depth exceeded (limit: 64). This usually indicates an infinite or unintended recursive instantiation chain. The chain was: pool[Entity] -> pool[Component] -> pool[Entity] (cycle).
Ownership and Auto-Free
Conjure tracks ownership at compile time to prevent use-after-free, double-free, and resource leaks without a garbage collector. The compiler determines ownership from type definitions and inserts cleanup automatically at scope exit. Bindings stay usable after assignment: assigning one owning binding to another creates an alias, not a move.
Owning types vs value types
Every type in Conjure is either an owning type or a value type, determined by the compiler from the type definition. No annotation is needed.
Value types contain no owning fields. They are copied bitwise on assignment, pass-by-value, and return. Both the source and destination are independent after the copy:
struct Point { x f32, y f32 } // value type: no owning fields
var p1 = Point{ x = 1, y = 2 }
var p2 = p1 // copy: both bindings are independent
p1.x = 10
println("${p2.x}") // prints 1.0, unaffected by p1's changeOwning types contain at least one owning field, transitively. The owning field types are string, []T, *T, and any struct or data enum that contains these. Owning types are aliased on assignment, pass-by-value, return, or storage into a container. Both bindings continue to refer to the same underlying resource, and the source binding remains usable:
struct NamedPoint { name string, x f32, y f32 } // owning: contains string
var np1 = NamedPoint{ name = "origin", x = 0, y = 0 }
var np2 = np1 // alias: np1 and np2 share the same resource
println("${np1.name}") // ok: np1 is still usable
println("${np2.name}") // ok: same datastring is always treated as an owning type for the purposes of alias analysis, even when the tag is statically known to be literal (tag 0). This keeps the rule simple. Users who want to make the borrow explicit at the call site can still pass by reference (&string).
Alias-set tracking
Owning values participate in alias sets. Each alloc() (or other resource-producing expression) starts a new alias set with exactly one member. Assignment, parameter passing, container insertion, and function return all extend the alias set with a new member; they never split or move ownership between sets.
The compiler tracks alias sets per function. The rules are:
- A binding is a member of exactly one alias set at any program point.
- Reassignment removes a binding from its current alias set and adds it to the right-hand side’s alias set.
- A function call passing an owning value by value extends the alias set with the parameter inside the callee.
- Returning an owning value extends the alias set with the caller’s destination binding.
- Storing an owning value in a container (
[]T, struct field, dynamic array) extends the alias set with the container slot. The container slot is now a member of the same set.
Alias sets are an analysis construct only. They do not affect the runtime representation: an owning value is just its pointer, slice header, or string header. The compiler uses alias sets to decide where to insert cleanup.
func consume(w World) { ... } // receives an alias
func process(w &World) { ... } // borrows
var world = World{ ... }
process(&world) // borrow: world still usable
process(&world) // still fine
consume(world) // alias extended into 'consume'; world still usable here
process(&world) // ok: world is still in scope and LiveReturns extend the alias set into the caller:
func makeWorld() World {
var w = World{ ... }
return w // alias set extended into the caller's binding
}
var w = makeWorld() // w joins the alias set started inside makeWorldContainers extend the alias set into a container slot:
var entity = alloc(Entity{ name = "player" })!
var entities []*Entity
entities.append(entity) // alias set now includes the container slot
println("${entity.name}") // ok: entity is still a live aliasAfter a container insertion, the original binding remains usable. The compiler will not auto-free it independently: the alias set is freed once, when the last live member goes out of scope.
Automatic free at scope exit
When the last live member of an alias set goes out of scope, the compiler inserts a cleanup call: overload free (user-declared if present, compiler-synthesized otherwise). This is the free point for the alias set.
func process() {
var data = readFile("input.txt") // alias set A starts here (string)
var world = World{ ... } // alias set B starts here
doWork(&data, &world)
// end of scope:
// - 'data' is the last live member of set A → cleanup runs
// - 'world' is the last live member of set B → cleanup runs
}Branch-divergent cleanup
When an alias set is escaped (passed into a container, returned, stored in a global) on some branches but not others, the compiler synthesizes a hidden bit flag for the local binding and conditionally frees based on the flag:
func maybeStore(world World, store bool, entities &[]World) {
if store {
entities.append(world) // alias set extends into entities; no local free
}
// if !store: 'world' is the last live member → cleanup runs here
}This bit flag is a per-scope synthesized boolean, dead-code-eliminated when the compiler can prove the escape is unconditional or never happens.
Loops
When an owning value is created inside a loop body, its alias set is fully scoped to the iteration. Cleanup runs at the end of each iteration:
for i in <10 {
var buf = alloc([]u8, 4096)!
doWork(&buf)
// buf is freed at end of iteration (last live member)
}No conditional or partial frees at the type level
Alias-set tracking does not split sets across fields. Field-level access remains read-only with respect to ownership: you cannot remove a field from a struct’s alias set, and you cannot free one field of a live struct. The whole struct’s alias set is freed as a unit when the struct’s last live alias exits scope. The struct’s overload free (synthesized or user-declared) handles the field-level cleanup.
Explicit early free
Calling free(x) ends an alias set immediately, regardless of remaining scope. The intrinsic runs overload free, then deallocates. After the call, every member of the alias set (including aliases reachable through other bindings or container slots) is considered freed. Subsequent use of any member is a compile error.
var data = readFile("input.txt")
doWork(&data)
free(data) // explicit early free
// println("${data}") // compile error: data was freedWhen a binding has already been freed explicitly, the compiler skips the implicit free at scope exit for the entire alias set. The intrinsic is the cleanup and the auto-free machinery does not double-fire.
defer remains useful for non-cleanup tasks (logging, releasing locks, flushing buffers) and for forcing a precise free point in code where the implicit free would happen too late. Idiomatic Conjure does not use defer free(x) for resource cleanup, allowing the implicit free to handle that case.
Disabling auto-free (@noautofree)
Functions and modules can opt out of auto-free by applying the @noautofree annotation. Inside a @noautofree function, the compiler emits no implicit cleanup at scope exit. Every owning binding must be explicitly handed off (returned, stored in a container, passed to a consuming function) or explicitly freed with free() or defer free(x). Failure to do either is a compile error, not a leak.
@noautofree func encodeFrame(buf *AudioBuf) {
var scratch = alloc([]f32, 1024)!
defer free(scratch) // mandatory: no auto-free in @noautofree scopes
process(&scratch, buf)
}@noautofree exists because some scopes cannot tolerate compiler-inserted cleanup:
- Interrupt and signal handlers. A hidden
free()at scope exit may block on an allocator mutex. - Realtime audio callbacks. Deterministic timing requires that no allocator code runs unannounced.
- Lockstep simulations and rollback netcode. Cleanup ordering must match across replays and across compiler revisions.
@noautofree applies transitively to nested blocks within the function but does not propagate to called functions: a regular function called from a @noautofree scope still uses auto-free internally. Library boundaries are stable.
Whole-program builds that disable auto-free everywhere are intentionally not provided. A global flag would split the source ecosystem in two and break library compatibility. The annotation is per-function so that performance-critical or determinism-critical sections opt out without affecting the rest of the program.
Borrows into owning containers
Taking the address of an element inside an owning container produces a reference (&T), not a pointer (*T). The container still owns the underlying storage; the address operator only borrows it. The compiler chooses the type from the operand: addresses of locals, struct fields, array elements, slice elements, and dynamic-array elements all yield &T.
var items = []i32{ 10, 20, 30, 40, 50 }
var item = &items[2] // type: &i32 (borrow into items' storage)
println("${item}") // 30The same rule applies to fields and chained accesses:
struct Player { health i32, score u32 }
var p = Player{ health = 100, score = 0 }
var health = &p.health // &i32: borrow into p
*health = 75 // ok: writes through the borrow*T is reserved for owning values returned by alloc() (or other resource-producing intrinsics). The & operator never produces *T. This means:
&items[i]is&i32, a borrow, subject to the escape rules in Reference escape rules.alloc(i32{})is*i32, an owning pointer that participates in alias-set tracking.- A
*Tcan narrow to a&Timplicitly (see Pointer and reference interop); the reverse requires an explicit cast and is almost never correct.
Borrows do not extend the container’s alias set
An &T taken from inside a container is treated as a borrow rooted at the container itself. The container’s auto-free point is unaffected. If the container is freed while a borrow into it is still live (returned, stored in a field, captured by a generator), the reference escape rules reject the program at compile time.
var items = []i32{ 10, 20, 30 }
var item = &items[1] // &i32, borrow into items
// items is the alias-set root; item is just a borrow.
// At scope exit, items is freed. 'item' goes out of scope first by lexical
// order, but the borrow could not have outlived items in any case because
// the escape rules forbid it.Indexing a slice yields a borrow of the underlying buffer
A slice ([:]T) does not own its backing storage; an & into a slice element produces a borrow whose provenance traces back to whatever owns the buffer the slice was taken from. The escape rules use that provenance, not the slice variable, when deciding whether the borrow may escape.
Reference escape rules
A reference (&T) is escapable only if it derives, by a chain of field accesses and indexings, from a parameter of the current function. The compiler rejects:
- Assigning a non-escapable reference to a global, struct field, dynamic array, or slice.
- Returning a non-escapable reference from a function.
- Storing a non-escapable reference in generator state.
The check is flow-insensitive and uses syntactic provenance only. No lifetime annotations are needed.
func getRef(items &[]i32) &i32 {
return &items[0] // ok: derives from parameter
}
func bad() &i32 {
var x = 42
return &x // error: x is local, reference cannot escape
}1 | return &x | ^^ Cannot return a reference to local variable 'x'. References can only escape a function if they derive from a parameter. 'x' is stack-allocated and will be freed when this function returns.
Memory Model
Conjure’s memory model sits close to C: allocation is explicit, lifetimes are managed through ownership and auto-free (see Ownership and Auto-Free), and there is no garbage collector. What Conjure adds over C is a set of distinct pointer types that make ownership and nullability visible at the type level, built-in allocation intrinsics with type-safe dispatch, compiler-inserted cleanup at scope exit, and debug-mode instrumentation for leak detection and use-after-free.
Pointer types
| Spelling | Role | Deref | Arithmetic | Nullable | Typical use |
|---|---|---|---|---|---|
*T | Typed pointer | *p / auto | No | No | Heap allocations, out-params |
&T | Borrowed reference | Implicit | No | No | Function parameters, read/write access |
?*T | Nullable pointer | Unwrap first | No | Yes | Optional heap data, C interop returns |
?&T | Nullable reference | Unwrap first | No | Yes | Optional borrowed data |
rawptr | Untyped pointer | *p, p[i] | Yes | Yes | C interop, allocation internals |
*T (typed pointer)
Points to one value of type T on the heap or in another data structure. *T is non-nullable: assigning null to a *T is a compile error. Use ?*T when a pointer may be absent. Supports auto-deref for field access (ptr.field works without explicit *). Does not support pointer arithmetic or indexing.
&T (reference)
A borrowed, non-null pointer. Auto-derefs implicitly: ref.field works directly. The address-of operator (&) on a local, field, or container element produces &T, never *T (see Borrows into owning containers). References follow the escape rules defined in Reference escape rules: they can only escape a function if they derive from a parameter.
?*T, ?&T (nullable pointer/reference)
The optional wrapper on pointer types. Pointer-sized because the null bit pattern represents absence. Must be unwrapped before dereferencing (see Optionals for unwrapping mechanisms: else, !, if narrowing).
rawptr (untyped pointer)
Alias for *u8 (equivalent to void* in C). Unlike *T, rawptr is nullable because it is primarily used for C interop and low-level allocation where null is a valid sentinel. Supports arithmetic and indexing for low-level buffer manipulation. Explicit cast required to convert to typed pointers. Extern functions that return pointers must use nullable types (?rawptr, ?*T) to make nullability explicit (see Extern functions).
Non-nullable pointer enforcement
Struct fields, data enum payloads, and variables of type *T must be initialized with a valid (non-null) pointer. Zero-initializing a struct that contains non-nullable pointer fields is a compile error:
struct World {
entities []Entity
camera *Camera // non-nullable: must always point to a valid Camera
}
var w = World{ entities = []Entity{}, camera = alloc(Camera{}) } // ok
var w World // error: cannot zero-initialize; 'camera' is non-nullable1 | var w World | ^^^^^ Cannot zero-initialize 'World' because field 'camera' has non-nullable pointer type '*Camera'. All non-nullable pointer fields must be explicitly initialized. = hint: field 'camera' declared at world.cjr:4
1 | var p *Node = null | ^^^^ Cannot assign 'null' to type '*Node'. Typed pointers are non-nullable. Use '?*Node' if the pointer may be absent.
Extern functions and nullable pointers
C functions may return null pointers. The compiler enforces that extern function declarations returning pointer types use nullable types (?*T, ?rawptr), since the compiler cannot verify that C code upholds a non-null guarantee. See Extern functions for details and diagnostics.
extern "malloc" func cMalloc(size usize) ?rawptr // C malloc can return NULL
extern "SDL_CreateWindow" func createWindow(...) ?*Window // may fail and return NULL
var win = createWindow(...) else panic("Failed to create window.")Pointer and reference interop
A pointer can narrow to a reference, but a reference cannot widen to a pointer:
var p *u32 = alloc(u32{})
var r &u32 = p // ok: pointer → reference
// var q *u32 = r // error: reference → pointer not allowedNon-nullable pointers implicitly convert to their optional counterparts:
var p *Node = alloc(Node{})
var q ?*Node = p // ok: *T implicitly wraps to ?*Trawptr can be converted to and from typed pointers with, and without, explicit casts.
var buf = alloc(64) // rawptr
var ints = buf.(*i32) // rawptr → *i32 (explicit cast)
var floats *f32 = buf // no cast needed: rawptr → *f32 (compiler can infer)alloc, free, and realloc (compiler intrinsics)
alloc, free, and realloc are built-in functions available without imports. The compiler dispatches based on the argument type and count, enabling type-safe allocation and debug instrumentation.
Allocation forms
alloc returns a fallible type. On allocation failure, it returns the OutOfMemory fault. The caller handles the fault using try (propagate), ! (panic), or else (default):
var ptr1 = alloc(64) // !rawptr: 64 raw bytes
var ptr2 = alloc(MyStruct{a = 1, b = 2}) // !*MyStruct: initialized on heap
var ptr3 = alloc(MyStruct) // !*MyStruct: zero-initialized on heap
var ptr4 = alloc([]MyStruct, 10) // ![]MyStruct: dynamic array with cap=10The compiler dispatches based on the argument:
| Form | Returns | Description |
|---|---|---|
alloc(T) | !*T | Allocate and zero-initialize |
alloc(T{...}) | !*T | Allocate and copy the value to the heap |
alloc(n usize) | !rawptr | Allocate n raw bytes |
alloc([]T, cap u32) | ![]T | Allocate a dynamic array with preallocated capacity |
var ptr = alloc(MyStruct)! // panic on OOM (common in game code)
var ptr = try alloc(MyStruct) // propagate OOM to caller
var ptr = alloc(MyStruct) else return OutOfMemoryfree and early free
free() performs an explicit early free on its argument’s alias set. After free(ptr), every member of the set (other bindings, container slots) is considered freed, and any subsequent use is a compile error. The auto-free machinery does not double-fire at scope exit for a set that has already been freed.
var world = alloc(World{ name = "main" })!
doWork(&world)
free(world) // explicit early free of world's alias set
// world.name // compile error: world was freed1 | world.name | ^^^^^ Cannot use 'world' after it was freed. 'world' was freed by 'free(world)' at line 3. After an explicit free, every alias of the freed value is invalid.
The free intrinsic invokes the type’s overload free (user-declared or compiler-synthesized) and then performs whatever post-overload cleanup the dispatch table prescribes: deallocate the pointee for *T, release the backing buffer for []T, no further work for value types, release raw bytes for rawptr.
free() is rarely needed in idiomatic Conjure. Auto-free at scope exit (see Automatic free at scope exit) handles the common case. Reach for an explicit free() only to free earlier than the enclosing scope ends, or inside @noautofree scopes (see Disabling auto-free (@noautofree)) where auto-free is disabled.
Freeing nullable pointers
free() also accepts ?*T. When the value is null, the call is a no-op. When non-null, the memory is freed. In both cases, the alias set is ended.
Defense in depth
In debug builds, free() also poisons the freed memory with a sentinel pattern (0xDE) as an additional safety net in case alias-set checks are bypassed via unsafe casts. In sanitized builds, full allocation tracking detects double-free and buffer overflows (see Debug instrumentation).
realloc
realloc resizes an existing allocation. It has two dispatched forms:
| Form | Returns | Description |
|---|---|---|
realloc(ptr *T, newCount usize) | !*T | Reallocate a typed pointer; ends the old alias set, returns a new one |
realloc(arr []T, newCap u32) | !void | Grow a dynamic array’s backing buffer in place |
For the pointer form, realloc ends the old pointer’s alias set (the underlying memory may move) and starts a new alias set with the returned pointer. The caller must use the returned pointer:
var buf = alloc(64)!
buf = realloc(buf, 128)! // old alias set ends, new one starts with returned ptrFor the dynamic array form, realloc grows the backing buffer in place:
var items = alloc([]i32, 16)!
realloc(items, 256)! // grow capacity to 256Arena support
alloc accepts an optional allocator as the last argument:
import std/memory
var arena = memory.new(1024 * 1024)
var ptr = alloc(MyStruct, arena) // allocates from arena
var arr = alloc([]f32, 100, arena) // array from arena
arena.reset() // free everything at onceCompiler optimizations
Because alloc and free are intrinsics, the compiler can:
- Elide allocations when escape analysis proves the value does not outlive the stack frame.
- Batch allocations when multiple
alloccalls in the same scope have known sizes. - Devirtualize arena calls when the concrete allocator type is visible at the call site.
1 | var x = MyStruct{ a = 1 }\n free(&x) Cannot free a stack-allocated value. 'free()' can only be called on heap-allocated pointers returned by 'alloc()'.
panic, assert, and unreachable
panic terminates the program with a diagnostic message. In debug builds, it prints a stack trace and aborts. In release builds, it exits with the given exit code. If you wish to dump the error message in release builds, you can use the --always-print-panics CLI option or alwaysPrintPanics config option.
panic("Index out of range.", 0)The second argument is an optional exit code. When zero, the runtime computes a deterministic hash of the message and uses that as the exit code, giving every distinct panic a stable exit value.
assert evaluates a condition and panics if it is false. In release builds, assertions are removed unless the user opts in with a build flag:
assert(index < len, "Index ${index} out of bounds for length ${len}.")
assert(count > 0) // message is optionalBoth panic and assert work at compile time within compile-time contexts, producing compile-time errors instead of runtime aborts.
unreachable is a keyword that marks a code path as provably impossible. It has type never and can appear anywhere an expression is expected:
func classify(x i32) string {
when x {
case 1: return "one"
case 2: return "two"
case 3: return "three"
}
unreachable // caller guarantees x is 1, 2, or 3
}In debug builds, reaching unreachable panics with a diagnostic that includes the source location. This catches logic errors during development.
In release builds, the compiler treats unreachable as an optimization hint. The backend (LLVM, C) is told this code path cannot execute, which enables dead code elimination, branch folding, and other optimizations. If the code path is reached in a release build, the behavior is undefined.
unreachable differs from panic in intent and behavior. panic means “this is a recoverable error condition that should abort.” unreachable means “this code path is logically impossible and the programmer asserts it will never execute.” The compiler may use unreachable to optimize surrounding code in ways that panic does not enable.
panic: Index out of range.
at src/game/world.cjr:42 in updateEntities()
at src/game/main.cjr:15 in main()panic: Reached unreachable code.
at src/game/state.cjr:28 in classify()
at src/game/main.cjr:10 in main()hashof
hashof is a compiler intrinsic that produces a u64 hash value for any value. The compiler generates an optimal hash function based on the type’s structure:
var h = hashof(user) // recursive field hash for User
var h2 = hashof("hello") // known string hash
var h3 = hashof(42) // integer mixDefault behavior per type:
- Integers: splitmix64 mixing function.
- Floats: canonicalized before hashing:
+0.0and-0.0hash to the same value, and all NaN bit patterns hash to a single canonical NaN. This keepshashofconsistent with==for hash table use. The canonical value is then bit-cast to integer and mixed. - Strings: FNV-1a or wyhash.
- Structs: recursive
hashCombineover all fields in declaration order. - Enums: hash the tag value; for data enums, also hash the payload.
- Arrays/slices: hash each element sequentially.
Types can override the default hash by declaring overload hash (see Overload Declarations section).
embed
embed() is a compiler intrinsic that includes file contents as a compile-time constant. It is memoized across builds: if the same file is embedded multiple times, it is read once and shared.
Basic forms:
const ICON = embed("assets/icon.png") // [:]u8 (raw bytes)
const SHADER = embed("shaders/basic.glsl", string.fromBytes) // string (validated UTF-8)
const MODEL = embed("assets/hero.gltf", gltf.parse) // gltf.Model (processed)Path resolution:
- Default: paths are relative to the project root.
./or../prefix: relative to the current source file.- Absolute paths: used as-is.
const A = embed("assets/icon.png") // <project_root>/assets/icon.png
const B = embed("./local_data.bin") // relative to this source fileThe LSP provides file path autocompletion inside embed() strings.
Processing functions (loaders):
The second argument to embed() is an optional loader function. It receives [:]u8 (the raw file contents) and returns any serializable type. The loader runs at compile time via the comptime harness, regardless of whether it is marked @comptime:
const MODEL = embed("assets/hero.gltf", gltf.parse) // gltf.Model
const CONFIG = embed("config/game.toml", toml.parse) // toml.Table
const ATLAS = embed("assets/atlas.json", atlas.build) // atlas.SpriteSheetThe loader function signature must be func(data [:]u8) T for any concrete return type T. The return type of the embed() call becomes T.
Return types:
| Form | Returns |
|---|---|
embed(path) | [:]u8 (immutable slice into read-only data) |
embed(path, loader) | Whatever loader returns |
To get a string from embedded data, use string.fromBytes as the loader. string.fromBytes takes [:]u8, validates UTF-8, NUL-terminates, and returns a string (tag 0, literal).
The compiler tracks all embedded file paths to support incremental builds. If any embedded file changes on disk, the modules that embed it are invalidated and recompiled.
Files are deduplicated: embedding the same file from multiple modules includes the data once in the final binary.
1 | const shader = embed("shaders/missing.glsl") | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Cannot embed file 'shaders/missing.glsl'. File not found. Searched relative to project root: /home/user/my-game/
String Interpolation
String interpolation uses ${} delimiters within string literals. The compiler dispatches to type-appropriate formatters at compile time.
Syntax
var name = "Alice"
var age = 30
var greeting = "Hello, ${name}! You are ${age} years old."Format specifiers follow the expression after a colon:
var pi = 3.14159
var s1 = "Pi is approximately ${pi:.2f}" // "Pi is approximately 3.14"
var s2 = "Hex: ${value:08x}" // "Hex: 0000ff42"
var s3 = "Padded: ${count:>5}" // "Padded: 42"Type dispatch
The compiler generates formatting code per type at each interpolation site:
- Primitives (
i32,f64,bool): compiler-generated formatters. string: identity (inserted directly, no conversion).- Structs: default format is
TypeName{field1 = value1, field2 = value2, ...}. Override withoverload format. - Enums: default format is the variant name. Data enums include the payload.
- Pointers: format as hex address.
Types can customize their formatting by declaring overload format (see Overload Declarations section).
Allocation model
String interpolation produces a string (tag 1, heap). The result is an owning type and participates in alias-set tracking (see Ownership and Auto-Free). The compiler manages the allocation strategy:
- Immediate consumption. If the result is passed directly to
print/println, the compiler writes formatted output directly to the stdout buffer. No string allocation occurs. - Stack-local. If the result is assigned to a local variable that does not escape, the buffer can be stack-allocated (subject to the same size limits as other stack values).
- Heap-allocated. If the result escapes (returned, stored in a struct, appended to a collection), the string is heap-allocated as a tag-1 heap string.
// No allocation: writes directly to stdout
println("Hello, ${name}!")
// Stack-allocated (does not escape)
var msg = "Error at line ${line}"
println(msg)
// Heap-allocated (escapes via return)
func formatError(code i32) string {
return "Error ${code}" // caller owns this string
}Type Casts
Conjure uses dot-parenthesized syntax for explicit type casts: value.(TargetType). Casts are deterministic: they either succeed at compile time or fail with a diagnostic.
var a = 3.14.(i32) // float to integer (truncates)
var b = 42.(f64) // integer to float
var c = 256.(u8) // truncating narrow cast
var d = x.(i64) // widening castPointer casts:
var buf = alloc(64) // rawptr
var ints = buf.(*i32) // rawptr to *i32Implicit conversions
The following conversions happen automatically without a cast:
- Integer widening (smaller to larger, same signedness):
i8toi32,u16tou64. - Untyped integer literal into any sized integer or float.
- Untyped float literal into any float type.
*Tto&T(pointer narrows to reference).Tto?T(value wraps to optional).*Tto?*T,&Tto?&T(non-nullable wraps to nullable).rawptrto any typed pointer (*T).string,[]T, and[:]Ttorawptr(see Container-to-rawptrcoercion below).
Container-to-rawptr coercion
A value of type string, []T, or [:]T implicitly converts to rawptr when the expected type is rawptr. The compiler emits a load of the value’s data field and discards the length and capacity. All three container types share the same field name (string.data, []T.data, [:]T.data), so the rule is uniform regardless of which container the source value has. This conversion exists to make C-ABI calls and other pointer-plus-length APIs ergonomic to invoke without manually reaching into the container’s internals.
extern "memcpy" func memcpy(dst rawptr, src rawptr, n usize) rawptr
var src = "hello"
var dst []u8
dst.resize(src.len)
memcpy(dst, src, src.len) // dst → dst.data, src → src.dataThe coercion only fires when the expected type is exactly rawptr. It does not apply to typed pointers (*T), does not apply to ?rawptr (assign null explicitly or unwrap first), does not participate in type inference, and does not change ownership: the resulting rawptr borrows the container’s backing storage, so the source value must outlive any callee that retains the pointer. Passing an empty string, []T, or [:]T produces a null rawptr; callees that dereference unconditionally must check the length separately.
Explicit cast required
- Float to integer:
3.14.(i32)(truncates toward zero). - Integer narrowing: compile error, requires explicit cast (
x.(u8)). - Sign change: compile error, requires explicit cast (
x.(i32)from unsigned, or vice versa). - Between unrelated pointer types: cast through
rawptr. distincttypes:myId.(u32),val.(UserId).stringtocstring: use.cstr()(not a cast).
Mixed expression promotion
In binary expressions with operands of the same signedness, the result is the wider type. Narrowing in a binary expression requires an explicit cast. Mixing signed and unsigned types in a binary expression is a compile error:
1 | var z = x + y | ^ Cannot mix signed and unsigned types in arithmetic expression. Left operand is 'i32', right operand is 'u32'. Cast one operand to match: 'x.(u32)' or 'y.(i32)'.
Untyped literals are unchanged and still infer to context per Untyped literals and uint, int, float.
1 | var n = myString.(i32) | ^^^^^^^^^^^^^^ Cannot cast 'string' to 'i32'. No conversion exists between these types. Use 'fmt.parseInt(s)' to parse a string as an integer.
1 | var b = 300.(u8) | ^^^^^^^^ Truncating cast from 'i32' to 'u8'. The value 300 does not fit in 8 bits and will be truncated to 44.
Generators
Generators are stackless coroutines that the compiler transforms into state machines. They provide custom iteration, frame-based game coroutines, and building blocks for async patterns. There is no runtime scheduler or implicit heap allocation.
Declaration and yield
A generator function uses gen[T] as its return type and yield statements in the body:
func fibonacci() gen[u32] {
var a u32 = 0
var b u32 = 1
while true {
yield a
var temp = a
a = b
b = temp + b
}
}The compiler transforms this into a struct that holds the function’s local state and a resume entry point. Each call advances to the next yield. No stack is allocated for the generator; all state lives in the struct.
Calling generators
Generators are called like functions. Each call returns ?T (the next yielded value, or null when exhausted):
var fib = fibonacci()
var val = fib() // ?u32: first value (0)
var val2 = fib() // ?u32: second value (1)
// when exhausted: fib() returns nullThe .done read-only property indicates whether the generator has completed:
while !fib.done {
var n = fib()! // ! unwraps the ?u32
println("${n}")
}For loop integration
for loops automatically call generators and check for exhaustion:
for n in fibonacci() {
if n > 1000 { break }
println("${n}")
}This desugars to:
var _gen = fibonacci()
while true {
var _val = _gen() // call generator, returns ?T
if _val { // null-check (narrowing)
var n = _val // narrowed to T
if n > 1000 { break }
println("${n}")
} else {
break // generator exhausted
}
}An empty generator (one that yields nothing) iterates zero times because the first call returns null.
Implicit delegation
When a generator yields an expression whose type is itself a gen[T] of the same yield type, the compiler delegates automatically:
func dialog() gen[string] {
yield "Hello" // direct: string
yield presentChoices() // delegate: gen[string]
yield "Goodbye" // direct: string
}
func presentChoices() gen[string] {
yield "[1] Accept"
yield "[2] Decline"
}The rule: when the function yields T and yield expr is used:
- If
typeof(expr) == T: direct yield (produce the value). - If
typeof(expr) == gen[T]: delegate (pump the sub-generator until done, then continue).
The sub-generator’s state is stored inline in the parent’s state struct. No heap allocation.
Void generators (game coroutines)
Generators that yield void are used for frame-based game logic. Each yield suspends until the next call. Calling returns ?void (null when done):
func attackSequence(enemy &Enemy) gen[void] {
enemy.playAnimation("windup")
yield // wait one frame
yield // wait another frame
enemy.playAnimation("strike")
checkHitbox(enemy)
for _ in <30 { yield } // wait 30 frames
enemy.playAnimation("recover")
}
// Game loop ticks the generator each frame:
var seq = attackSequence(&enemy)
while !seq.done {
seq() // advance one step per frame
}Lifetime and single-use
Generators are single-use. Once exhausted (.done == true), calling them again returns null. There is no restart mechanism; create a new generator instead.
Generators that capture &T references cannot outlive the referenced data. The compiler tracks this and prevents storing such generators in struct fields or returning them from functions that would outlive the borrow:
func iter(data &[]i32) gen[i32] { ... }
var arr = []i32{1, 2, 3}
var g = iter(&arr) // g cannot outlive arr1 | return g | ^^^^^^^^ Generator 'g' captures a reference to 'arr', which is stack-allocated. The generator cannot be stored in a struct field or returned from this function because 'arr' would be freed before the generator completes.
Type Introspection
Type introspection is accessed through the typeinfo(T) function, which returns an introspection record for any type. Convenience shortcuts sizeof(T), alignof(T), and nameof(T) are provided for common queries. Numeric type constants (i32.MAX, f32.EPSILON, etc.) remain on the numeric type name as a special case, restricted to built-in numeric types only.
typeof()
typeof() has two uses: extracting the type of an expression, and using a type as a value.
Type of an expression
When called with a value expression, typeof() returns the compile-time type. The expression is not evaluated:
var x = 42
const T = typeof(x) // i32
const U = typeof("hello") // stringThis is useful for declaring variables with the same type as another variable:
var x = 42
var y typeof(x) // y has type i32, zero-initialized
var z typeof(x) = 100 // z has type i32, initialized to 100typeof() preserves wrappers: if x is *Vec3, typeof(x) is *Vec3.
typeof() on a type
When called with a type name instead of an expression, typeof() returns the generic type type. This means typeof(i32) is type, not i32. typeof() does not act as an identity function on types.
typeinfo()
typeinfo(T) returns a compile-time introspection record for any type. When called with a value expression instead of a type, the compiler infers the type automatically: typeinfo(myVec) is equivalent to typeinfo(typeof(myVec)). The record provides the following properties:
| Property | Returns | Description |
|---|---|---|
.size | usize | Size of the type in bytes |
.align | usize | Alignment requirement in bytes |
.name | string | Human-readable name of the type |
.fields | array | Array of field descriptors (structs) |
.fieldCount | u32 | Number of fields (structs) |
.variants | array | Array of variant descriptors (enums) |
.variantCount | u32 | Number of variants (enums) |
.annotations | array | Array of annotations on the type |
.maxOrdinal | u32 | Max enum ordinal |
.kind | TypeKind | The kind of the type (.Struct, .Enum, etc.) |
.isOwning | bool | Whether the type is an owning type (see Owning types vs value types) |
const pointSize = typeinfo(Point).size // 8 (two f32s)
assert(typeinfo(Point).align == 4)
const colorCount = typeinfo(Color).variantCount // 3For compound types, parentheses disambiguate the type expression:
const optSize = typeinfo(?i32).size // 5 (4 bytes + 1 tag byte)
const sliceSize = typeinfo([:]f32).size // 16 (data + len)When working with type parameters in templates, typeinfo(T) works directly on the parameter name.
Field reflection in templates
typeinfo() is commonly used in template modules for serialization, hashing, and debug printing:
// std/json.cjr
template[T type]
func serialize(value &T) string {
var buf = strings.new()
buf.appendString("{")
for i, field in typeinfo(T).fields {
if i > 0 { buf.appendString(", ") }
buf.appendString("\"${field.name}\": ")
// dispatch based on field type...
}
buf.appendString("}")
return buf.toString()
}Shortcut introspection functions
The compiler provides shortcut functions for common type queries:
| Function | Equivalent to | Description |
|---|---|---|
sizeof(T) | typeinfo(T).size | Size of the type in bytes |
alignof(T) | typeinfo(T).align | Alignment requirement in bytes |
nameof(T) | typeinfo(T).name | Human-readable name of the type |
Like typeinfo() itself, these accept both type names and value expressions. When called with a value, the compiler infers the type: sizeof(myVec) is equivalent to sizeof(typeof(myVec)).
Numeric type constants (i32.MAX, f32.EPSILON, etc.) remain on the numeric type name as a special case. This is restricted to built-in numeric types only; users cannot declare T.X for their own types. See Numeric Math for the full list.
Annotations
Annotations are metadata attached to declarations and fields using the @name syntax. Some annotations are built-in; others can be user-defined.
Built-in annotations
| Annotation | Applies to | Effect |
|---|---|---|
@protected | Fields | Field is readable outside module, writable only inside |
@inline | Functions | Hint to inline the function at call sites |
@comptime | Functions | Function runs only at compile time |
@noautofree | Functions | Disable auto-free in scope; require explicit free() (see Disabling auto-free (@noautofree)) |
@nopanic | Functions | Reject any code path that can panic (see panic, assert, and unreachable) |
@tag | Structs | Marks a struct as an annotation type |
@packed | Structs | Removes all inter-field padding (see Struct layout annotations) |
@optimize | Structs | Allows compiler to reorder fields for minimum size (see Struct layout annotations) |
@inline func fastAdd(a i32, b i32) i32 { return a + b }
struct Player {
@protected health i32 // read-only from outside
name string
}Custom annotations
Custom annotations are structs marked with @tag. They can carry typed data and are accessible via compile-time reflection:
@tag(targets = {.FIELD})
struct maxLength { value u32 }
struct UserInput {
@maxLength(value = 256)
name string
}Annotations from other modules require importing the module:
import std/json
struct User {
@json.field(name = "first_name")
firstName string
}Annotation values are accessible at compile time through typeinfo(T).fields[i].annotations, enabling template modules to generate code based on annotation metadata.
Compile-Time Execution
Conjure runs compile-time code in three layers, ordered by cost and capability:
Constant folding
The compiler evaluates simple expressions inline: primitive arithmetic, literal construction, if/when with a known discriminator. No process spawn. This powers const declarations and dead-branch elimination:
const MAX_SIZE = 1024 * 1024 // folded to 1048576
const IS_DEBUG = DEBUG and OS == "linux"@comptime functions
Functions marked @comptime run only during compilation. The compiler builds a native binary (the “comptime harness”) from all comptime roots and executes it. Results are serialized back into the build as static data:
@comptime
func buildTrie(words []string) Trie {
var trie = Trie{}
for w in words { trie.insert(w) }
return trie
}
const WORDS = []string{"apple", "apricot", "banana"}
const DICT = buildTrie(WORDS)Because the harness is native code, comptime executes at full runtime speed. Any function can be called from comptime context, including I/O, file access, and network operations. This enables asset processing, shader compilation, and code generation at build time.
Loaders passed to embed() are also executed in the comptime harness, allowing complex processing of embedded data.
Safety limits
Comptime execution is sandboxed with configurable limits:
- Memory ceiling: caps total comptime allocations.
- Wall-clock timeout: aborts unbounded work.
- Instantiation depth: limits recursive template instantiation (default: 64).
All limits produce diagnostics that point to the outermost comptime root involved.
1 | const DICT = buildTrie(WORDS) | ^^^^^^^^^^^^^^^^ Compile-time execution timed out after 30 seconds. The comptime function 'buildTrie' did not complete within the allowed time. This may indicate an infinite loop. The timeout can be configured in conjure.toml.
Syntax Conventions
Semicolons
Conjure uses automatic semicolon insertion. Newlines act as statement terminators when the preceding token can end a statement (identifiers, literals, ), }, ]). Explicit semicolons are allowed but rarely needed.
Inside parentheses () and brackets [], newlines do not insert semicolons. This allows multi-line expressions, function calls, and array literals to span lines naturally.
Comments
Only single-line comments are supported:
// This is a comment
// This is a doc comment (used by the language server for hover documentation)Uninterrupted line comments are automatically associated with a following declaration (provided there is no blank line in between) and displayed by the LSP on hover.
Trailing comments after struct fields, enum members, and union variants are also supported and attached to the respective symbol. Leading comments take precedence over trailing comments when both are present for a symbol.
Block comments (/* */) are not supported.
Logic keywords
Conjure uses keyword-based logical operators:
| Keyword | Equivalent | Description |
|---|---|---|
and | && | Logical AND |
or | || | Logical OR |
not | ! | Logical NOT |
The symbolic forms (&&, ||) are not supported. ! is supported as the prefix not operator as well as not.
Reserved keywords and identifiers
Keywords are recognized by the lexer and are part of the grammar. They cannot appear as identifiers anywhere:
and as break case const continue defer
distinct else enum extern false fault for
func if import in link not null
or overload return struct template test true
try union unreachable var when while yieldReserved built-in identifiers are recognized as regular identifiers by the parser but intercepted during semantic analysis. They cannot be shadowed by user declarations, but the parser treats their use as regular call expressions:
alloc alignof assert embed free hashof nameof panic
realloc sizeof typeinfo typeofNumeric Math
Numeric math operations are type-namespaced static functions on their respective types. The compiler provides these via built-in extensions on each numeric type and emits optimal code per type (FPU instructions for floats, conditional moves for integers, etc.). No imports required.
var a = i32.min(3, 5) // 3
var b = f32.sqrt(2.0) // 1.41421...
var c = f64.clamp(x, 0.0, 1.0) // clamp to range
var d = i32.abs(-42) // 42Type limits
Every numeric type exposes compile-time constants for its range and precision. These are type-namespaced and available without imports:
var maxInt = i32.MAX // 2147483647
var minByte = u8.MIN // 0
var bits = i64.BITS // 64
var eps = f64.EPSILON // 2.220446049250313e-16
var biggest = f32.MAX // 3.40282346638528860e+38Integer type constants:
| Constant | Description |
|---|---|
T.MAX | Largest value (e.g. 2147483647 for i32, 255 for u8) |
T.MIN | Smallest value (e.g. -2147483648 for i32, 0 for u8) |
T.BITS | Number of bits (e.g. 32 for i32, 8 for u8) |
Float type constants:
| Constant | Description |
|---|---|
T.MAX | Largest finite value |
T.MIN | Smallest positive normal value (subnormals go smaller) |
T.EPSILON | Difference between 1.0 and the next representable value |
Universal mathematical constants (PI, TAU, E, etc.) live in std/math and require an import.
Operations per type
| Operation | Integer types | Float types | Notes |
|---|---|---|---|
.min(a, b, ...) | All | All | Variadic, returns minimum |
.max(a, b, ...) | All | All | Variadic, returns maximum |
.abs(x) | Signed only | All | Absolute value |
.clamp(x, lo, hi) | All | All | Composes min/max |
.sign(x) | Signed only | All | Returns -1, 0, or 1 |
.floor(x) | All | Round toward -infinity | |
.ceil(x) | All | Round toward +infinity | |
.round(x) | All | Round to nearest | |
.sqrt(x) | All | Square root | |
.sin(x), .cos(x), .tan(x) | All | Trigonometric | |
.pow(x, y) | All | Exponentiation | |
.log(x), .log2(x) | All | Logarithms | |
.lerp(a, b, t) | All | Linear interpolation | |
.inverseLerp(a, b, value) | All | Inverse of lerp | |
.remap(v, fLo, fHi, tLo, tHi) | All | Range remapping | |
.smoothstep(edge0, edge1, x) | All | Hermite S-curve | |
.step(edge, x) | All | Hard 0/1 threshold |
Calling a math function on the wrong type category is a compile error:
1 | var r = i32.sqrt(16) | ^^^^^^^^ 'sqrt' is not available on integer type 'i32'. The square root function is only defined for floating-point types (f32, f64). Cast to a float first: f32.sqrt(x.(f32))
Bitwise utilities
Bitwise operations are type-namespaced on integer types, following the same pattern as arithmetic operations. No imports required:
var p = u64.popcount(0xFF00) // 8
var z = u64.leadingZeros(16) // 59
var t = u64.trailingZeros(16) // 4
var ok = u32.isPowerOfTwo(256) // true
var next = u32.nextPowerOfTwo(100) // 128Testing
Test blocks define unit tests inline with the code they test. Tests are compiled and run only in test builds (conjure test), never in release:
test "addition works" {
assert(add(2, 2) == 4)
assert(add(-1, 1) == 0)
}
test "string equality" {
var a = "hello"
var b = string.copy(ptr, 5)
assert(a == b)
}Test blocks are top-level declarations. They have their own scope and can access all symbols visible in the module. The test name is a string literal used for identification in test output.
Tests are executed by compiling a special test binary that includes all test blocks with a dispatch entry point. Each test runs in isolation, and the test runner captures panics to report failures without crashing the entire suite.
Test output:
running 12 tests from src/math/vec3.cjr
✓ addition works (0.1ms)
✓ string equality (0.2ms)
✗ division by zero (0.1ms)
panic: Division by zero.
at src/math/vec3.cjr:45 in test "division by zero"
11 passed, 1 failed, 0 skipped (2.3ms total)Tests that panic are caught and reported as failures rather than terminating the test runner. Each test runs in isolation; state from one test does not leak into another.
Compiler Architecture
The compiler uses a multi-pass architecture. The frontend runs across all modules before the backend begins, enabling circular import support. The backend only runs during conjure build / conjure run (the LSP stops after the frontend).
The frontend owns every diagnostic a user will ever see. If the frontend reports no errors, backend lowering is guaranteed to succeed. This is a load-bearing invariant for the language server: if the LSP shows no errors, the program is valid.
Pipeline
Frontend (always runs, LSP stops here):
Source → Lex → Parse → Forward-declare → Resolve types → Resolve sigs
→ Check bodies → Move and escape analysis
↑ passes 3-7 are incremental: only re-run for
modules whose source changed or whose deps
changed
Backend (conjure build / conjure run only):
→ Lower to IR → Optimize → Emit targetAlias-set and escape analysis runs inside the check-bodies phase. It assigns every owning binding to an alias set, propagates set membership through assignment, calls, returns, and container operations, and decides where to insert auto-free calls (see Automatic free at scope exit) and any conditional-free flags. It enforces the reference escape rules (see Reference escape rules) and verifies that loops maintain consistent alias-set state across iterations. The pass is per-function and intra-procedural; there is no inter-procedural lifetime inference.
Build profiles
Build profiles select the optimization and instrumentation strategy. They are orthogonal to the target, which selects the architecture and platform (see Freestanding targets).
| Feature | debug | release | sanitized |
|---|---|---|---|
| Optimization level | 0 | 2 | 0 |
| Debug info | Yes | No | Yes |
| Shadow call stack | Yes | No | Yes |
| Bounds checks | Yes | Yes | Yes |
| Nullable checks | Yes | Yes | Yes |
| Overflow checks | Panic | Wrap | Panic |
| Memory sanitization | No | No | Yes |
| Leak detection | No | No | Yes |
conjure build # debug profile, host target
conjure build --profile=release # optimized, safety checks on
conjure build --profile=sanitized # full instrumentation
conjure build --target=thumbv7em-none-eabihf --profile=release # freestanding ARMAny profile can be combined with any target. The runtime abstraction described in Runtime abstraction (cjr_*) sits below both axes: the toolchain ships a default implementation for hosted targets, and freestanding targets require the user to provide one (see Freestanding targets).
Debug instrumentation
In debug builds, the compiler emits instrumentation for runtime diagnostics:
Shadow call stack
Every function prologue pushes a frame descriptor containing the function name and source location. On panic, the runtime walks the shadow stack and prints a human-readable traceback. No DWARF parsing or attached debugger required.
Bounds checking
Array, slice, and dynamic array indexing emits a compare-and-trap before the load/store. Out-of-bounds access produces a panic with a stack trace.
Pointer poisoning (debug profile)
When free() is called in a debug build, the runtime overwrites the freed memory with a sentinel byte pattern (0xDE). Any subsequent read through a dangling pointer returns garbled data that is immediately suspicious, and any write traps. This catches use-after-free bugs without the overhead of full allocation tracking.
Memory sanitization (sanitized profile)
Tracks all allocations in a global table. Detects double-free, use-after-free, memory leaks (reported at program exit), and buffer overflow via red zones around each allocation. Use-after-free diagnostics include the original allocation site, the free site, and the invalid access site.
Runtime abstraction (cjr_*)
Conjure’s standard library is layered on a small set of C-ABI symbols. It’s a thin shim that the rest of the toolchain calls into for platform services, including allocation, output, termination, and (when used) threading, time, and file I/O. These symbols are collectively called the runtime abstraction, and they all live under the cjr_ prefix.
The runtime abstraction is the language’s only point of contact with the underlying OS or kernel. Everything above it (alloc(), free(), auto-free, println, std/fs, std/threading) is portable code that calls down through this layer for whatever it actually needs. Everything below it is platform-specific glue.
For hosted targets (those whose triple names an OS, e.g. x86_64-linux-gnu, aarch64-darwin, x86_64-windows-msvc), the toolchain ships a default implementation that wraps libc: cjr_alloc is a thin wrapper over malloc, cjr_panic formats a diagnostic and calls abort, cjr_write writes to stdout via write(2). The user never sees these symbols.
For freestanding targets (triples whose OS field is none, e.g. thumbv7em-none-eabihf, riscv32-none-elf), the default implementation is not linked. The user supplies their own and links them in. The standard library calls into the abstraction identically; any std/ module the user imports works as long as the symbols it transitively depends on are present at link time.
Core abstraction (always required):
| Symbol | Signature | Used by |
|---|---|---|
cjr_alloc | func(size usize, align usize) ?rawptr | alloc(), default global allocator |
cjr_realloc | func(p rawptr, old usize, new usize, align usize) ?rawptr | realloc() |
cjr_free | func(p rawptr, size usize, align usize) | free(), auto-free |
cjr_panic | func(msg *u8, msg_len usize, file *u8, file_len usize, line u32) never | bounds checks, nullable unwraps, panic(), unreachable |
cjr_write | func(fd i32, buf *u8, len usize) isize | println, print, std/io text output |
Optional abstraction (required only if the corresponding stdlib is imported):
| Symbol family | Pulled in by |
|---|---|
cjr_thread_* | std/threading |
cjr_mutex_* | std/threading, std/sync |
cjr_clock_* | std/time |
cjr_file_* | std/fs, std/io file operations |
cjr_env_* | std/env |
The dependency is link-time and granular: a program that does not import std/threading never references cjr_thread_*, so those symbols do not need to be implemented. The compiler tracks which cjr_* symbols each std/ module depends on and annotates link-time errors with the module that pulled in the missing symbol.
The cjr_* interface is part of Conjure’s stable ABI. New optional families may be added in minor releases as new std/ modules ship, but signatures and semantics of existing symbols are versioned alongside the language. Embedded users porting Conjure to a new target only need to track this interface, not the standard library or the language internals.
Freestanding targets
A freestanding target is one whose target triple names no operating system (the OS field is none). Examples: thumbv7em-none-eabihf (ARM Cortex-M4F), riscv32-none-elf (bare-metal RISC-V), aarch64-none-elf (ARM64 firmware). These targets are for bare-metal, embedded, kernel, and other no-OS code.
The language and standard library work the same way on freestanding targets as on hosted ones. The only difference is who provides the runtime abstraction (see Runtime abstraction (cjr_*)): the user, instead of the libc-backed default.
What the user provides:
- Implementations of the core
cjr_*symbols, plus any optional families pulled in bystd/modules they import. - An entry point appropriate to the target (
_start,Reset_Handler, etc.). Conjure does not synthesize one. - Linker script and any platform-specific section layout.
What works identically on hosted and freestanding:
- Auto-free at scope exit (see Automatic free at scope exit), including
overload freecascades. The free machinery is a compile-time transformation; it does not depend on the target. - Alias-set tracking, escape rules, bounds checks, nullable checks, overflow checks (in
wrapmode),embed(), comptime functions, template modules, generics. - Any
std/module whose transitivecjr_*dependencies are linked. @noautofree(see Disabling auto-free (@noautofree)) for functions that cannot tolerate compiler-inserted cleanup. ISRs typically combine@noautofreewith@nopanic.
Panic-free verification with @nopanic.
The @nopanic annotation declares that a function must not reach the panic runtime on any control-flow path. The compiler verifies this by walking the call graph and checking every site that could panic: bounds-checked indexing, nullable unwraps via !, overflow checks in panic mode, unreachable reached at runtime, explicit panic() and assert() calls, and calls to other functions that are not themselves @nopanic.
A @nopanic function that contains a potentially-panicking expression is a compile error. The diagnostic includes the specific operation and a suggestion (use else, use ?, use wrap-mode arithmetic, mark the called function @nopanic).
@nopanic func encodeSample(sample i16, buf &[]u8) {
buf.append(u8(sample >> 8)) // ok: explicit narrowing
// var x = arr[i] // error: indexing may bound-check panic
var x = arr[i] else 0 // ok: explicit default
}@nopanic is independent of @noautofree. The two compose: a @noautofree @nopanic function is the idiomatic shape for interrupt handlers and audio callbacks.
Standard library functions are not @nopanic by default and cannot be called from @nopanic code unless individually annotated.
Annotations are portable across targets and profiles. @noautofree and @nopanic apply per function regardless of target or profile. A function that compiles on a hosted target with these annotations behaves identically on a freestanding target. The annotations are guarantees the compiler verifies, not flags that change behavior based on how the program is built.
// portable: runs the same way on hosted and freestanding targets
@noautofree @nopanic
func writeRegister(addr u32, value u32) {
var reg = rawptr(addr).(*u32)
*reg = value
}