Types And Values
This chapter documents Zynx type syntax, text and bytes, numeric behavior, global initialization, and the var/let/const binding rules.
Status: current public API unless a rule is marked unsupported or unspecified.
Type Surface
Supported type syntax includes:
- Shared immutable borrows/views, written with a bare type such as
T,str, or[T] - Owned values, written
^T,^str, or^[T] - Exclusive mutable borrows/views, written
&T,&str, or&[T] - Named types, including generic instantiations such as
HashMap<str, i32> - Raw pointers, written
*Tfor a read-only pointee and*mut Tfor a writable pointee - Nullable types, written
T? - Error unions, written
T throws(ErrorSet)orT throws(ErrorSetA | ErrorSetB) - Array and slice-like types, written
[T]or[T; N] - Tuple types, written
(T1, T2, ...) - Erased callable types, written
(T1, T2) -> R,(&self, T) throws(ErrorSet) -> R where Send, or(^self, T) -> R; barefuncand the removedcall(...)form are not valid types - C function-pointer values, written
extern "C" fn(T1, T2) -> R; they are non-null and distinct from erased callables and raw data pointers - Fixed-size scalar vectors and matrices, written
Vector<T, N>andMatrix<T, R, C>;boolelements form masks Unique<T>, the reserved language ownership typeHandle<T>, the reserved checked identity type for arena-owned valuesCapsule<T>, the reserved isolated ownership boundary typeThreadLocal<T>, the compiler-owned per-OS-thread storage handleMaybeUninit<T>, the acceptedstd.memtype for one explicitly staged slot; implementation is pending
Void Values
void is an ordinary zero-sized value type with exactly one value, written void(). Its size is 0, its alignment is 1, and it is always Copy + Send + Sync. It has no destructor, provenance, resource, or address identity. There is no source () type or literal and no separate Unit type. A nominal empty struct remains a distinct domain type rather than an alias for void.
let done: void = void();
let pair: (void, i32) = (void(), 7);
let maybe: void? = void(); // distinct from null
let events: [void; 3] = [void(), void(), void()];void is legal wherever another value type is legal: bindings, parameters, results, fields, tuple and enum payloads, arrays and slices, nullable values, generic arguments, error-union successes, Futures, containers, and channels. void? has two logical states, null and void(); its tag remains semantic state even though its present payload needs no bytes. Zero-sized arrays, slices, and containers still track logical length, capacity, initialization, moves, and bounds. Addresses of zero-sized values or elements do not identify a logical position.
Function fallthrough and return; both return void(); writing return void(); is also valid. This does not make blocks, if, or loops into value expressions: those forms remain statement-only and never yield a trailing value. Empty tuple-pack elaboration normalizes to void, while nonempty tuples preserve every void position.
void() == void() is always true and != is always false; ordering, arithmetic, bitwise, and shift operations are unavailable. At the C boundary a function result maps to C void, while by-value void parameters/fields and empty Zynx aggregates are rejected. *void remains an opaque raw pointer and cannot be dereferenced.
never is valid only as a function return type. A -> never function must not fall through or use return; it must leave control flow by throwing, calling another never function, or looping forever. () throws(E) -> never represents a callable that has no success value and can only leave by propagating E.
Void-returning functions fall through at the end of the function body. A final bare return; is equivalent to return void(); and may produce an unnecessary_return warning. Use return; for real early exits from guards, branches, loops, and other nested control-flow.
Erased Callable Identity
(Args...) -> R uses the ordinary hidden shared/read environment receiver. (&self, Args...) -> R requires exclusive/mutating environment access, and (^self, Args...) -> R consumes the environment. Zero actual arguments are () -> R, (&self) -> R, and (^self) -> R. The receiver marker, when present, is first and followed by an ordinary comma; it is not a call-site argument, and callable type syntax never uses a semicolon. An outer storage role binds the complete arrow, for example ^(&self, Event) -> void. call is an ordinary identifier and has no type grammar.
An erased callable type records four independent parts: its hidden environment receiver (implicit shared/read, &self, or ^self), call arguments, checked-error/result contract, and optional Send/Sync qualification. Ownership of the callable value is written separately with ordinary T/&T/^T roles.
type Read = (Input) -> Output;
type Mutate = (&self, Input) -> Output;
type Once = (^self, Input) -> Output;
type Transferable = (Input) -> Output where Send;
type SharedTransferable =
(Input) -> Output where Send + Sync;The receiver controls invocation access and is orthogonal to cross-thread capability. The qualification is part of static type equality. Aliases, nullable values, fields, tuples, arrays, generic containers, and channels retain it exactly; runtime representation contains no capability flag or witness.
The only implicit capability conversions forget proof: Send + Sync -> Send, Send + Sync -> Sync, and either single qualifier to unqualified. Erasing directly to an unqualified callable type forgets both even when the source closure proved them. No cast or later context can strengthen or recover the forgotten qualification.
C Function-Pointer Identity
The accepted native callback type has a separate identity:
type Mapper = (i32) -> i32;
type CMapper = extern "C" fn(i32) -> i32;
type MaybeCMapper = CMapper?;Mapper is an environment-carrying Zynx callable. CMapper is an opaque, non-null C code-pointer value with no environment, receiver, ownership, drop, deref, arithmetic, data-pointer cast, or integer cast. Its target-defined representation is not promised to be one machine word. The value is Copy + Send + Sync; invocation remains unsafe and these capabilities say nothing about foreign context data or concurrent callback behavior.
Only an exact, named extern "C" fn item or a value received through the C ABI can form CMapper. Ordinary Zynx functions and closure literals do not convert, including noncapturing closures. A nullable callback is written through CMapper? (or a grouped inline C-function-pointer type), maps null to the native null function-pointer representation, and must be narrowed before call. *mut (i32) -> i32 and *(i32) -> i32 are rejected rather than retained as alternate spellings.
Cold Future Identity
The sealed async type has exactly two static identities:
^Future<T>
^Future<T> where SendUnqualified Future is neither Send nor Sync. where Send proves the whole possible in-flight frame can move once across a thread boundary. Future does not admit where Sync or where Send + Sync; it is an affine consumed driver, not a shared polling object.
The producer verifies captures, every local live across suspension, cleanup, nested awaited frames, and private runtime state. The qualifier does not by itself require eventual success or checked-error payloads Send; an operation that transports those outcomes adds separate bounds.
Only Future<T> where Send -> Future<T> weakening exists. There is no strengthening or recovery after unqualified storage. Aliases, fields, nullable values, tuples, arrays, generic containers, channels, and branch joins retain the exact identity without a runtime witness.
^Future<T> in L where Send remains bounded by L. Qualification never creates or extends a checked loan; scoped parallel execution must join within L, and channel storage that could escape L is rejected.
When a callable returns a qualified Future, parentheses make the two identities explicit: () -> (Future<T> where Send). A second trailing where Send after that group would qualify the callable itself.
Nullable Values
T? stores either null or a T payload. null has no standalone inferred payload type; it needs an expected nullable type from an annotation, assignment, return, argument, field, array/vector element, or catch fallback.
T converts to T?, and null converts to any T?. The reverse conversion is never implicit. Use a null check, catch/try composition when the value is an error union, or an API that accepts a nullable value.
Flow checks narrow nullable values:
if x != null { ... }treatsxasTinside the then branch.if x == null { ... } else { ... }treatsxasTinside the else branch.- Shared
T?narrows toT, and mutable&T?narrows to&T.
Optional member access value?.field or value?.method() evaluates the receiver once. If the receiver is null, the whole optional chain yields null; otherwise the member access is performed on the payload and the result is wrapped as nullable. Each nullable step in a chain must use ?. explicitly: x?.y?.z is valid when both x and y may be null. Ordinary . on a nullable value is rejected. Conversely, the receiver of every ?. must already have resolved type T?. Using ?. on non-nullable T is a hard compile error for both fields and methods; use ., the sole non-nullable member-access spelling.
Optional access never injects a non-nullable receiver into T? to justify the operator, even when the surrounding expression expects a nullable result. Ordinary contextual result injection remains separate:
struct Counter {
let value: i32,
fn next(self) -> i32 {
return self.value + 1;
}
}
let counter = Counter { value: 1 };
let direct: i32? = counter.value; // `.` first, then contextual i32 -> i32?
let bad = counter?.value; // error: Counter is not nullable; use `.`
let maybe: Counter? = Counter { value: 2 };
let field = maybe?.value; // real null branch
let call = maybe?.next(); // the same rule covers methodsThe present-payload path follows the same access-mode, ownership, move, loan, and lifetime rules as ordinary .. Optional access does not make an otherwise illegal move, borrow, or escape legal.
Nullable arithmetic, bitwise operations, indexing, and calls are rejected until the value has been narrowed or unwrapped. A nullable value in a boolean condition is also rejected; compare it with null explicitly.
Checked references are not nullable unless the type says so. Shared T? and mutable &T? narrow to T and &T; neither non-null form accepts null. Raw pointers already include null, so *T? and *mut T? are rejected.
Writable *mut T permits unsafe replacement of an existing complete T; it does not mean that ordinary assignment is a no-drop memory store. *p = rhs evaluates the pointer/place once and RHS once, drops the old initialized pointee, then moves the RHS temporary in. Its caller must satisfy the full raw pointer validity and exclusive-access contract documented in Memory, Unsafe, And FFI. Uninitialized or partially initialized storage requires an explicit trusted single-slot MaybeUninit<T>.initialize instead. Its matching unsafe take returns one complete valid owner and makes that slot uninitialized again; its role-directed .ptr changes no state: shared access yields *T and explicit exclusive access yields *mut T, with no ptr_mut alias. The wrapper has no runtime liveness tag and never drops a possible payload. Explicit wrapper aggregates remain ordinary composition, but there is no raw-allocation range constructor, fresh-allocation mem.view retyping, range-wide publication, uninitialized-buffer API, ArrayBuilder, generic raw output surface, or ownership adoption API. General initialized-value cleanup suppression is absent: there is no ManuallyDrop<T>, forget, or leak. The type-local discard self statement is a nominal consuming-finalization rule, not a storage wrapper or adoption API.
Payload Enum Equality
Payload enums use value equality. == compares the tag and, for a matching variant, only the active payload fields in declaration order. It short-circuits on the first unequal field. != negates that result. Padding, inactive variant storage, and other representation bytes have no language-level equality role.
The complete enum type is eligible only when every field of every variant has ordinary equality returning exact scalar bool. A missing or non-scalar field equality makes both enum operators hard errors even for two payloadless runtime values. The operation borrows both operands for shared reading and does not move, copy, allocate, throw, or change drop obligations. Floating fields keep their ordinary IEEE equality, including equal signed zeros and unequal NaNs.
For a generic enum declaration, a concrete instantiated use checks eligibility after payload substitution. A comparison written inside a generic body must already prove every symbolic field operation from the declared bounds; an unresolved field is rejected rather than rechecked for each instantiation. Zynx does not currently add an Eq marker or user-defined == overloading. See Payload Enum Equality for evaluation order, examples, and optimization constraints.
Arrays, Ranges, And Slices
[T; N] is a fixed-size array value whose length is part of the type. An atomic dimension is written directly (16, N); a compound checked usize expression is braced ([T; {N << 1}]). Shape expressions support parentheses, integer casts, unary + - ~, and + - * / % << >> & | ^. Plain array literals are always inline fixed arrays; expected ^[T] context never changes them into dynamic storage. [value; N] repeats one Copy value to build a fixed-size array. Fixed-size arrays are values and participate structurally in duplication.
[T; N] is Copy exactly when T: Copy. It uses ordinary infallible copy and does not also receive a compiler-synthesized fallible Clone. When T is non-Copy and T: Clone, the fixed array has structural clone(self) throws(AllocError) -> ^Self; the result remains exactly [T; N]. Elements clone in increasing index order. If one clone fails, the already initialized result prefix drops in reverse order, while the source remains unchanged.
Array roles are recursive. [[T; C]; R] is a shared fixed array of shared rows; ^[^[T]] owns a slice whose elements are owning slices. Each owned dynamic layer has its own {ptr, length, capacity} header and cleanup.
^[T] is the built-in owning slice: a three-word {ptr, length, capacity} value that moves, rather than copies, when it is bound, assigned, or returned. Moving consumes the source, so using the moved-from name afterward is a compile error. Bare [T] is a two-word shared immutable view, &[T] is a two-word exclusive mutable view, and an ^[T] parameter takes ownership. See Passing Arguments. The static role, not capacity or pointer bits, decides cleanup: a nonempty owner retains and frees its allocation base, while a view never frees storage. The canonical empty owner is let xs: ^[T] = ^[]; with length == capacity == 0 and owes no allocation cleanup. Its private aligned empty pointer representation is not dereferenceable and is not promised to be null. ^[...] is the only dynamic owning-slice literal. Every form except exact ^[] has checked allocation effect, so the use site writes try ^[...] or catches AllocError; ^[] allocates nothing and is infallible. ^[value; N] requires T: Copy, and owning range literals use the same ^ marker. Every repeat, range, or expansion owning form remains statically fallible even when its computed length is zero or T is zero-sized; effects never depend on const substitution or layout. The repeat expression is evaluated exactly once after preflight and is ordinarily dropped when N == 0. std.slice has no arity-specific owning constructors.
For an allocation-capable owning literal, layout preflight and any required allocation complete before any element expression is evaluated. Nonzero-sized storage uses one exact-capacity allocation; physical allocation may be omitted for zero-sized storage. Elements initialize left to right. If element k fails, the initialized prefix drops in reverse order, the buffer is freed, and the suffix remains unevaluated and unmoved. Success has length == capacity == N. A fixed-size array can be viewed as [T], but cloning it never returns ^[T]; there is no implicit or clone-based conversion from fixed array to owning slice. Mutating through a mutable slice is observable through the original storage.
Range slicing, such as xs[1..<3] or b.items[1..<3], always creates a zero-copy loan. It never adopts or transfers the source allocation. A loan from a named place may live within the inferred region of that place. A loan from an owning temporary, such as (try make())[0..<2], is valid only through the full expression, for example as a direct call argument; binding, storing, returning, or capturing it is rejected because its anonymous owner would die. Binding any borrowed view into an owning ^[T] slot is rejected because a view owns no allocation to transfer; see Ownership, Borrowing, And Drop for the coercion rules.
Growth and editing exist only on the owning role and consume their receiver. A named call makes that take visible: xs = try (<-xs).push(<-value), xs = try (<-xs).insert(i, <-value), or xs = try (<-xs).reserve(total). clear is infallible and retains storage. pop returns ^(^[T], ^T?); remove returns ^(^[T], ^T), so callers destructure the tuple and reinstall its first owner. There is no slice append and no Taken<T> result type. Deep-copy with let ys = try xs.clone();; clone shared-borrows xs and is available when elements are Copy or implement Clone.
Arrays and slices expose .length and .ptr; only the owning slice role ^[T] exposes readonly logical .capacity. Shared views expose read-only *T; mutable and owned views may expose *mut T. Indexing with xs[i] returns an element and iterating with for x in xs visits each element; indexing is bounds-checked.
Owning-slice capacity counts logical elements. Already-satisfied reserve(total) is an exact no-op; push and insert reuse sufficient capacity and otherwise grow at most once, under a private strategy with amortized constant-time repeated push. clear and successful removal retain capacity. A failed checked consuming operation drops each owner already taken by the call and leaves its source binding moved; it does not recover the old slice. For zero-sized T, length and capacity remain logical while physical allocation and pointer arithmetic are omitted. Dynamic insert/remove indices use defined bounds traps rather than checked errors.
(<-xs).output(additional) is the distinct owning transition for appending into unused capacity without first creating initialized T values. It returns an affine ^OutputSpan<T> with one bounded, contiguous live prefix. push initializes its next element; commit publishes that prefix as the successor ^[T], and cancel drops only the new prefix and restores the old length. Session overflow traps. Dropping the session without either explicit terminal operation destroys the whole collection. Ordinary [T] and &[T] views never carry this initialization state.
MaybeUninit<T> covers only one explicitly staged slot. Ordinary aggregates and literals may contain several explicit wrappers, but no operation retypes a fresh allocation into a checked wrapper range or publishes/converts that range at once. It also does not expose the unused capacity of an owning slice. OutputSpan<T> remains the sole contiguous owner-output protocol, while sparse core-container storage remains SDK-internal RawStorage<T>.
for x in <-xs consumes an owning collection through its exact iter(^self) overload and yields ordinary owned elements. ^[T] is the owning slice of ordinary T values; ^[^T] specifically stores ^T element owners and is not a synonym. The source cannot be used afterward and an early exit drops the unyielded tail before freeing storage.
Ranges are first-class std.Range<T> values built with ..< (exclusive end) or ... (inclusive end). They can be bound, passed, returned, iterated, and used as subscript operands. Array-literal range sugar such as [1...5] creates a fixed-size array from the integer sequence.
Subscript bounds are checked. A failed index or slice bound is a runtime trap, not a throws(...) error. Slice ranges support exclusive and inclusive forms; negative integer bounds are interpreted from the end of the indexed sequence. For example, items[1..<-1] excludes the first and last element.
Type Aliases, Distinct Types, And Struct Construction
type Name = T; creates a transparent alias. The alias expands to its target for type checking and ABI purposes, including generic and const-generic aliases. Alias cycles are rejected. Type packs are not supported on aliases in the language.
type Name = distinct T; creates a new nominal type with the same representation as T but no implicit conversion to or from T. Use an explicit as cast at the boundary. Distinct types are useful for IDs, units, and other values that share storage shape but should not be accidentally mixed.
Struct literals initialize fields directly; they do not call init. Use Name { field: value } when writing the type explicitly, or { field: value } when one struct type is known from context. Every required field must be initialized exactly once by name. Unknown fields, duplicate fields, missing fields, and field type mismatches are rejected. Field default values are used when a literal omits that field. Qualified and contextual struct literals are compiler-known construction forms, not user-extensible conversions.
A direct constructor call, Name(args...), resolves to init(self, args...). This is the only way to invoke a user-defined initializer; a no-argument initializer is written Name(). Every field without a default must be assigned through self.field before init returns. An init return type must be void or void throws(...); use try Name(...) for a fallible constructor. C ABI unions cannot declare init. init is not callable as a value method.
let raw: i64 = 7;
let id = UserId(raw); // accepted: explicit constructor expression
let hidden: UserId = raw; // rejected: no inserted UserId.init(raw)
send(UserId(raw)); // accepted
send(raw); // rejected when send expects UserIdDeclarations, assignments, call arguments, returns, overload results, and other expected-type contexts never insert a user initializer or another user-defined conversion. The @implicit init form and --trace-implicit-constructors option do not exist. @explicit init is also invalid because every ordinary initializer is already explicit.
Semantic conversions, fallible conversions, and conversions that allocate use domain-specific named functions or methods and must be called explicitly. Use forms such as try UserId.parse(text) or try Image.decode(bytes); the API defines the name, result, errors, and allocation behavior. Use as only for a separately specified language cast. Compiler-known contextual literals, such as an exact numeric literal under a declared numeric type, remain direct construction and execute no user code. Existing array, Vector, Matrix, enum, and other literal rules are closed compiler behavior, not an extensible coercion list. No new builtin coercion is added here.
Builtin numeric type calls are a separate language-provided string parsing form, not an init lookup. For integer types (i8, u64, i128, usize, and custom iN/uN widths from 1 through 65535 bits) and for f32/f64, T(text) takes one str argument and has type T throws(std.arith.parse.ParseError). Use try i32("123"), try u24("8080"), or try f64("3.14") to unwrap the parsed value. Invalid text and overflow throw std.arith.parse.ParseError.Invalid. Parsing rules are documented in std.arith.
This v1 special case applies only to the concrete builtin numeric type tokens. Aliases, distinct numeric wrappers, and generic numeric construction such as try T(text) are follow-up work, not part of the current type-call surface.
Text, Unicode, And Bytes
Bare str is the shared UTF-8 text view, &str the mutable view, and ^str the owning UTF-8 value:
- ABI: an owner is a three-word
{ptr, size, capacity}value, while a shared or exclusive view is a two-word{ptr, size}value. A view guarantees exactlysizereadable UTF-8 bytes; no role promises thatptr[size]exists or equals zero. The static role decides cleanup; capacity and pointer contents are not ownership discriminators. sizeand the owner's logicalcapacitycount UTF-8 bytes. A freshly constructed^"text"hassize == capacity == 4.capacityis a readonly field available only on^str; projecting the owner tostror&strforgets it.- The
sizebytes of every role must be strict UTF-8. U+0000 is an ordinary Unicode scalar and its zero byte may occur anywhere inside the counted range. NUL termination belongs only tostd.ffi.CStr. - A nonempty
^strowns a heap allocation from the global allocator; the canonical empty owner uses allocation-free static storage.^stris move-only: moving transfers ownership and consumes the source, so using the moved-from name afterward is a compile error, and dropping releases any owned allocation. - A hole-free
"literal"always denotes a program-lifetime sharedstrview over static read-only bytes. Expected type cannot make it allocate or turn it into^str, and static literal storage cannot be borrowed as mutable&str. ^"literal"is the sole owning-string literal. Any hole-free owning literal whose decoded content is statically empty is allocation-free and infallible; this includes empty escaped, raw, and trimmed multiline spellings. Every statically nonempty owning literal and every interpolated literal has checked effectAllocError, so its use is written undertryorcatch.("literal").clone()remains the general operation that produces an owned^strcopy from any sharedstrview and may throwAllocError.append(^self, suffix: str | rune),reserve(^self, total), andclear(^self)are the sole owner-growth family. A named owner is visibly taken, for exampletext = try (<-text).append(suffix: value). Append and reserve reuse sufficient capacity and otherwise grow at most once; clear is infallible and retains capacity. Raw-byte, ordinal-scalar, and graphemepush/pop/insert/remove/replacefamilies are absent.std.unicode.utf8.text(data)is the canonical checkedBytes-to-strboundary. It returns a source-region view ornullfor malformed UTF-8 and accepts embedded U+0000.
str.size is the O(1) byte count. There is no str.length scalar-count property: scalar traversal is the ordinary str.iter() cursor, and grapheme segmentation is explicit through std.unicode.grapheme. Normalization remains explicit through std.unicode.norm; no literal, append, slice, comparison, or iteration normalizes text. Test emptiness as text.size == 0; the redundant is_empty method has no target API or compatibility alias.
rune contains exactly U+0000..U+D7FF and U+E000..U+10FFFF. It is Copy + Send + Sync, compares and orders by scalar value, interpolates as its UTF-8 encoding, and has no arithmetic, bitwise, pointer, successor, or predecessor operations. rune as u32 is total; u32 as rune is a checked cast that traps for an invalid scalar. Use std.unicode.scalar(value) -> rune? for untrusted numeric input that must not trap.
String literal forms:
"text"is escaped text. Supported escapes are\n,\t,\r,\e,\\,\",\',\xNN, and\u{HEX}.`text`is raw: backslashes and braces are ordinary text, a doubled backtick denotes one backtick, and no other escapes are processed.""" ... """is multiline escaped text. The opening delimiter must be followed by a newline, and the closing delimiter must be alone on its line.``` ... ```is the raw multiline form.
Prefix any form with ^ to request its owning counterpart. Active {expression} or {expression:spec} holes are accepted only in an escaped owning form, for example try ^"name={name}"; a bare escaped literal containing an active hole is rejected.
Multiline literals trim the indentation prefix before the closing delimiter from each nonblank content line. All string literals must be valid UTF-8 text; U+0000 is accepted in ordinary text literals. The old r"..." raw-string spelling is not supported.
Plain str.size is the byte count of the view; it is O(1) because the count is stored directly. It neither scans for NUL nor proves that a terminator follows the view.
Integer text indexing and ordinal scalar/grapheme slicing are not supported. Ordinary str.iter() is an affine, allocation-free cursor that yields rune values, so for scalar: rune in _ in text is the one direct scalar traversal. text.slice_bytes(start, size) is the sole text-slice primitive: it returns a zero-copy str? in the source region and returns null when either byte bound is out of range or splits a UTF-8 scalar. Construct std.bytes.Bytes(text) explicitly when arbitrary byte indexing or byte slicing is intended; Bytes.slice may split UTF-8 and must pass through std.unicode.utf8.text or the lower-level decoding APIs before becoming text.
Owning escaped string literals interpolate expression values with {expr} and format them with {expr:spec}; write {{ or }} to emit a literal brace. Numeric and boolean values emit ASCII, fieldless enums render their variant name, nullable values render the payload or the literal null, and references and pointers format as address-like values. Vector and Matrix values render as bracketed lane lists; Matrix rows use nested brackets. A single scalar format spec applies to every element, the aggregate expression is evaluated once, and a runtime formatter iterates every lane in Vector order or Matrix row-major order. Every otherwise legal aggregate shape is formattable; interpolation has no separate per-hole or combined lane cap and does not generate per-lane code. Format specifiers cover alignment, fill, sign, width, zero padding, grouping, precision, integer bases (d, x, X, b, o), float modes (f, e, E, g, G, %), and string mode s. An interpolation expression has success type ^str and checked effect AllocError; it never creates a hidden transient view, stack buffer, or context-selected allocation. The standard library does not include a separate formatting module or printf-style formatting API; formatting arbitrary byte buffers requires an explicit encoder such as std.encoding.hex or std.encoding.base64. See String Interpolation And Formatting for the complete rules, the default rendering of each type, and the supported specifiers.
There is no implicit conversion between text and bytes. Use std.bytes.Bytes(text) to borrow the UTF-8 bytes of a str, and use std.unicode.utf8.text to validate bytes before borrowing them as text, then call clone when an independent ^str is required. The other safe constructor, std.bytes.Bytes(data), borrows a [u8] in its checked source region. There is no public raw-pointer Bytes constructor. For initialized foreign storage, unsafe code first creates an owner-anchored [u8] in L with std.mem.view(ptr, length, anchor) and then calls the safe std.bytes.Bytes(view) constructor. Text roles are not FFI-safe. A size-aware C boundary receives a view's pointer and explicit byte count. A C API that requires a terminated string uses the nominal std.ffi.CStr family. CStr in L is a two-word immutable byte view proving that its first NUL is readable at ptr[size]; ^CStr is its exact immutable owner. No str role has that proof. c"..." creates a static CStr; bounded foreign input uses CStr.find, and every dynamic text or byte value uses checked allocating ^CStr construction such as try ffi.CStr(Bytes(text)). That construction rejects interior NUL. There is no separate CString, implicit text conversion, borrowed ^str projection, or unbounded raw-pointer constructor. std.mem raw-pointer helpers such as view, copy, and byte_at are unsafe boundaries: the caller must guarantee pointer validity, provenance, alignment, initialization, aliasing, lifetime, and any text invariants required by the specific helper. view requires a real final owner/source anchor and returns a checked [T] in L or &[T] in L; it cannot create an owning ^[T] or expose uninitialized storage as initialized elements.
The single-slot MaybeUninit<T> transition also does not adopt storage. Publishing a fully initialized built-in allocation as ^[T] or ^str is private compiler/SDK machinery. A foreign allocation stays behind its nominal resource owner and can expose an initialized mem.view anchored in that owner; conversion to a built-in owner requires an explicit copy.
Measurement Fields And Operations
The public measurement vocabulary separates logical cardinality from byte extent and makes nonconstant work visible:
.lengthis an exact logical element, entry, or item count;.sizeis an exact byte extent, notably forstr,std.ffi.CStr, and file metadata;.capacityis an owner's or bounded session's logical limit, expressed in the same unit as its content measurement;byte_sizedisambiguates a byte extent carried beside another coordinate;countis an operation that traverses, validates, or consumes input, never a field or computed property.
A readonly measurement field is authoritative state stored or maintained with the value. Its read is exact, total, O(1), allocation-free, and effect-free; it does not acquire a lock, call the host, or execute user code. Zynx has no computed-property or hidden-getter mechanism. A measurement that requires work or may fail remains a named function or method call, such as consuming iter.count(<-cursor) or fallible json.Value.length().
Public APIs do not spell a measurement len. Generated foreign bindings and private representation names may retain foreign or internal spelling, but do not introduce a public alias.
Numeric Semantics
The normal numeric contract is the same in debug and release builds. Checked numeric traps are preserved at every optimization level; a trap never becomes wrapping behavior or an unchecked operation.
Integer literals start with the smallest builtin integer type that can represent the written value: non-negative literals prefer i32, then u32, i64, u64, i128, and u128; negative literals prefer i32, then i64, then i128. A contextual target can accept an integer literal when the written value fits that target, for example let b: u8 = 255; or let tag: u24 = 0xffffff. Integer suffixes may force a target directly, such as 123_i24 or 0xffff_u17. Custom integer spellings are iN and uN with no leading zero and 1 <= N <= 65535; i1 is signed two's-complement with range -1..0, and u1 has range 0..1. Unannotated literal inference remains capped at the standard widths above; custom widths require a suffix, contextual type, cast, or type call. If the literal does not fit the contextual target, the program is rejected. Floating-point literals start with the smallest supported floating type whose range contains the written value, and contextual float targets may widen the literal.
Normal integer arithmetic is checked. +, -, *, unary -, and compound assignment forms such as += and *= trap on overflow or underflow. / and % trap on a zero divisor and also trap for signed min / -1 and min % -1. These traps are runtime traps, not throws(...) errors, and therefore use the trap cleanup rule: they abort immediately and do not run drops or defer.
Bitwise &, |, ^, and ~ operate on the fixed-width bit pattern of the integer type. For i8, i16, i32, i64, i128, and isize, >> is an arithmetic right shift: it extends the sign bit and is equivalent to floor division by 2^count. Thus (-3 as i32) >> 1 is -2. For u8, u16, u32, u64, u128, and usize, >> is a logical right shift that fills high bits with zero. The exact type of the shifted left operand selects the rule.
Shifts discard shifted-out bits, but the count must be non-negative and less than the bit width of the shifted value. An invalid runtime count traps; an invalid constant count is rejected at compile time. Constant evaluation uses these language rules at the exact language width and does not inherit the host C implementation's behavior for signed >>.
ByteAlignedInteger is the closed compiler-known subset of scalar Integer whose exact semantic bit width is divisible by eight. It includes i8/u8, standard wider and target-width integers, and byte-aligned custom widths such as i24 and u40; it excludes bool, non-byte widths, Vector, and Matrix. User code cannot implement or forge the capability.
std.bit.swap_bytes is an allocation-free, infallible const fn over that capability. It reverses the logical eight-bit chunks of the exact fixed-width bit pattern, preserves the input type, treats signed values by the same two's-complement pattern rule, is independent of endianness/ABI/storage, is the identity for i8/u8, and is an involution. A generic T: Integer body cannot call it without strengthening the bound to T: ByteAlignedInteger; there is no Vector or Matrix overload.
Floating-point arithmetic follows the IEEE-754 operation for the target type. Float overflow, underflow, NaN, and infinity are floating results, not integer-style traps. Float comparisons are ordered comparisons except !=, which is unordered: if either operand is NaN, != is true while every other comparison is false.
The as operator is explicit and checked for numeric casts that can lose range. Integer-to-integer casts trap when the source value is outside the destination range, including signed-to-unsigned negative values. Float-to-integer casts trap for NaN, infinity, and out-of-range finite values. Integer-to-float casts, float-to-float casts, and float narrowing may lose precision or round according to the target floating type; they do not trap solely because precision is lost. bool does not cast to or from an integer or floating type, including the integer values 0 and 1.
For generic numeric code where value-level overload resolution is too narrow, use these builtins: @int_to_i64<T: Signed>(value), @int_to_u64<T: Unsigned>(value), and @float_to_f64(value). These are widening conversions only and perform no range checks.
Expected numeric types flow into numeric literals in assignments, returns, arguments, struct fields, tuple elements, and array/vector elements. Casts such as let byte: u8 = 1 as u8 are redundant and produce an unnecessary_cast warning; write let byte: u8 = 1. Keep explicit casts for computed narrowing, for example (word & 0xff) as u8, distinct type conversions, and casts that intentionally choose a generic type argument.
Array fill literals use [value; count] to build a fixed-size array by repeating one copyable value. The count follows the same shape-expression grammar, so let bytes: [u8; 16] = [0; 16];, return [0; N];, and a braced compound count such as [0; {N + 1}] are valid in matching contexts.
Arithmetic, bitwise, comparison, and shift operands must have matching concrete types unless a rule for that operator explicitly says otherwise. Numeric literals are not coerced from a sibling operand. Use an expected type from an assignment, return, call argument, struct field, array/vector element, or an explicit as cast when a literal should have a non-default numeric type.
Vectors use the same numeric rules element-wise. Binary +, -, *, /, and % accept an exact-element scalar splat in either operand order; each operand is typed independently, so normal literal defaults are not changed by the Vector operand. Comparisons, bitwise, and shifts do not gain scalar operands. Matrices add element-wise arithmetic, integer bitwise/shift operators, @hadamard, checked linear Matrix*Matrix, Matrix*Vector, and Vector*Matrix multiplication, plus scalar scaling. Integer aggregate arithmetic traps on every invalid lane operation, product, and accumulation step. Aggregate comparisons return same-shape bool masks instead of a scalar boolean. Reduce a mask explicitly with @all or @any, or choose eagerly with @select; see Vector Types and Matrix Types.
Normal operators are the checked default. Alternate arithmetic semantics live in explicit stdlib namespaces: std.arith.checked returns nullable results, std.arith.wrapping wraps, std.arith.saturating saturates, and std.arith.overflowing returns the wrapped result with an overflow flag.
Declaration-level const initializers and const-generic arguments are stricter than local runtime expressions: if a constant expression would overflow, divide by zero, use an invalid shift count, or perform an out-of-range checked cast, it is rejected at compile time. Safe constant casts such as 255 as u8 may be folded, but folding never changes observable behavior: only trap-free expressions are folded, and any expression that could trap keeps its runtime checked operation.
The constant-expression subset is deliberately explicit. Module-level const initializers and const-generic arguments may use:
- boolean, numeric, string, and
nullliterals when the target type supports them; - references to module-level constants whose initializer is itself supported;
- fieldless enum literals such as
State.Readyand contextual fieldless enum literals when the target type is known; - builtin numeric casts with compile-time range checks;
- unary
+, unary-, bitwise~, and logical!; - arithmetic, bitwise, comparison, and logical
&&/||operators when every evaluated operand is also a constant expression; - struct literals whose field values are all supported constant expressions.
Module-level const dependencies are checked recursively as a graph. A const may reference another module-level const declared before or after it, but initializer cycles are rejected. Mutable globals and const declarations without initializers are not constant expressions.
Payload enum literals are not supported in module-level constant initializers. Use a runtime local initializer or a fieldless enum constant. Calls, method calls, user-defined casts, allocation, field access on runtime values, indexing runtime values, and pattern matching are not constant expressions.
Global Initialization
Module-level let and const declarations are static storage: a module-level let is an immutable global and const is a compile-time constant. Both take a constant-expression initializer; they do not run as ordered runtime statements. A declaration of the exact form @host let name: fs.Dir | net.Network | mmio.Region in _; is classified separately before these rules. It is a private per-target-instance shared loan supplied transactionally before main, not constant-initialized storage, a foreign symbol, a process-global resource owner, or a general runtime-initialized global. Only the direct body of main may read it, and user code cannot move, replace, exclusively borrow, close, or let it escape its private instance region. A std.mmio.Region host loan is additionally neither Send nor Sync; its trusted backing mapping remains owned by the host until entry quiescence.
A module-level initializer therefore cannot call functions, allocate, await, trigger a runtime trap, throw, mutate another global, or otherwise perform side effects; a runtime-only initializer is rejected with unsupported global constant initializer; calls are not constant expressions. A plain mutable global — var at module scope — is rejected in safe code (mutable module-level global is not allowed there). Per-thread mutable state uses a module-level immutable ThreadLocal<T> descriptor and scoped read/update access instead. Generated module interface packages (.zxm) may contain exported declarations without initializers; those describe storage defined by the implementation module and do not create new default-initialized globals.
A module-level let or const normally requires its constant initializer: an uninitialized let name: T; or const name: T; at module scope is rejected with module-level let `name` requires an initializer (respectively const). The compiler-owned ThreadLocal<T>(factory) form is the single ordinary-global initializer exception: it is legal only as the direct initializer of a module-level let. It constructs an allocation-free descriptor at compile time; factory itself runs lazily and independently on first access by each OS thread. The factory is synchronous, infallible, noncapturing, takes no arguments, and returns exactly the logical payload type. A move-only payload is transferred with the normal ^T return role while the descriptor remains ThreadLocal<T>.
Within one module, globals are declared in source order for name lookup, but constant initializers may refer to module-level const declarations before or after their definition as described above. Across modules, imported modules are analyzed and emitted before the importing module. Because module-level initializers are restricted to constant expressions, this order does not create observable side effects.
Module-level let and const initializers must publish an ordinary constant-evaluator result. A value with a custom drop or another cleanup obligation is therefore rejected as a global initializer. Zynx does not register an implicit process-exit destructor or atexit callback for an ordinary global. A resource that needs cleanup must be created in an explicit runtime scope whose lexical end performs the drop.
A ThreadLocal<T> descriptor is the single stateful exception described above, but the descriptor does not own one process-global T. Each OS thread lazily constructs its own payload, drops it when that thread exits, and drops the main thread's payload at program exit. A thread that never accesses the key does not construct or drop its payload.
Ordinary globals have no cleanup order because they cannot carry cleanup obligations. Within one thread, live ThreadLocal payloads are dropped in reverse order of successful initialization. Accessing a key that has already been dropped is a deterministic trap, not a use-after-drop. Exported descriptors remain correct because access, initialization, and drop registration are performed by the defining module's descriptor thunks.
Bindings And Constants
Local bindings are introduced with one of three keywords: var, let, or const.
var name = value; is a mutable binding. It may be reassigned, compound-assigned (name += 1), assigned through (name.f = ..., name[i] = ...), mutably borrowed (&name, passed to a &T parameter, or given to a struct-literal & field), and used as the receiver of a mutating &self method.
let name = value; is an immutable binding. None of those mutations are allowed on it. Reassignment or compound assignment reports cannot reassign immutable binding; name.f = ... and name[i] = ... report cannot assign through immutable binding; &name and passing it to a &T parameter report cannot mutably borrow immutable binding; and a mutating &self method call reports cannot call a mutating `&self` method on immutable binding. A let still permits every non-mutating use: reading it, moving or consuming it (<-name, an ^self method, a consuming loop), and mutating storage through a reference it holds. Binding immutability is not type-role immutability: with a var x, let r = &x; r = 5; writes through r (assignment to a reference writes the referent; references never rebind), and o.rf.y = 9 through a field let rf: &T likewise writes to the referent, not to the binding.
A let may be initialized once by a deferred write: let x; x = 1;. That single initializing assignment may be split across the arms of an if/else or an exhaustive match, since exactly one arm runs. Completing the initialization from inside a loop entered after the declaration is rejected — the write could run more than once — with cannot initialize immutable binding `x` inside a loop; declare the binding inside the loop for a fresh binding each iteration, or compute the value before the loop.
const name = value; is a compile-time constant. Its initializer must be compile-time-evaluable: literals, other constants, fieldless enum variants, struct or tuple literals built from constants, as casts, and the arithmetic, bitwise, comparison, and logical operators over such operands. A runtime initializer is rejected — `const` requires a compile-time-constant initializer; calls are not constant expressions — spell that binding let instead, since an immutable let accepts any expression. A const always needs an initializer (`const` requires an initializer) and, like let, may never be reassigned.
Tuple destructuring binds one name per member, so it uses let or var, never const: let (a, b) = pair; or var (a, b) = pair;.
const has no type role. It always introduces a compile-time binding. Type permissions use one uniform role model:
- bare
Tis a shared immutable borrow or view; &Tis an exclusive mutable borrow;^Towns the value;*Tis a read-only raw pointer;*mut Tis a writable raw pointer.
Roles compose recursively, for example Future<^T>, ^[^str], and *mut Vector<i32, 4>. The removed read-only-view type-prefix is a hard syntax error. Existing code can be migrated once, from a clean legacy revision, with scripts/migrate_const_roles.py path; --dry-run reports compiler-authored edits without applying them.
Every stored struct field carries exactly one explicit mutability keyword. A var a: T field is initialized during construction and may later be assigned only through exclusive access to the containing value. A let a: T field is initialized exactly once during construction and rejected on every later assignment with field `a` is immutable and cannot be reassigned. Bare a: T is a hard syntax error; there is no mutable default. A var r: T stores a shared borrow, var r: &T an exclusive borrow, and var r: ^T an owner, and the same roles are available to let. Stored-value role is orthogonal to the field-mutability keyword. Field mutability is also orthogonal to @readonly, which restricts post-construction writes to an @readonly var field to methods of its declaring struct. A let field remains immutable after construction to every caller and method, whether or not it carries @readonly.
Function parameters are immutable bindings: a value or pointer parameter cannot be reassigned in the body (copy it into a var local for a mutable binding). Assigning through a reference parameter (v = x where v: &T) is unaffected, as that writes through the reference rather than rebinding the parameter.
let and const are not deep immutability. They do not add data-race or C ABI const/volatile guarantees beyond the Zynx type-role rules and the explicit std.mem.volatile.load/store API in Memory, Unsafe, And FFI. The source spelling volatile<T> remains C ABI pointer-qualifier metadata only; it is not a Zynx value qualifier and does not change ordinary loads or stores.