Type System
Neuro is statically typed. Annotations are explicit by default and optional where the initializer's type is unambiguous: literals take their type from context, and every other expression already carries the type the checker resolved for it.
Current Status
- Implemented: primitive types (integers, floats, booleans,
char) - Implemented: half-precision scalars (
f16,bf16) with a narrow storage/cast/compare contract - Implemented: extended integer types (
i8-i64,u8-u64) - Implemented: function types
- Implemented: void type
- Implemented: contextual inference for numeric literals
- Implemented: string type
- Implemented: structs (definition, instantiation, field access, field mutation)
- Implemented: fixed-size arrays
[T; N]ofCopyelements - Implemented: borrowed slices
&[T]/&mut [T]over an array, aVec<T>, or a sub-range - Implemented: tuples
(T1, T2, ...)ofCopyelements, with destructuring - Implemented: generic functions, structs, and impls, monomorphized
- Implemented: traits, operator traits, and
impl/dyndispatch - Implemented: enums, generic enums,
Option<T>/Result<T, E>, and the standard collections - Implemented: statically shaped tensors
Tensor<T, [d0, ...]>— annotations, literal coercion, the construction helpers, and the ownership surface (.clone(),.to(device))
Primitive Types
Integer Types
Neuro supports 8 integer types with different sizes and signedness:
Signed Integers
| Type | Size | Range |
|---|---|---|
i8 | 8-bit | -128 to 127 |
i16 | 16-bit | -32,768 to 32,767 |
i32 | 32-bit | -2,147,483,648 to 2,147,483,647 |
i64 | 64-bit | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
Unsigned Integers
| Type | Size | Range |
|---|---|---|
u8 | 8-bit | 0 to 255 |
u16 | 16-bit | 0 to 65,535 |
u32 | 32-bit | 0 to 4,294,967,295 |
u64 | 64-bit | 0 to 18,446,744,073,709,551,615 |
Examples:
func demo_integers() -> i32 { val tiny: i8 = 127 // Smallest signed val small: i16 = 32767 // Medium signed val normal: i32 = 2147483647 // Default signed val big: i64 = 9223372036854775807 // Largest signed val byte: u8 = 255 // Smallest unsigned val word: u16 = 65535 // Medium unsigned val dword: u32 = 4294967295 // Large unsigned val qword: u64 = 18446744073709551615 // Largest unsigned return normal}Default Type: Integer literals default to i32 when no annotation is present. Contextual inference from declaration, parameter, and return context is implemented; range validation is enforced (e.g. 300 cannot be assigned to i8). If an unannotated integer literal exceeds the range of i32 (e.g. 5000000000), a compile error is emitted. It is not silently promoted to i64.
Type Suffixes: A suffix appended directly to an integer literal overrides contextual inference and pins the type:
val a = 42i64 // i64, no annotation neededval b = 255u8 // u8val c = 0xFFu8 // hex literal with suffixval d = 0b1010i32 // binary literal with suffixValid suffixes: i8, i16, i32, i64, u8, u16, u32, u64. The value is range-checked against the suffix type at compile time; 300u8 is a compile error.
Integer Overflow
When a runtime +, -, or * produces a result outside the range of its integer type, the behavior depends on the optimization level the program was compiled with:
| Build | Flag | Overflow behavior |
|---|---|---|
| Debug | -O0 (default) | The program aborts at runtime (traps). |
| Release | -O1, -O2, -O3 | The result wraps using two's complement. |
func main() -> i32 { mut x: u8 = 200u8 val y: u8 = 100u8 val z: u8 = x + y // 300 > u8::MAX // Debug (-O0): aborts here // Release (-O2): wraps to 44 return z as i32}The debug-build trap turns a silent miscalculation into an immediate failure during development, while release builds match the zero-overhead wrapping behavior of the underlying hardware. The check is applied to +, -, *, and unary - only; division and modulo are unaffected. Compile-time constant folding always uses wrapping arithmetic regardless of optimization level.
Unary negation is 0 - x, so it overflows wherever that subtraction does: at a signed type's MIN, and at every nonzero value of an unsigned type.
mut y: u8 = 1u8val n = -y // Debug: aborts. Release: wraps to 255.A negative literal written for an unsigned type is rejected outright rather than deferred to run time, because a literal is range-checked against the value it denotes:
// val x: u8 = -1 // COMPILE ERROR: -1 is negative and u8 is unsignedval x: u8 = 0u8.wrapping_sub(1u8) // 255, if the wrap is what you meantInteger Methods
When the overflow behavior matters, request it explicitly with a builtin intrinsic method. These dispatch on any integer receiver and take one same-typed argument. They are compiler-known intrinsics, not user-defined impl methods.
| Method | Returns | Behavior |
|---|---|---|
.wrapping_add(rhs) / .wrapping_sub(rhs) / .wrapping_mul(rhs) | receiver type | Two's-complement wrap on overflow. Never traps, regardless of build profile. |
.saturating_add(rhs) / .saturating_sub(rhs) / .saturating_mul(rhs) | receiver type | Clamp to the type's MIN / MAX instead of overflowing. |
.checked_add(rhs) / .checked_sub(rhs) / .checked_mul(rhs) | Option<T> | Option::Some(result) when it fits, Option::None when it would overflow. |
.shr(n) | receiver type | Right shift by n. Arithmetic (sign-preserving) for signed types, logical for unsigned. Right shift is a method rather than an operator (see operators.md). |
val a: u8 = 200val b: u8 = 100val wrapped: u8 = a.wrapping_add(b) // 44 (200 + 100 = 300, wraps mod 256)val saturated: u8 = a.saturating_add(b) // 255 (clamps to u8::MAX)val floored: u8 = b.saturating_sub(a) // 0 (unsigned underflow clamps to 0)val shifted: u8 = a.shr(2) // 50 (200 >> 2, logical)checked_* is the reporting form: the overflow is not papered over, and the caller must
deconstruct the Option before using the value.
T is the receiver's own type, so 200u8.checked_add(100u8) is an Option<u8>.
val checked: Option<u8> = a.checked_add(b)val total: u8 = match checked { Option::Some(v) => v, Option::None => 0u8 // 300 does not fit in a u8}None of these ever trap: the checked forms compile to the LLVM *.with.overflow
intrinsics and select the variant from the overflow bit, so there is no branch on the
happy path.
Floating-Point Types
| Type | Size | Precision | Range (approx) |
|---|---|---|---|
f16 | 16-bit | ~3 decimal digits | ±6.1e-5 to ±65504 |
bf16 | 16-bit | ~2 decimal digits | ±1.18e-38 to ±3.39e38 |
f32 | 32-bit | ~7 decimal digits | ±1.18e-38 to ±3.40e38 |
f64 | 64-bit | ~15 decimal digits | ±2.23e-308 to ±1.80e308 |
f16 is the IEEE-754 half float; bf16 is bfloat16, which trades mantissa bits for an f32-sized exponent range. Both are full scalar primitives with a deliberately narrow contract (see Half-Precision Types below).
Examples:
func demo_floats() -> f64 { val pi: f32 = 3.14159 // Single precision val e: f64 = 2.71828182845 // Double precision (default) val sci: f64 = 1.23e10 // Scientific notation return e}Default Type: Float literals default to f64. Contextual inference from declaration, parameter, and return context is implemented.
Type Suffixes: A suffix appended directly to a float literal overrides contextual inference and pins the type:
val a = 1.5f32 // f32, no annotation neededval b = 2.0f64 // f64val c = 1e10f32 // exponent form with suffixval d = 1.5e-5f64 // fractional + exponent with suffixValid suffixes: f16, bf16, f32, f64. The suffix attaches directly to the literal; no whitespace is permitted between the digits and the suffix. The exponent form (1e10f32) and the fractional form (1.5f32) both accept a suffix.
Float Methods
| Method | Returns | Behavior |
|---|---|---|
.is_nan() | bool | true when the receiver is NaN, false for every other value including Inf and -Inf. Nullary; defined on f32 and f64 only. |
Floats follow IEEE 754 in full, so every comparison against NaN is false — NaN == NaN
and NaN != NaN alike. That makes NaN undetectable with the comparison operators, and
.is_nan() is the way to test for it:
val zero: f64 = 0.0val nan: f64 = zero / zero // 0.0 / 0.0 is NaNval inf: f64 = 1.0 / zero // division by zero is Inf, an ordinary ordered value val a: bool = nan.is_nan() // trueval b: bool = inf.is_nan() // false — infinity is not NaNval c: bool = nan == nan // false: the equality operator cannot see itThe result is an ordinary bool, so it composes with !, &&, and ||. Like the integer
intrinsics, .is_nan() needs a value receiver: read through a &f64 with *r first.
f16 / bf16 do not provide it — their scalar contract is storage and casts only, with no
arithmetic that could produce a NaN (see below).
Half-Precision Types (f16 / bf16)
Modern AI relies on half-precision for mixed-precision training, so f16 and bf16 are first-class scalar primitives. To avoid the cross-hardware inconsistency of half-precision ALUs, they carry a narrow scalar contract:
| Operation | Supported? |
|---|---|
Binding, move/copy (Copy) | ✅ |
Equality (==, !=) | ✅ |
as-cast to/from any numeric type, and to/from each other | ✅ |
Suffixed literals (1.5f16, 0.02bf16) | ✅ |
Arithmetic (+, -, *, /, %) | ❌ compile error |
Ordering (<, >, <=, >=) | ❌ compile error |
Half-precision literals must carry their suffix; there is no contextual default, so val x: f16 = 1.5 is an error. Write 1.5f16.
Scalar arithmetic is intentionally undefined: half-precision math is not portably specified across hardware. Compute in f32 and cast back:
func main() -> i32 { val a: bf16 = 10.0bf16 val b: bf16 = 4.0bf16 // val bad = a + b // compile error: arithmetic not defined on bf16 val sum: bf16 = (a as f32 + b as f32) as bf16 // 14.0 val h: f16 = 1.5f16 val same: bool = h == 1.5f16 // equality is allowed return sum as i32 // 14}As tensor element types (Tensor<bf16, [...]>, Phase 2) the restriction lifts entirely: elementwise math, matmul, and reductions lower through MLIR to the accelerator's native half-precision units. The split keeps half-precision where it pays off (bulk tensor compute) without committing the scalar layer to non-portable semantics.
Digit Separators
Underscores may be placed between digits of any numeric literal to improve readability. They carry no value (the compiler strips them before parsing) and work in every base, in floats, in exponents, and alongside type suffixes.
val million = 1_000_000 // decimal groupingval mask = 0xFF_FF // hexval flags = 0b1010_0011 // binaryval perms = 0o7_5_5 // octalval ratio = 1_000.000_5 // floatval scaled = 1_0e1_0 // exponentval wide = 1_000_000i64 // with a type suffixA separator is only recognized between digits: a leading underscore (_1000) is an identifier, not a number.
Boolean Type
The bool type represents truth values:
func demo_booleans() -> i32 { val is_true: bool = true val is_false: bool = false if is_true { return 1 } else { return 0 }}Values: true or false
Operations: Logical operators (&&, ||, !), comparison results
Character Type
The char type is a single Unicode scalar value (a 32-bit code point), not a byte.
Char literals are written with single quotes and support escape sequences:
func demo_chars() -> i32 { val letter: char = 'A' val newline: char = '\n' val emoji: char = '\u{1F44D}' // thumbs-up, U+1F44D // char is Copy, so the source stays valid after a bind. val also = letter // Built-in total order: all six comparison operators work directly. if letter < 'Z' && also == letter { return letter as i32 // as-cast char -> integer (65) } return 0}| Property | Behavior |
|---|---|
| Width | 32-bit Unicode scalar value |
| Literals | 'a', '\n', '\t', '\r', '\\', '\'', '\0', '\xNN', '\u{...}' |
| Copy | Yes, binding a char copies it; the source remains valid |
| Comparison | Built-in ==, !=, <, >, <=, >= (ordered by code point) |
| Casts | as to/from any integer type ('A' as i32, 97 as char); not to/from float/bool |
| Arithmetic | None, 'a' + 1 is a compile error; cast to an integer and compute there |
An empty literal (''), a multi-character literal ('ab'), and an unterminated literal
('a) are all lexer errors.
String Type
The string type is an immutable, UTF-8 encoded fat pointer { ptr, i64 }, a pointer to
the bytes plus a stored byte length. Equality (==, !=) compares byte content; the +
operator concatenates two strings into a new owned string.
Storage and the len Guarantee
String literals live in read-only program memory (.rodata) for the lifetime of the
program; they are not heap-allocated, so a program that only reads literals never leaks.
Concatenation (a + b) is the first runtime heap-backed string: it mallocs a fresh buffer
and copies both operands' bytes in, yielding a new owned string. Both literal and heap-backed
forms share the same { ptr, i64 } ABI, so consumers cannot tell them apart. An anonymous heap
string — the result of +, of interpolation, or of String::to_string — is owned by no
binding the drop machinery tracks, so it still leaks; see the alpha memory warning in the README.
A String builder is different: it is a tracked binding, so its
buffer is freed at scope exit.
The pointer addresses a NUL-terminated byte sequence so it doubles as a valid C string for
future FFI, but the stored len field excludes that trailing NUL. len is the
authoritative length: it is the exact UTF-8 byte count of the content. Consumers must use
len and must not scan for a NUL terminator, because interior NUL bytes are legal content
"a\0b".len() is 3, not 1.
String Methods
Builtin intrinsic methods dispatch on a string receiver via the usual receiver.method()
syntax:
val s: string = "hello, world"val n: u64 = s.len() // 12, O(1) read of the stored byte lengthval copy: string = s.clone() // a fresh string equal to sval hello: &string = s.slice(0..5) // "hello", borrowed, zero copyval world: &string = s.slice(7..=11) // "world", inclusive upper bound.len() -> u64, returns the number of UTF-8 bytes, read directly from the fat pointer
in O(1) with no scan. The length excludes the null terminator. Because the index is a
byte count, a multi-byte code point contributes more than one to the length.
.clone() -> string, returns a fresh string equal to its receiver. It is the
canonical explicit deep copy for non-Copy owned types and, now that move-by-default has
landed (1C, see variables), the way to
keep using a value after it would otherwise be moved. Today strings
are immutable and .rodata-backed (no heap string type exists yet), so the clone copies the
(ptr, len) fat pointer, observationally a deep copy because the pointee bytes are
immutable and shared safely. .clone() takes no arguments and returns a string, so it
chains with other builtin methods ("hi".clone().len()). Copy scalar types
(i8..u64, f32/f64, bool) do not provide .clone(): assignment already duplicates
them.
.slice(range) -> &string, returns a borrowed &string view into the receiver's UTF-8
data, with no allocation: since strings are immutable, a sub-range is just a (ptr + start, len) fat pointer (the analogue of Rust's &str). The range is exclusive (s.slice(a..b))
or inclusive (s.slice(a..=b)). Indices are byte offsets, not character offsets. The
slice is itself a &string, so it chains (s.slice(0..5).len()) and compares byte-wise
(s.slice(0..5) == "hello"). Two boundary rules are enforced at runtime in both debug and
release builds and panic (abort, no unwinding, see control flow) on
violation:
- Bounds: the range must satisfy
0 <= start <= end <= len. An out-of-bounds or reversed range panics withstring slice out of bounds. - Code-point alignment: each endpoint must fall on a UTF-8 code-point boundary. A range
that splits a multi-byte code point panics with
string slice splits a UTF-8 code point.
.char_slice(range) -> &string, the codepoint-indexed companion to .slice. It returns
the same borrowed, zero-copy &string, but its range counts Unicode code points rather
than bytes, walking the UTF-8 data to locate each endpoint — O(n) on the receiver's length,
where .slice is O(1). Use it whenever the indices came from counting characters (tokenizer
and NLP work); use .slice when the offsets are already byte offsets or the text is known to
be ASCII.
val s = "héllo" // 5 characters, 6 bytes: 'é' takes twoval by_char = s.char_slice(0..3) // "hél" — three characters, four bytesval by_byte = s.slice(0..3) // "hé" — three bytesval tail = s.char_slice(3..=4) // "lo", inclusive upper boundval empty = s.char_slice(5..5) // "", the character count is a legal boundOnly the bounds rule applies: the range must satisfy 0 <= start <= end <= character count, and a reversed or out-of-range range panics with string char slice out of bounds.
There is no code-point-alignment rule to break — a code point index cannot name a position
inside a code point, which is the reason to reach for this method in the first place.
A range expression a..b / a..=b is valid only as a .slice or .char_slice
argument; used anywhere else it is a compile error.
.chars() -> Chars, an iterator over the receiver's Unicode scalar values. Each step is
O(1): the cursor decodes the code point standing at its byte offset and advances by that code
point's own UTF-8 width, so no part of the text is scanned twice. Chars is an ordinary
Iterator from the prelude (see control flow), which means a for head
drives it, .enumerate() numbers the scalars, .map(f) / .filter(p) decorate them, and the
iterator itself is a value that can be held and stepped by hand. The receiver is borrowed,
not consumed, so the text stays usable afterwards.
val text = "héllo" for c in text.chars() { println("{c}") // 5 scalars, though text.len() is 6 bytes} for (position, c) in text.chars().enumerate() { } // position counts code points mut walk = text.chars()val first = walk.next() ?? '?' // Option::Some('h')for (offset, c) in text.char_indices(), the same walk with the byte offset of each
scalar bound alongside it. Those are the offsets .slice(range) takes, which is what makes the
pair the tokenizer's tool: find a position by reading characters, then cut by bytes. An offset
names the code point its step yields, never the one after it.
mut cut: u64 = 0for (offset, c) in "aé漢".char_indices() { if c == '漢' { cut = offset } // 3 — 'a' is one byte, 'é' two}.char_indices() is a for-head form, like .enumerate(), rather than a method: it binds
a pair, and a pair cannot travel through Iterator::next, whose Option payload is limited to
scalars in this phase. So it appears only in a for head, it binds a pair there (never a single
variable), and it takes no .enumerate() and no adapters — it already carries a position of its
own. Where a chain is wanted, walk .chars() instead.
Growable Strings (String)
string is immutable, which makes it cheap to pass, slice, and share, but wrong for text that is
assembled. Writing s = s + piece in a loop allocates a new buffer and recopies everything
accumulated so far on every step, so building an n-piece string costs O(n²) bytes copied.
String is the growable counterpart: an owned, mutable, heap-backed UTF-8 buffer that appends in
amortized O(1). The pair mirrors [T; N] / Vec<T> exactly, and for the same reason.
mut report = String::new() // no annotation needed: `String` takes no type argumentsreport.push_str("run: ")report.push_str(name) // `name` is read, not movedreport.push_str(" ok") val line: string = report.to_string() // finished text, immutable from hereString is a compiler-known library type, not a keyword and not a language primitive, the
same status Vec<T> has: the language exposes no allocator and no raw pointers, so nothing in
.nr source could implement it. A program that declares its own String shadows this one.
string | String | |
|---|---|---|
| Contents | immutable | mutable, appendable |
| Representation | { ptr, i64 } fat pointer, by value | { buffer, len, cap } header owning the buffer |
| Grows | never | amortized O(1) per append |
| Role | the text a program passes around | the buffer a program builds text in |
String Methods
String::new() -> String, an empty builder. Allocates nothing until the first append, so an
unused builder costs no heap traffic. It takes no type arguments, so unlike Vec::new() it needs
no annotation to be inferred.
.push_str(text), appends the bytes of a string or an immutable &string. The argument is
read, not moved — the same latitude a + operand or a map lookup key gets — so the caller's
binding stays usable afterwards. It mutates, so it needs a mut binding or a &mut String.
.len() -> u64, the byte length, read from the header in O(1). Bytes, not characters, for the
same reason string.len() is.
.clear(), resets the length to zero and retains the buffer, so refilling in a loop does
not reallocate. This is what makes one builder reusable across iterations. It mutates.
.to_string() -> string, copies the accumulated bytes into a fresh owned immutable string.
This is the bridge back to string: everything that consumes text — +, ==, .len(), a
Vec<string> element, a map key — takes the result. A borrowed view into the buffer would be
zero-copy, but a later push_str may reallocate and leave it dangling, and the borrow checker
does not yet track a builder's outstanding views, so the copy is the sound answer. It is one
allocation at the end of a build, not one per append.
Ownership
String owns a heap buffer, so it follows the ordinary rules with no exceptions: it is never
Copy, assignment and argument passing move it, &String / &mut String borrow it, and the
buffer is freed when the owner leaves scope.
mut buf = String::new()buf.push_str("a")val moved = buf // buf is MOVED// buf.push_str("b") // COMPILE ERROR: use of moved value 'buf'Phase 1C Limitations
- No
.push(char),String::with_capacity(n),String::from(s), or.is_empty(). - No borrowed
.as_str(); use.to_string(). - A
Stringcannot be a collection element or a map key. Stringis not an interpolation hole or a+operand — call.to_string()first.
Struct Types
Structs are user-defined types that group named fields. They use nominal typing, two structs with identical fields are distinct types.
Definition
struct Point { x: f64, y: f64} struct Counter { value: i32, step: i32}Fields are listed as name: Type, separated by commas or newlines. Any primitive type (or another struct type) is valid as a field type.
Instantiation
val p = Point { x: 3.0, y: 4.0 }val c = Counter { value: 0, step: 1 }All fields must be provided. Extra or missing fields are compile errors.
Field Access
val x_coord = p.x // reads field x from pval total = c.value + c.stepField access resolves to the declared field type.
Field Mutation
Field mutation is only allowed on mut bindings:
mut cursor = Point { x: 0.0, y: 0.0 }cursor.x = 5.0 // OK: cursor is mut val fixed = Point { x: 1.0, y: 2.0 }fixed.x = 3.0 // Error: AssignToImmutableFieldDefinition Order
Structs can be used before they are defined in the source file, the compiler performs a pre-registration pass:
func main() -> i32 { val s = Score { value: 42 } return s.value} struct Score { value: i32}Copy and Clone (@derive)
By default a struct is move-by-default, just like string: binding, assigning,
returning, or passing it by value moves the source, and reading the source afterward is a
use of moved value error. A struct opts out of moving by deriving Copy:
@derive(Copy, Clone)struct Point { x: i32, y: i32 } val a = Point { x: 3, y: 4 }val b = a // a is COPIED, not movedval s = a.x + b.y // a is still valid hereRules:
- A struct may derive
Copyonly when every field isCopy. Primitive scalars (i8tou64,f32,f64,bool) areCopy;stringis not; a struct field isCopyonly when its type also derivesCopy. Violating this is aCopyDeriveNonCopyFielderror. CopyimpliesClone.@derive(Clone)(orCopy) enablesstruct.clone(), an explicit deep copy that returns a fresh value without moving the receiver. A user-definedclonemethod in animplblock shadows the builtin.@derive(Debug)gives the struct its{value:?}rendering: the struct's name followed by each field in declaration order, e.g.Point { x: 1, y: 2 }. A field-less struct renders as its bare name. Every field must itself be renderable — a scalar,string,char,bool, or another struct that derivesDebug— otherwise the derive is aDeriveFieldUnsupportederror. A struct has no display form, so"{p}"stays an error even with the derive; write"{p:?}".@derive(PartialEq)gives the struct==and!=, compared field by field. The same field rule applies: a nested struct must derivePartialEqtoo. The comparison is generated inline and never calls a method, which is why a struct that both derivesPartialEqand declaresimpl PartialEq forit is aDeriveConflictsWithImplerror — keep one.- The derivable-and-implemented set is exactly
Copy,Clone,Debug,PartialEq. Any other name in a@derivelist is a compile error, never a silent no-op:Hashableis specified but not generated yet (UnimplementedDerive— write theimplby hand), and anything else isUnknownDerive. A repeated name isDuplicateDerive. - A derived
PartialEqdoes not satisfy theimpl PartialEqa struct key of aHashMaporBTreeMaprequires: a collection key calls the trait method, and a derive provides none.
@derive(Clone)struct Vec2 { x: f64, y: f64 } val v = Vec2 { x: 1.0, y: 2.0 }val w = v.clone() // independent copy; v stays usable@derive(Debug, PartialEq)struct Point { x: i32, y: i32 } val a = Point { x: 1, y: 2 }val b = Point { x: 1, y: 2 }println("{a:?} == {b:?} is {a == b}") // Point { x: 1, y: 2 } == Point { x: 1, y: 2 } is trueSee examples/structs/derives.nr for both derives run
end to end, including the recursion through a nested struct.
Type Errors
| Error | Cause |
|---|---|
MissingStructField | Struct literal omits a declared field |
UnknownField | Struct literal or access uses a field that doesn't exist |
AssignToImmutableField | Field assignment on a val binding |
StructAlreadyDefined | Two struct declarations share the same name |
UnknownStruct | Struct literal references an undeclared struct name |
CopyDeriveNonCopyField | @derive(Copy) on a struct with a non-Copy field |
Enum Types
Enums are user-defined types that hold exactly one of several named variants. A variant may be a bare tag, carry a positional tuple payload, or carry named fields, all three may appear in one enum. Like structs, enums use nominal typing.
Definition
// Bare variantsenum Color { Red, Green, Blue} // Mixed variant shapesenum Shape { Circle { radius: f64 }, // named-field variant Rectangle { width: f64, height: f64 }, Triangle { base: f64, height: f64 }} enum Message { Quit, // unit variant Move(i32, i32), // tuple variant Write(bool)}Construction
Each variant shape has its own construction syntax, all prefixed with the enum name:
val c = Color::Red // unit variantval m = Message::Move(1, 2) // tuple variantval s = Shape::Circle { radius: 5.0 } // struct variantAn enum value can be bound to a val/mut, passed to and returned from functions, and stored in a struct field. Enums are Copy (their payloads are scalar Copy primitives, see below), so binding or passing one duplicates it rather than moving it.
Memory Layout
An enum is a tagged union: a discriminant identifying the active variant, plus storage for the widest variant's payload. Two enums with the same variant names but declared separately are distinct types.
Generic Enums
An enum may take type parameters. Each distinct set of type arguments is monomorphized into its own nominal tagged union, exactly as a generic struct is, so Slot<i32> and Slot<i64> are different types, each with its own payload width, and there is no runtime cost.
enum Slot<T> { Filled(T), Vacant }enum Tagged<T, U> { Left(T), Right(U) } val a = Slot::Filled(4) // T inferred from the payload → Slot<i32>val b: Slot<bool> = Slot::Vacant // no payload, so the annotation fixes TWhere the type arguments come from, in order:
- The expected type, an annotated binding, a parameter, a struct field, a
return. - The payload,
Slot::Filled(4)determinesT = i32by unifying4's type against the variant's declared payload. - The enclosing function's return type, when it is an instance of the same enum. This is what makes the common fallible-function shape work, because a tail
ifbranch has no other context:
func divide(a: i32, b: i32) -> Result<i32, i32> { if b == 0 { Result::Err(1) // Err determines E; T comes from the return type } else { Result::Ok(a / b) // Ok determines T; E comes from the return type }}If none of the three determines an argument, the construction is rejected with GenericEnumNotInferable, annotate the target. Using the bare name as a type (func f(o: Slot) -> i32) is GenericEnumNeedsArgs.
A match pattern is written with the base name; it matches whatever instance the scrutinee has, and its payload binds at that instance's concrete type:
match a { Slot::Filled(v) => v + 1, // v is i32 here Slot::Vacant => 0}Option<T> and Result<T, E>
These two generic enums are the standard library's absence and failure types. They are available in every program without a declaration, the compiler prepends an implicit prelude:
enum Option<T> { Some(T), None }enum Result<T, E> { Ok(T), Err(E) }They are ordinary generic enums: nothing about them is special-cased in the type checker or the backend, and a program that declares its own Option or Result shadows the prelude entry.
func unwrap_or(o: Option<i32>, fallback: i32) -> i32 { match o { Option::Some(v) => v, Option::None => fallback }} val present = Option::Some(30)val absent: Option<i32> = Option::NoneVariants may be written qualified (Option::Some, Result::Err) or, because the implicit prelude imports Some, None, Ok, and Err into every file without @no_prelude, unqualified (Some(x)). The ?? operator unwraps either type with a fallback, the ? operator unwraps one or hands the failure to the caller, and val-else unwraps one or exits the scope.
Phase 1 Limitations
- Payloads are scalar
Copyprimitives only, integers, floats,bool,char. A payload ofstring, a struct, an array, a tuple, or a reference is rejected (UnsupportedEnumPayload); broader payloads arrive with heap support. The rule is enforced per instance, soOption<i32>is available whileOption<string>is not yet. - Type arguments are
Copy, the same restriction generic functions and structs carry this phase. - No
implblocks on enums, methods (and thereforeOption/Resulthelpers such as.map_err) need impls over enums, which are struct-only today. - No lifetime parameters on an enum, with scalar-only payloads there is nothing to annotate;
enum E<'a, T>is a parse error.
Type Errors
| Error | Cause |
|---|---|
EnumAlreadyDefined | Two enum (or an enum and a struct) share a name |
UnknownEnumVariant | Construction names a variant the enum does not declare |
EnumVariantFormMismatch | A variant built with the wrong syntax (e.g. a struct variant called like a function) |
EnumVariantArityMismatch | A tuple variant built with the wrong number of arguments |
UnknownEnumField / MissingEnumField / DuplicateEnumField | Struct-variant field set is wrong |
UnsupportedEnumPayload | A variant payload is not a scalar Copy primitive |
GenericEnumNeedsArgs | A generic enum's bare name used as a type |
GenericEnumNotInferable | A construction whose type arguments no context determines |
GenericArgCountMismatch | Option<i32, bool>, wrong number of type arguments |
Newtype Declarations
A newtype creates a distinct nominal type that wraps an inner type. Unlike a type alias, which is transparent, so the alias and its target are interchangeable, a newtype and its inner type are different types. This buys unit-of-measure and domain-identifier safety at zero runtime cost.
newtype Meters = i32newtype Seconds = i32newtype Celsius = f64Construction and Inner Access
Build a newtype value by calling the newtype name with a single inner-typed argument, and read the wrapped value back with .0:
val d: Meters = Meters(30) // constructionval raw: i32 = d.0 // inner accessDistinctness
Because a newtype is a separate type, its values are not interchangeable with the inner type or with another newtype over the same inner type:
val m: Meters = Meters(3)val bad: i32 = m // ERROR: expected i32, found Metersval also_bad = Meters(1) + Seconds(2) // ERROR: arithmetic is not defined on newtypesA newtype forwards Copy/Clone from its inner type, so a Copy-inner newtype is itself Copy. It can be a val/mut binding, a function parameter or return type, and a struct field.
Phase 1E Limitations
- Inner type must be
Copy, integers, floats,bool,char, and otherCopyaggregates. A non-Copy inner such asstringis rejected (NewtypeInnerNotCopy); non-Copy wrappers arrive with broader move/heap support. - No inherent methods or operator traits yet, arithmetic and other operators on a newtype await the trait system (1F). Use
.0to compute on the inner value.
Type Errors
| Error | Cause |
|---|---|
NewtypeAlreadyDefined | A newtype reuses a builtin, struct, enum, or newtype name |
NewtypeInnerNotCopy | The wrapped inner type is not Copy |
CyclicNewtype | A newtype wraps itself directly or transitively |
References, Immutable Borrows (&T)
An immutable borrow &T is a non-owning reference to a value. It lets a
function read a value without taking ownership, so the caller keeps using its binding
afterward. The borrow expression &x takes a reference to the place x.
func describe(s: &string) -> u64 { s.len() // method call auto-derefs through the borrow} func main() -> i32 { val msg: string = "Neuro" val n: u64 = describe(&msg) // borrow, msg is NOT moved val again: u64 = msg.len() // still valid: borrowing never consumes return 0}Rules:
- A reference type
&Tmay appear on parameters, return types, and local bindings. - Borrowing does not move the borrowed value; that is the whole point of a reference.
A non-
Copyvalue such asstringstays usable after being borrowed. &Tis itselfCopy: passing or re-borrowing a reference duplicates the pointer.- Method and field access auto-deref through a borrow:
r.len()/r.clone()on a&string, andr.field/r.method()on a&Struct, behave as if applied to the referent. - Only a place (a
val/mut/parameter binding) can be borrowed. Borrowing a temporary (a literal or a call result) or aconst(an inlined value, not a memory location) is aCannotBorrowValueerror.
struct Point { x: i64, y: i64 }impl Point { func sum(&self) -> i64 { self.x + self.y }} func read_sum(p: &Point) -> i64 { p.sum() } // borrow a struct, call through itNot yet lifetime-verified: a returned
&Tis not yet checked against the lifetime of the value it points into; that check lands with lifetime inference. Integer intrinsics (r.wrapping_add(..)) still require a value receiver, read through*rfirst.
References, Mutable Borrows (&mut T)
A mutable borrow &mut T is a non-owning reference that grants write access to a
value. The borrow expression &mut x requires x to be a mut binding, you
cannot acquire write access through a reference to a value you may not write directly.
Values are read and written through the prefix dereference operator *:
func increment(n: &mut i32) { *n = *n + 1 // read with *n, write with *n = ...} func main() -> i32 { mut counter: i32 = 40 increment(&mut counter) // mutate in place, counter is borrowed, not moved increment(&mut counter) return counter // 42}Rules:
&mut xrequires amutbinding; mutably borrowing avalis a compile error (cannot mutably borrow).*rreads the referent; dereferencing a non-reference is an error (cannot dereference).*r = valuewrites through the reference and requiresr: &mut T; writing through an immutable&Tis an error (cannot assign through an immutable reference).&mut Tand&Tare distinct types, there is no implicit&mut T → &Tcoercion (explicit over implicit).
func main() -> i32 { mut x: i32 = 7 val r: &mut i32 = &mut x *r = 35 return *r // 35}Borrow Exclusivity (& / &mut aliasing rules)
The borrow checker enforces two coexistence rules at compile time:
- Any number of shared
&Tborrows of a place may be live at the same time. - A
&mut Tborrow is exclusive: while it is live, no other borrow of that place shared or mutable, may exist.
A borrow's region is lexical. A borrow held by a binding (val r = &x) lives until that
binding leaves scope; a borrow passed to a function, used in a condition, or returned ends
with the statement that took it. So sequential borrows in separate statements never conflict,
and a borrow taken inside a block is released at the block's closing brace.
func main() -> i32 { mut x: i32 = 5 val a: &i32 = &x val b: &i32 = &x // ok, shared borrows coexist val c: &mut i32 = &mut x // ERROR: cannot borrow 'x' as mutable, it is already borrowed return 0}func inc(n: &mut i32) { *n = *n + 1 } func main() -> i32 { mut x: i32 = 0 inc(&mut x) // the &mut ends with this call inc(&mut x) // ok, the previous borrow is no longer live return x // 2}The diagnostics are cannot borrow '<name>' as mutable (a &mut while any borrow is live) and
cannot borrow '<name>' as immutable (a & while a &mut is live).
Deferred: this is a lexical check, not non-lexical liveness (NLL). Reading or moving a value while it is borrowed lands with full lifetime inference, which extends the same borrow-region analysis.
Lifetimes, Returned References
Lifetimes are inferred in the vast majority of cases. The elision rules match Rust: a
single input reference lifetime is applied to the outputs, and the &self lifetime is applied to
a method's outputs. In practice this means a function or method that returns a reference may
borrow one of its reference parameters (or, in a method, &self); the returned borrow then lives
as long as the caller's borrow.
func first(a: &i32, b: &i32) -> &i32 { a // ok, the returned borrow outlives the call}The borrow checker rejects returning a reference to a value that dies when the function returns a body-local or a by-value parameter, because the reference would dangle:
func dangle() -> &i32 { val local: i32 = 5 return &local // ERROR: cannot return a reference to 'local', it is local to // this function and does not outlive the call}The check follows a returned reference through a local reference binding (val r = &local; r is
also rejected) and into the arms of a returned if/else. The diagnostic is
cannot return a reference to '<name>'.
Explicit lifetime annotations
For the advanced patterns elision cannot express, a lifetime parameter is declared in the
generic-parameter list as 'a and used on reference types as &'a T / &'a mut T:
func longest<'a>(a: &'a string, b: &'a string) -> &'a string { if a.len() > b.len() { a } else { b }}An annotation is a well-formedness surface only: every lifetime used on a reference must be
declared in the enclosing <...> list (an undeclared 'b is the undeclared lifetime error), and
the annotation is then erased, &'a T and &T are the same type, so a lifetime costs nothing
at runtime and never changes which values a signature accepts. The elision rules above still do the
real outlives checking; explicit lifetimes do not tighten or relax it. Lifetime parameters may be
mixed with type and const parameters (func f<'a, T>(...)); only the type/const parameters drive
monomorphization. Lifetime bounds ('a: 'b) and struct-field references are not part of this
surface yet.
String Slices (&string)
&string is the borrowed string slice: a non-owning (ptr, len) view into
UTF-8 data. There is no separate slice type, &string is both "a borrow of an owned
string" and "a string slice," the analogue of Rust's &str.
A slice is read-only, so its fundamental operation is equality. The operators ==
and != compare the underlying UTF-8 bytes for any combination of an owned string and a
&string slice; a borrowed operand is auto-dereferenced to its fat pointer before the byte
compare, so it costs no copy.
func slices_equal(a: &string, b: &string) -> bool { a == b // two borrowed slices} func main() -> i32 { val lang: string = "Neuro" val same: string = "Neuro" val eq: bool = slices_equal(&lang, &same) // true val lit: bool = (&lang == "Neuro") // true, slice vs owned literal if eq && lit { return 0 } return 1}Comparing through borrows never moves: lang stays usable after each &lang. Reference
peeling for equality is string-only, so comparing a non-string reference to its value
(&n == n on an i32) or mixing types (i32 == &string) is still a type error; reading
other &T through == needs the * deref operator.
Void Type
Functions that don't return a value have implicit void return type:
func print_debug() { // No return type specified = void // Implicit return at end of function} // Explicit void (optional, rarely used)func print_debug_explicit() -> void { return}Note: The main function must return i32 (exit code), not void.
Type Annotations
Variable Declarations
Type annotations are optional when the type can be inferred from context:
val x: i32 = 42 // Explicit type annotationval pi: f64 = 3.14159 // Explicit type annotationval flag: bool = true // Explicit type annotationval n = 100 // Inferred i32 (default for integer literals)val pi = 3.14159 // Inferred f64 (default for float literals)Function Parameters
Function parameters must have explicit type annotations:
func add(a: i32, b: i32) -> i32 { return a + b}Function Return Types
Return types must be explicitly specified (or omitted for void):
func returns_int() -> i32 { return 42} func returns_float() -> f64 { return 3.14} func returns_nothing() { // Implicit void return}Type Compatibility
Strict Type System
Neuro uses strict type checking with no implicit conversions in Phase 1:
func strict_types() -> i32 { val x: i32 = 42 val y: i64 = x // Error: type mismatch (i32 vs i64) return y}Even compatible types require explicit conversion.
Function Type Checking
Function calls are type-checked strictly:
func takes_i32(x: i32) -> i32 { return x} func test() -> i32 { val x: i64 = 100 return takes_i32(x) // Error: expected i32, found i64}Return Type Checking
All return statements must match the declared return type:
func returns_i32() -> i32 { if true { return 42 // OK: i32 } else { return 3.14 // Error: expected i32, found f64 }}Type System Features
Phase 1, Core Language
Landed (✅):
-
Primitive types (i8-i64, u8-u64, f32, f64, bool, char, f16/bf16)
-
String type with fat pointer ABI (
{ ptr, i64 }) -
Explicit type annotations + contextual numeric inference with range validation
-
Explicit type conversions via
as -
Function types, strict type checking, type-mismatch error reporting
-
Structs, methods, fixed-size arrays
[T; N], borrowed slices&[T]/&mut [T], tuples + destructuring, type aliases -
Enums with associated data
enum E { A, B(T), C { f: T } }, generic enumsenum Slot<T> { ... } -
Pattern matching, newtypes
-
Generics + monomorphization, traits, operator traits, static/dynamic dispatch, closures
-
Option<T>/Result<T, E>from the implicit prelude, the??coalescing and?propagation operators,val-elsebinding -
Collections
Vec<T>/HashMap<K, V>/BTreeMap<K, V>,checked_*integer methods -
Modules and imports: multi-file programs, inline
moduleblocks, re-exports, and the implicit prelude (see modules.md) -
String interpolation with the format mini-language (
"{x:.2}") and triple-quoted block strings with dedent, see expressions.md -
Named arguments with external labels, see functions.md
-
Growable runtime strings through the
Stringbuffer (String::new/.push_str/.clear/.to_string)
Phase 1 has no remaining work; every sub-phase 1A-1H is complete.
Phase 2, Tensors
- Implemented: static tensor types
Tensor<f32, [3, 3]>, literal coercion, the construction helpers, and tensor ownership (see Tensor Types) - Planned: reading a tensor back — indexing, slicing, arithmetic, and reductions
- Planned: broadcasting rules
- Planned: shape generics, named dimensions, and dynamic shapes
Type Safety Guarantees
Neuro's type system provides:
- No undefined behavior from type errors: All type errors caught at compile time
- No implicit conversions: Explicit is better than implicit
- Function type safety: Arguments and returns type-checked
- Memory safety: Types prevent invalid memory access (future: ownership system)
Common Type Errors
Type Mismatch
func mismatch() -> i32 { val x: i32 = true // Error: expected i32, found bool return x}Error message:
Type error: Type mismatch
expected: i32
found: bool
at program.nr:2:18
Argument Type Mismatch
func takes_i32(x: i32) -> i32 { return x} func wrong_arg() -> i32 { return takes_i32(true) // Error: expected i32, found bool}Error message:
Type error: Argument type mismatch
expected: i32
found: bool
at program.nr:6:22
Return Type Mismatch
func returns_wrong() -> i32 { return true // Error: expected i32, found bool}Error message:
Type error: Return type mismatch
expected: i32
found: bool
at program.nr:2:12
Best Practices
1. Choose Appropriate Integer Types
// Good: use smallest type that fitsval age: u8 = 25 // Ages fit in u8 (0-255)val year: u16 = 2025 // Years fit in u16val file_size: u64 = 1000000000 // Large files need u64 // Avoid: unnecessarily large typesval counter: i64 = 0 // Wasteful if i32 suffices2. Use f64 for Most Floating-Point Math
// Good: f64 for precisionval pi: f64 = 3.141592653589793 // Only use f32 when:// - Memory is constrained// - Precision is not critical// - Interfacing with f32 APIs3. Be Explicit About Types
Even with future type inference, explicit types improve readability:
// Clear intentfunc calculate_area(radius: f64) -> f64 { val pi: f64 = 3.14159 return pi * radius * radius}4. Use Booleans for Flags
// Good: boolean for true/falseval is_valid: bool = true // Avoid: integer for boolean logicval is_valid: i32 = 1 // Less clearType Conversion
Explicit type conversions are supported via the as operator. There are no implicit type conversions in Neuro.
func convert_types() -> i64 { val x: i32 = 42 val y: i64 = x as i64 // Explicit conversion (widening) val f: f64 = y as f64 // Int to float val pi: f64 = 3.14 val trunc: i32 = pi as i32 // Float to int (truncates) val flag: bool = true val num: i32 = flag as i32 // Boolean to int (1) return y}The compiler will reject invalid casts (e.g. casting a string to an integer).
Examples
Working with Multiple Types
func compute(a: i32, b: f64) -> f64 { // Mix i32 and f64 by casting one // return a + b // ERROR: Type mismatch // Explicit conversion return (a as f64) + b }Type-Safe Function Composition
func double(x: i32) -> i32 { x * 2} func add_ten(x: i32) -> i32 { x + 10} func compose() -> i32 { val x: i32 = 5 val y: i32 = double(x) // 10 val z: i32 = add_ten(y) // 20 z}Type Aliases
A type declaration introduces a transparent alias for an existing type. The
alias and its target are fully interchangeable, no new nominal type is created,
so a value of the alias type and a value of the target type are the same type to
the compiler (contrast with newtype, which is a distinct type).
type Meters = f64type Id = i64 struct Sensor { location: Meters // same as `location: f64`} func to_id(raw: i32) -> Id { raw as Id // same as `raw as i64`} func main() -> i32 { val s = Sensor { location: 10.0 } val tag: Id = to_id(7) return tag as i32 // 7}Aliases resolve in every type-annotation position: variable and const
annotations, function parameters and return types, struct fields, and as cast
targets. Alias chains collapse to their ultimate target:
type A = Btype B = i32val x: A = 1 // x : i32Rules and diagnostics:
- A duplicate alias name is a compile error.
- An alias may not shadow a built-in type name (
i32,f64,string, …). - A cyclic alias chain (
type A = B;type B = A) is a compile error. - An unknown target type is reported where the alias is used, against the resolved name.
Aliases are resolved at parse time, so they carry zero runtime cost and produce
exactly the same code as writing the target type directly. Using an alias as a
value-position constructor or path name (e.g. MyAlias { ... }) is not part of
this feature.
Arrays
A fixed-size array [T; N] holds exactly N values of element type T, with
N fixed at compile time and part of the type, [i32; 3] and [i32; 4] are
distinct types. N is an integer literal, or, inside a generic definition, a
const generic parameter ([T; CAP]), resolved to a concrete length by
monomorphization.
val a: [i32; 4] = [10, 20, 30, 40] // explicit typeval b = [1, 2, 3] // inferred [i32; 3] val first = a[0] // index readmut c = [0, 0, 0]c[1] = 42 // element assignment (mut binding)val n = a.len() // 4, as u64 (compile-time constant) for x in a { } // iterate by valuefor x in &a { } // iterate over a borrow- Element type: currently restricted to
Copyscalar primitives (the integer types,f16/bf16/f32/f64,bool,char). An array ofCopyelements is itselfCopy. Arrays of non-Copyelements (strings, structs) are not yet supported. - Literals must be homogeneous; the length is the element count and, when a
[T; N]annotation is present, must equalN. - Bounds: an out-of-range index panics with a located diagnostic in debug
builds (
-O0); release builds omit the check (matching the integer-overflow policy). - Iteration:
for x in arr/for x in &arrbind each element in order, andfor (i, x) in arr.enumerate()binds itsu64position alongside it (see Control Flow).
Borrowed Slices (&[T] / &mut [T])
&[T] is a non-owning (ptr, len) view over a contiguous run of T — the array-and-Vec
analogue of &string. A function that only reads or writes elements takes one, and stops
caring whether they came from a [T; N], a Vec<T>, or the interior of either.
func sum(xs: &[i32]) -> i32 { mut total = 0 for x in xs { total = total + x } total} func double_each(xs: &mut [i32]) { mut i: u64 = 0 while i < xs.len() { xs[i] = xs[i] * 2 i = i + 1 }} val fixed: [i32; 4] = [1, 2, 3, 4]mut grown: Vec<i32> = Vec::new() val a = sum(&fixed) // whole arrayval b = sum(fixed.slice(1..3)) // sub-range, zero copyval c = sum(&grown) // Vec, same signature[T]alone is unsized and never appears outside a reference; annotating a parameter[T]is a compile error. The owned forms are[T; N]andVec<T>.- Unsizing:
&[T; N],&Vec<T>, and&[T]all satisfy a&[T]parameter, and the&mutforms a&mut [T]one. Mutability must match exactly — there is no&T→&mut Tstrengthening, and no&mut T→&Tweakening. .slice(range)on an array, aVec<T>, or another slice yields a&[T]view over the named sub-range, copying nothing. It acceptsa..banda..=b. An out-of-range or reversed range panics in every build, debug and release alike: the call hands back a view that outlives the check, so there is no later point at which the mistake could still be caught..len()is O(1), read from the length word of the view — the borrowed run's length, not the container's.- Indexing is bounds-checked exactly as on the owning container: debug builds panic on an
out-of-range index, release builds omit the check.
xs[i] = vrequires a&mut [T]; the write reaches the buffer, so the owner sees it. - Iteration:
for x in xsbinds each element by value, and.enumerate()works on a slice like it does on an array. - Borrow rules are the ordinary ones. A live view counts as a shared borrow of the place
it came from, so a
&mutof that place while the view is alive is rejected, and returning a view of a function-local buffer is rejected as a dangling reference.
Tuples
A tuple (T1, T2, ...) is an anonymous, fixed-size, heterogeneous aggregate.
Element types may differ; the arity is part of the type, so (i32, bool) and
(i32, i32) are distinct.
val pair: (i32, i32) = (12, 30) // explicit typeval mixed = (5, true, 'a') // inferred (i32, bool, char) val a = pair.0 // index access by constant positionval b = pair.1 val (x, y) = pair // destructuring bindval (_, keep, _) = mixed // `_` discards an elementval ((p, q), r) = ((1, 2), 3) // nested destructuring- Element type: currently restricted to
Copytypes, so a tuple is itselfCopy. Tuples holding astringor another non-Copyvalue are not yet supported (the same restriction as array elements). - Grouping vs. tuple: a single parenthesized expression
(x)is grouping, not a one-element tuple. A tuple literal needs at least two elements. - Index access:
t.0,t.1, … read by a constant index; an out-of-range index is a compile error. (Becauset.0.1lexes as the float0.1, write a nested access as(t.0).1.) - Destructuring:
val (a, b) = tbinds each position;_is a wildcard, and patterns nest. It is a binding form, not a new value, it desugars to ordinary bindings. - Function boundaries: tuples may be passed as parameters and returned, e.g.
func min_max(a: i32, b: i32) -> (i32, i32).
Struct and array destructuring patterns are also supported: val Point { x, y } = p
binds each named field, and val [first, second, ..rest] = arr binds array elements
positionally with an optional trailing ..rest (a fresh [T; N - k] remainder) or
bare .. to ignore it. A rest-less array pattern must match the array's length
exactly. See Variables → Destructuring.
Tensor Types
Tensor<T, [d0, d1, ...]> is a statically shaped tensor: the element type and every
extent are known at compile time and are part of the type.
type Weights = Tensor<f32, [784, 128]> struct Layer { bias: Tensor<f32, [128]>} func forward(w: Weights, x: Tensor<f32, [128]>) -> Tensor<f32, [128]> { return x} func loss(l: Tensor<f32, []>) { } // rank-0 scalar tensorfunc image(px: Tensor<u8, [3, 224, 224]>) { }The shape is written as a bracketed list of non-negative integer literals. An empty list
[] is the rank-0 scalar tensor. The element must be a fixed-width scalar: any integer
type, f16 / bf16 / f32 / f64, or bool.
Rank and every extent are part of the type, so Tensor<f32, [2, 2]> and
Tensor<f32, [3, 3]> are different types and a mismatch is a compile error naming both:
func takes_square(t: Tensor<f32, [3, 3]>) { } func pass_through(t: Tensor<f32, [2, 2]>) { takes_square(t) // error: expected Tensor<f32, [3, 3]>,} // found Tensor<f32, [2, 2]>A tensor owns its buffer, so it is not Copy. Passing one to a function moves it;
pass &Tensor<T, S> or &mut Tensor<T, S> to share it.
func consume(t: Tensor<f32, [2, 2]>) { } func twice(t: Tensor<f32, [2, 2]>) { consume(t) consume(t) // error: use of moved value 't'}.clone() is the explicit way to get a second owner. It takes no arguments, yields a
tensor of the same type, and leaves the receiver usable — including when it is called
through a borrow, where the result is an owned tensor rather than the borrow.
func consume(t: Tensor<f32, [2, 2]>) { } func read(t: &Tensor<f32, [2, 2]>) -> i32 { return 2 } func twice(t: Tensor<f32, [2, 2]>) { consume(t.clone()) consume(t) // fine: the clone was consumed, not `t`} func borrowed(t: &Tensor<f32, [2, 2]>) { consume(t.clone()) // an owned copy of someone else's tensor read(t) // the borrow was never consumed}Device transfer
.to(device) consumes the tensor and returns one whose buffer lives on the requested
device. Its argument is the prelude enum Device:
enum Device { CPU, GPU(i32) // GPU index}func main() -> i32 { val a = Tensor::<f32, [2, 2]>::identity() val here = a.to(Device::CPU) return 0 // `a` is moved: using it here is an error}To keep the source, clone first: t.clone().to(Device::CPU).
A borrow cannot be consumed, so .to is not offered on &Tensor<T, S> — calling it there
reports that the borrowed type has no such method.
The host is the only device this compiler can lower to today; the GPU backend is later
work. .to(Device::CPU) is therefore the move itself and copies nothing, and a transfer to
any other device aborts at run time with a diagnostic rather than quietly leaving the
buffer on the host.
Tensor is a prelude name rather than a keyword, so a module declaring its own
Tensor shadows it; a shape argument is what marks a type application as a tensor, and
writing one under any other name is a parse error.
Building a tensor
A nested array literal becomes a tensor wherever an explicit Tensor<...> annotation is
in scope. The annotation supplies the element type and every extent, and it types the
literal's leaves — 1.0 under a Tensor<f32, ...> annotation is an f32 literal, not
an f64 one being narrowed, exactly as val x: f32 = 0.01 types its literal.
val v: Tensor<f32, [3]> = [1.0, 2.0, 3.0] val m: Tensor<f32, [2, 3]> = [ [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] val arr = [1.0, 2.0, 3.0] // no annotation: a plain [f64; 3], not a tensorA nested literal must be rectangular: every sub-array at a given depth has the length the corresponding extent declares, and the nesting is as deep as the shape is long. A ragged literal, a wrong extent, and a literal shallower than the shape are all compile errors. A value that already has a type is not converted for the annotation's benefit — a non-literal element must already be the element type.
Where no annotation reaches, name the type with a turbofish and use a constructor:
val zeros = Tensor::<f32, [3, 3]>::zeros()val ones = Tensor::<f32, [3, 3]>::ones()val eye = Tensor::<f32, [4, 4]>::identity()val w = Tensor::<f32, [128, 64]>::random_normal(mean: 0.0f32, std: 0.02f32)val v = Tensor::<f32, [3]>::from([1.0, 2.0, 3.0])val loss: Tensor<f32, []> = Tensor::scalar(0.5)identity() applies only to a square rank-2 shape, random_normal draws only into f32
or f64, scalar builds only the rank-0 tensor, and from takes the same nested literal
the annotated form coerces. A rank-0 tensor has no array-literal form at all — it is
written with Tensor::scalar(value). The generator behind random_normal is seeded from a
fixed constant, so a compiled program draws the same values on every run.
A tensor owns its buffer, and that buffer lives out of line: the value is a pointer to
a flat, row-major run of its elements, allocated when the tensor is constructed and released
when its binding leaves scope. The buffer keeps one address for its whole life, and a tensor
of any size compiles at every optimization level. .clone() allocates a second buffer and
copies into it, so the copy is independent of the original. The buffer is host memory;
device placement and DLPack handles are later work.
A tensor moves like any other non-Copy value, and the move hands the buffer on rather than
copying it — binding it, passing it to a function, returning it, storing it in a struct
field, and .to(device) all transfer ownership, and only the last owner releases it. A
tensor held in a struct field is not released when the struct goes out of scope; that gap is
shared with the standard collections.
What tensors cannot do yet
A tensor can be built, bound, moved, cloned, passed, returned, transferred with
.to(device), and stored in a struct — but not yet read back. Indexing and slicing
(t[i, j], t[1..3, ..]), tensor arithmetic (a + b, a @ b), in-place compound
assignment, .t(), .reshape(...), and the reductions (.sum(), .mean(), .max(),
.min()) are all later work. Symbolic
extents (Tensor<f32, [M, K]>), named dimensions, and dynamic axes (Tensor<f32, [?, 768]>)
are not accepted; a non-literal extent is a parse error.
Standard Collections
Beyond the fixed-size [T; N], the standard library provides three heap-backed
collections. They are library types, not language primitives, but because the
language exposes no allocator, the compiler knows all three by name and lowers
their operations directly.
mut counts: Vec<i32> = Vec::new() // growable contiguous arraymut stock: HashMap<string, i32> = HashMap::new() // average-O(1) lookupmut ranks: BTreeMap<i32, i32> = BTreeMap::new() // key-ordered mapVec::new() and its siblings carry no value to infer an element type from, so
the binding must be annotated.
Ownership
All three own a heap buffer, so none is Copy: assignment and by-value passing
move them, and the buffer is freed when the owner leaves scope. A method
that mutates (push, pop, insert, remove, clear) needs a mut binding,
exactly like a &mut self method.
mut a: Vec<i32> = Vec::new()val b: Vec<i32> = a // moves; `a` is invalid from hereVec<T>
| Operation | Result | Notes |
|---|---|---|
v.push(x) | , | Appends; grows the buffer as needed |
v.pop() | Option<T> | None when empty |
v.get(i) | Option<T> | The checked read |
v[i] / v[i] = x | T /, | Panics out of range, in every build |
v.len() | u64 | Live element count |
v.clear() | , | Empties without releasing the buffer |
for x in v | , | Iterates the live elements in order |
Unlike [T; N], whose length is a compile-time constant, a Vec's length is
only known at run time, so its bounds check is never elided in release builds.
HashMap<K, V> and BTreeMap<K, V>
Both share one surface:
| Operation | Result | Notes |
|---|---|---|
m.insert(k, v) | , | Overwrites the value of an existing key |
m.get(k) | Option<V> | None when absent |
m.contains_key(k) | bool | |
m.remove(k) | bool | true when a key was removed |
m.len() | u64 | Live entry count |
m.clear() | , | |
m.keys() | Vec<K> | A fresh Vec, so a map is iterated via for k in m.keys() |
HashMap is open-addressed with linear probing and average-O(1) lookup.
BTreeMap keeps its entries ordered by key, so keys() comes back ascending:
that ordering is the reason to choose it.
Element and key types
- Elements and values must be
Copy(any integer, float,bool,char,Copystruct, enum, tuple, or array) orstring. - Keys additionally need equality and, per map, a hash or a total order. The
compiler supplies both for integer,
bool,char, andstringkeys. A struct key supplies them itself:impl PartialEqplusimpl Hashable(HashMap) orimpl Comparable(BTreeMap). - Float keys are rejected. IEEE-754 comparison is a partial order, NaN compares false against everything, including itself, so a map keyed on a raw float could hold a key it can never find again. Use the prelude's validating wrappers, which reject NaN at construction:
mut scores: BTreeMap<OrderedF32, i32> = BTreeMap::new()scores.insert(OrderedF32::new(0.75f32), 3)OrderedF32 / OrderedF64 implement Comparable, not Hashable: hashing a
float has no representation-independent answer, so they are ordered-map keys.
Hashable
Hashable is a compiler-known lang-item trait, like Drop and the operator
traits, write the impl, never a trait declaration:
impl Hashable for Point { func hash(&self) -> u64 { (self.x * 31 + self.y) as u64 }}The shape is fixed: exactly one hash(&self) -> u64. Equal keys must hash
equally; the map only needs that much.
Current limits
pop()andget()build anOption<T>, so they are available only for element typesOptioncan carry, scalarCopyprimitives in this phase. Index aVecof structs or strings withv[i]instead.- A
stringstored in a collection is not freed when the collection is dropped; only the collection's own buffer is. This matches the existing string-concat limitation and resolves with the heap-string work. Vec<T>does not go through theIntoIterator/Iteratorprotocol (control flow):for x in vlowers to a counted loop, exactly asfor x in arrdoes — and so doesfor (i, x) in v.enumerate(), which binds the counter that loop already keeps. The protocol is what a user-defined type implements to stand in aforhead; the built-in sequences take the direct path instead.
References
- Variables - Variable declaration and usage
- Functions - Function types and signatures
- Operators - Type requirements for operators
- Expressions - Expression type checking
See Also
- Rust Book: Data Types
- Type System Design