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 the scalar surfacef32has, each operation correctly rounded - Implemented: extended integer types (
i8-i64,u8-u64) - Implemented: function types, void type, type aliases, newtypes
- Implemented: contextual inference for numeric literals
- Implemented: fixed-size arrays
[T; N], including of non-Copyelements, and borrowed slices&[T]/&mut [T]over an array, aVec<T>, or a sub-range - Implemented: tuples
(T1, T2, ...), including of non-Copyelements, with destructuring - Implemented: structs, enums, generic enums,
Option<T>/Result<T, E>, and the standard collections - Implemented: generic functions, structs, and impls, monomorphized
- Implemented: traits, operator traits, and
impl/dyndispatch - Text types are on their own page (Strings), as are tensors (Tensors)
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.
Arithmetic made only of literals takes its context's type the same way, so val d: u8 = 200 + 50
is u8 arithmetic and val big: i64 = 2147483647 + 1 is 2147483648. A literal next to a typed
operand takes that operand's type.
Type Suffixes: A suffix appended directly to an integer literal overrides contextual inference and pins the type:
val a = 42i64 // i64, no annotation needed
val b = 255u8 // u8
val c = 0xFFu8 // hex literal with suffix
val 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 covers +, -, *, unary -, and the one overflowing division, MIN / -1 (and the matching MIN % -1).
A shift is an overflow when its amount is outside 0..bits of the shifted type, a negative signed amount included. << and .shr(n) then panic with shift overflow in a debug build, and a release build masks the amount to amount & (bits - 1), so 1 << 40 on an i32 is 1 << 8.
A const initializer is folded by the compiler, so it never reaches either tier. Rather than pick one and make the choice observable in a value, an initializer whose arithmetic overflows its declared type is a compile error:
const C: u8 = 200u8 + 100u8 // error: constant expression overflows u8Write the value you mean, or widen the type. The bitwise operators &, |, ^ and ~ have no overflow rule to begin with and fold exactly as they evaluate at run time, a << folds when its amount is inside 0..bits and is a compile error otherwise, and an explicit as cast still narrows: 300 as u8 is 44 in a const just as it is in a function body.
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 = 1u8
val 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 unsigned
val 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 = 200
val b: u8 = 100
val 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 ordinary floats, computed through f32 (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 needed
val b = 2.0f64 // f64
val c = 1e10f32 // exponent form with suffix
val 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.
A float literal that rounds to infinity in its type is an error: 1e400, 1e39f32 and
70000.0f16 are refused, as an integer literal too large for its type is.
Float Methods
| Method | Returns | Behavior |
|---|---|---|
.is_nan() | bool | true when the receiver is NaN, false for every other value including Inf and -Inf. Nullary. |
.exp(), .log(), .sqrt(), .tanh(), .abs() | the receiver's type | The function of the receiver, IEEE 754 out of domain (.log() of 0.0 is -inf). Nullary. Float tensors have them too, see tensors. |
.pow(p) | the receiver's type | The receiver raised to p, a value of the receiver's type. |
.to_checked::<T>() | Option<T> | The receiver truncated toward zero as the integer type T, or None when the result does not fit T or the receiver is NaN. See Type Conversion. |
Every float type has these methods, f16 and bf16 included.
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.0
val nan: f64 = zero / zero // 0.0 / 0.0 is NaN
val inf: f64 = 1.0 / zero // division by zero is Inf, an ordinary ordered value
val a: bool = nan.is_nan() // true
val b: bool = inf.is_nan() // false: infinity is not NaN
val 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.
Half-Precision Types (f16 / bf16)
f16 and bf16 are ordinary floats, the storage types of mixed-precision training. They have binding and Copy, as casts to and from every numeric type, arithmetic (+ - * / %, unary -, compound assignment), the six comparisons, the float methods and .to_checked. A literal takes its type from context (val h: f16 = 0.5, h * 2.0) or from a suffix (1.5f16, 0.02bf16).
Each operation is correctly rounded to the operand type. a + b on two bf16 values computes in f32 and rounds the answer to bf16 once. f32 carries more than twice either format's significand bits, so that one rounding gives exactly the IEEE 754 result for + - * / and .sqrt(), on every target, whatever half-precision hardware it has. There is no excess precision: a * b + c rounds after * and again after +, at every optimization level.
val one: bf16 = 1.0
val step: bf16 = 0.00390625 // 2^-8, half the gap above 1.0
val stepwise = one + step + step // 1.0: each addition is a tie, rounded to even
val carried = (one as f32 + step as f32 + step as f32) as bf16 // 1.0078125There is no implicit widening. An f16 meets only f16, so h + 1.0f32 and h + 1 are type errors; a program that wants f32 accumulation writes as f32, as carried does. A half hole in an interpolated string prints the exact value, as an f32 hole does: "{h}" and "{h as f32}" give the same text.
As tensor element types (Tensor<bf16, [...]>) they have elementwise operators (with a tensor or a half-precision scalar on the other side), compound assignment, matmul, einsum, reductions, sorts, traversals and the elementwise math methods. On the CPU each element is computed in f32 and rounded back once, and a reduction accumulates in f32, so a long bf16 sum does not stall where a 16-bit running total would.
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 grouping
val mask = 0xFF_FF // hex
val flags = 0b1010_0011 // binary
val perms = 0o7_5_5 // octal
val ratio = 1_000.000_5 // float
val scaled = 1_0e1_0 // exponent
val 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
string and the growable StringBuilder buffer have their own page: see
Strings.
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. A field may have any type: a scalar, string, a collection, an array, a tuple, a tensor, an enum or another struct. A struct cannot hold itself inline.
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 p
val 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: cannot assign to field 'x' of immutable binding 'fixed'Definition 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 moved
val s = a.x + b.y // a is still valid hereRules:
- A struct may derive
Copyonly when every field isCopy. The scalars (integers, floats includingf16/bf16,bool,char) areCopy, and so are arrays and tuples of them;stringand the collections are not; a struct field isCopyonly when its type also derivesCopy. Anything else is a compile error that names the offending field. CopyimpliesClone..clone()on anyCopyvalue returns a copy.@derive(Clone)(orCopy) enablesstruct.clone(), an explicit copy that returns a fresh value without moving the receiver. The derived copy does not yet clone a field that owns a buffer (string, a tensor, a collection,StringBuilder, or an array of them), so such a field makes the derive aDeriveFieldUnsupportederror (BUG-098); writeimpl Cloneby hand instead. A field whose type has a hand-writtenCloneis cloned by it, unless the struct being cloned isCopy: aCopyvalue's.clone()is always a copy.@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, another struct that derivesDebug, or a type whoseDebugis written by hand, which renders through its ownfmt_debug). 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 derivePartialEqor implement it by hand, and a hand-written one is compared by its owneq.@derive(Hashable)gives the struct a hash of its fields, taken in declaration order. Each field must be an integer,bool,char,string, or a type that derives or implementsHashable; a float is refused. A struct derivingPartialEqandHashableis aHashMapkey.- The derivable set is exactly
Copy,Clone,Debug,PartialEq,Hashable. Any other name in a@derivelist isUnknownDerive, never a silent no-op, and a repeated name isDuplicateDerive. Deriving a trait and also writing itsimplfor the same struct isDeriveConflictsWithImpl: keep one.
Each derivable name is also an ordinary trait, implementable by hand and usable as a bound; Structs covers both.
@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) or impl 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 variants
enum Color {
Red,
Green,
Blue
}
// Mixed variant shapes
enum 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 variant
val m = Message::Move(1, 2) // tuple variant
val 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. An enum is Copy exactly when every payload it can carry is, so an enum over scalars duplicates on a bind while one carrying a string moves.
Methods and Trait Impls
An enum takes impl blocks exactly as a struct does: &self, &mut self and consuming self
methods, associated functions, user trait impls (static dispatch through a bound, dynamic
through &dyn Trait, default methods included) and operator traits such as PartialEq. The
body usually matches on self, and a &mut self method may assign a whole new variant to
self. From examples/types/enum_methods.nr:
impl Light {
func start() -> Light {
return Light::Red
}
func advance(&mut self) {
self = match self {
Light::Red => Light::Green,
Light::Green => Light::Amber,
Light::Amber => Light::Red
}
}
}
impl PartialEq for Light {
func eq(&self, other: &Light) -> bool {
return self.wait_secs() == other.wait_secs()
}
}Light::start() is an associated call and Light::Red a variant, so an impl member may not
share a variant's name (ImplMemberNamesVariant). A generic enum's impl<T> Tree<T> is
monomorphized with each instance, as a generic struct's is.
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.
Limitations
- Payloads must be sized. Any sized type is admissible,
Copyor not: astring, a struct, an array, a tuple, another enum.voidand the unsized types (dyn Trait,[T]) are rejected (UnsupportedEnumPayload), because a payload slot has to have a width. - No enum that holds itself inline, directly or through a struct, array, tuple or another enum: it would have no finite size.
- No
impl Dropon an enum (InvalidDropImpl): only a struct runs a user destructor. - No
.map_error otherOption/Resulthelper methods yet. - No lifetime parameters on an enum;
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 void or an unsized type |
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 |
ImplMemberNamesVariant | An impl method or associated function named like one of the enum's variants |
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 = i32
newtype Seconds = i32
newtype 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) // construction
val 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 Meters
val also_bad = Meters(1) + Seconds(2) // ERROR: no `impl Add` makes Meters + SecondsA newtype forwards Copy/Clone from its inner type, so a Copy-inner newtype is itself Copy and a newtype Name = string moves like the string it wraps. It can be a val/mut binding, a function parameter or return type, and a struct field.
A newtype takes impl blocks and trait impls exactly as a struct does: methods with any
receiver, associated functions, user traits (through a bound or &dyn), and the operator
traits, which is how it gets arithmetic:
newtype Meters = i32
impl Add for Meters {
type Output = Meters
func add(self, rhs: Meters) -> Meters { Meters(self.0 + rhs.0) }
}
impl Meters {
func double(&self) -> Meters { Meters(self.0 * 2) }
}.0 reads the inner value through a borrow too, so m.0 works on m: &Meters.
Type Errors
| Error | Cause |
|---|---|
NewtypeAlreadyDefined | A newtype reuses a builtin, struct, enum, or newtype name |
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.- A method call auto-derefs its receiver through any number of
&and&mut:r.len()on a&string,r.method()on a&Structor a& &Struct, andr.abs()on a&f64behave as if applied to the referent. A field read does too (r.fieldon a&Structor a& &Struct); a field write goes through one&mut. &&Tis a borrow of a borrow, the same type as& &T, and&&xborrows&x.- Every built-in operator reads a borrowed operand: see Reading through a borrow.
- 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 itA returned &T is checked against what it points into; see
Lifetimes.
Reading through a borrow
Arithmetic, comparison, bitwise and logical operators accept a borrowed operand wherever they
accept an owned one, and so do + and == on strings, every tensor operator, compound
assignment and string interpolation. The result is what the owned operands would give, and the
operand stays borrowed. A bare literal next to a borrowed operand takes the referent's type.
func clamp_add(x: &i64, step: &mut i64) -> i64 {
if x > 100 { return x - 1 }
*step += 1
2 * x + step.saturating_mul(2) // i64 arithmetic; x and step are only read
}Anywhere else a borrow keeps its own type: val y = *x copies the referent out,
val y = x copies the borrow, x as i64 is an error, and a write still goes through *r = v.
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 third rule governs the borrowee (the place the borrow points at) rather than the borrows against each other:
- While a
&mut Tof a place is live, the place may not be read, written, or moved through its own name. Every access goes through the borrow. - While any borrow of a place is live, the value may not be moved out of it, nor the binding assigned a new one. Either would leave the borrow pointing at storage the binding no longer owns.
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
}func consume(s: string) -> u64 { s.len() }
func main() -> i32 {
mut n: i32 = 1
val r: &mut i32 = &mut n
val read: i32 = n // ERROR: cannot use 'n' while it is mutably borrowed
*r = 5 // write through the borrow instead
val s: string = "hello"
val b: &string = &s
val len: u64 = consume(s) // ERROR: cannot move out of 's' while it is borrowed
return 0
}The diagnostics are cannot borrow '<name>' as mutable (a &mut while any borrow is live),
cannot borrow '<name>' as immutable (a & while a &mut is live),
cannot use '<name>' while it is mutably borrowed (any access through the frozen name),
cannot move out of '<name>' while it is borrowed, and
cannot assign to '<name>' while it is borrowed.
By design: the borrow region is lexical, not non-lexical liveness (NLL). A borrow held by a binding freezes its borrowee until that binding leaves scope, even when the borrow is never used again. Code is written around it: read back through the borrow, or confine the borrow to a block, and the block is what separates a shared read from a later
&mut. A borrow passed to a call ends with that call, so sequential calls never conflict.
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 annotation
val pi: f64 = 3.14159 // Explicit type annotation
val flag: bool = true // Explicit type annotation
val n = 100 // Inferred i32 (default for integer literals)
val e = 2.71828 // 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 has no implicit conversions between types:
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 Safety Guarantees
Every type error is reported at compile time, and no value changes type without an as.
Ownership and borrow checking reject a use after a move, a write through a shared borrow and
a reference that outlives its value; docs/BUGS.md lists the open defects in
that guarantee.
Common Type Errors
Type Mismatch
func mismatch() -> i32 {
val x: i32 = true // Error: expected i32, found bool
return x
}Error message:
error: type mismatch: expected i32, found bool --> program.nr:2:5 |2 | val x: i32 = true // Error: expected i32, found bool | ^^^^^^^^^^^^^^^^^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:
error: type mismatch: expected i32, found bool --> program.nr:6:22 |6 | return takes_i32(true) // Error: expected i32, found bool | ^^^^Return Type Mismatch
func returns_wrong() -> i32 {
return true // Error: expected i32, found bool
}Error message:
error: return type mismatch: expected i32, found bool --> program.nr:2:5 |2 | return true // Error: expected i32, found bool | ^^^^^^^^^^^Best Practices
1. Choose Appropriate Integer Types
// Good: use smallest type that fits
val age: u8 = 25 // Ages fit in u8 (0-255)
val year: u16 = 2025 // Years fit in u16
val file_size: u64 = 1000000000 // Large files need u64
// Avoid: unnecessarily large types
val counter: i64 = 0 // Wasteful if i32 suffices2. Use f64 for Most Floating-Point Math
// Good: f64 for precision
val pi: f64 = 3.141592653589793
// Only use f32 when:
// - Memory is constrained
// - Precision is not critical
// - Interfacing with f32 APIs3. Be Explicit About Types
Inference is available, but an explicit type states intent where a literal alone would not:
// Clear intent
func calculate_area(radius: f64) -> f64 {
val pi: f64 = 3.14159
return pi * radius * radius
}4. Use Booleans for Flags
// Good: boolean for true/false
val is_valid: bool = true
// Avoid: integer for boolean logic
val is_valid_flag: 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).
Float to integer
as from a float to an integer truncates toward zero. Every input has a defined result: a
value the target cannot hold saturates to the target's minimum or maximum, and NaN becomes
0. When a clamped answer would be wrong, use .to_checked::<T>(), which returns
Option<T> and gives None in exactly the cases where as would saturate or map NaN:
val reading: f64 = 300.7
val clamped: u8 = reading as u8 // 255
val checked = reading.to_checked::<u8>() ?? 0u8 // None, so 0
val small = (2.9).to_checked::<i8>() // Some(2).to_checked takes an f16, bf16, f32 or f64 value and exactly one integer type
argument.
A cast whose operand is a compile-time constant (a float literal, a const, or arithmetic
over those) that the target cannot hold is reported as the warning
float-cast-out-of-range. The program still compiles, and the cast still saturates.
Silence the warning with @allow(float_cast_out_of_range) on the enclosing function or
method, or on the module-level const whose initializer holds the cast:
@allow(float_cast_out_of_range)
const CEILING: i32 = 1e20 as i32Examples
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 = f64
type 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 = B
type B = i32
val 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 type
val b = [1, 2, 3] // inferred [i32; 3]
val first = a[0] // index read
mut 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 value
for x in &a { } // iterate over a borrow- Element type: any sized type. An array of
Copyelements is itselfCopy; an array holding an owner (astring, a non-Copystruct) is itself an owner and moves rather than copies. Reading one element out is a move of that element: see Ownership of an element below. - 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 every build. Unlike integer overflow, which wraps in release builds, an index past the end has no defined result to fall back on.
- 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 array
val b = sum(fixed.slice(1..3)) // sub-range, zero copy
val 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. There is no&T→&mut Tstrengthening. The one weakening is at a call: a&mutbinding passed to a¶meter is a shared reborrow for the call's duration, and one call may not reborrow the same&muttwice unless both parameters are&. .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: an out-of-range index
panics in every build.
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 type
val mixed = (5, true, 'a') // inferred (i32, bool, char)
val a = pair.0 // index access by constant position
val b = pair.1
val (x, y) = pair // destructuring bind
val (_, keep, _) = mixed // `_` discards an element
val ((p, q), r) = ((1, 2), 3) // nested destructuring- Element type: any sized type. A tuple is
Copyexactly when every element is, and otherwise moves, on the same terms as an array element: see Ownership of an element. - 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.
Ownership of an element
An aggregate that holds an owner IS an owner. An array, a tuple, a struct, an enum
payload, a newtype and a generic instance all follow the same three rules when what they
hold is not Copy.
The aggregate moves. Passing or binding it hands over everything inside it, so the source binding is spent:
val pair: (string, string) = ("left", "right")
val taken = pair // moves both elements
// println(pair.0) // ERROR: use of moved value 'pair'One element at a time. Reading an element out moves that element and leaves its siblings alone, which is what makes destructuring work:
struct Two { a: string, b: string }
val t = Two { a: "x", b: "y" }
val first = t.a // moves `t.a`
val second = t.b // `t.b` is untouched and still owned
val Two { a, b } = Two { a: "p", b: "q" } // the same thing, spelled as a patternA partially moved value cannot be read whole. Once any element has left, the aggregate is no longer complete, so naming it as a whole is an error:
val t = Two { a: "x", b: "y" }
val first = t.a
// consume(t) // ERROR: use of moved value 't'An index the compiler cannot evaluate names no particular element, so a move through one
takes the whole binding: a[0] moves element 0, while a[i] moves a. Use
.clone() on the element when the aggregate has to stay intact.
Tensor Types
Tensor<T, [d0, d1, ...]> and everything built on it (construction, indexing, shape
generics, named dimensions, reshaping, reductions, sorting, dynamic axes, devices) has
its own page: see Tensors.
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 array
mut stock: HashMap<string, i32> = HashMap::new() // average-O(1) lookup
mut 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 hereA collection owns what its slots hold, not what its arguments named. A string
handed to push or insert is copied into the slot, so the argument is read
rather than moved and the binding it came from stays usable; a string read back
out of a slot is copied too, so it outlives the collection it came from. The one
exception is pop, which hands over the slot's own buffer because the slot goes
with it.
val name: string = "ada" + "!"
mut names: Vec<string> = Vec::new()
names.push(name) // copies; `name` is still usable
val first = names[0] // a copy of its own, valid after `names` is goneVec<T>
| Operation | Result | Notes |
|---|---|---|
v.push(x) | nothing | 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 / nothing | Panics out of range, in every build |
v.len() | u64 | Live element count |
v.clear() | nothing | Empties without releasing the buffer |
for x in v | nothing | Iterates the live elements in order |
A Vec index is checked in every build, like an array's; a Vec's length is only
known at run time, so the optimizer can rarely prove the check redundant.
HashMap<K, V> and BTreeMap<K, V>
Both share one surface:
| Operation | Result | Notes |
|---|---|---|
m.insert(k, v) | nothing | 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() | nothing | |
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 needsPartialEqplusHashable(HashMap), derived or written by hand, orPartialEqplusimpl Comparable(BTreeMap). A type parameter is a key when its bounds name the same traits andCopy(func count<K: Hashable + PartialEq + Copy>). - 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 prelude trait, trait Hashable { func hash(&self) -> u64 }. A struct derives
it or implements it by hand:
impl Hashable for Point {
func hash(&self) -> u64 {
(self.x * 31 + self.y) as u64
}
}Equal keys must hash equally; the map only needs that much. The integers, bool, char and
string implement it already, and .hash() on one of them, or on a struct that derives it,
returns the hash the map uses for that key.
Current limits
- A collection element must be
Copyorstring, so aVecofStringBuilders or of non-Copystructs is rejected where an array of them is accepted. There is no capacity control (with_capacity,shrink_to_fit),insertdoes not return the displaced value, andkeys()builds aVecrather than an iterator. 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
- Strings: the
stringslice and the growableStringBuilderbuffer - Tensors:
Tensor<T, [dims]>and its shape system - Structs:
implblocks, methods, derives - 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