2Type System
Every value in Cryo has a known type at compile time. A binding's type is either written explicitly or inferred from its initialiser (local inference only - see section 3). There are no implicit conversions between numeric types; when you need a conversion, you write it with as.
2.1 Primitive Types
| Type | Description | Size |
|---|---|---|
void | No value; only valid as a return type. | 0 |
boolean | true / false. Not interchangeable with integers. | 1 byte |
char | 8-bit character (byte). | 1 byte |
string | NUL-terminated raw string (u8*). FFI-shaped. | pointer |
int | Default signed integer; alias for i32. | 4 bytes |
i8 i16 i32 i64 i128 | Signed integers of fixed width. | 1 / 2 / 4 / 8 / 16 bytes |
uint | Default unsigned integer; alias for u32. | 4 bytes |
u8 u16 u32 u64 u128 | Unsigned integers of fixed width. | 1 / 2 / 4 / 8 / 16 bytes |
float | Alias for f32 (single-precision IEEE 754). Distinct from double. Bare float literals still default to f64. | 4 bytes |
f32 f64 | IEEE 754 floats. | 4 / 8 bytes |
double | Alias for f64. | 8 bytes |
usize isize | Pointer-width unsigned / signed integers - distinct types whose width tracks the target's pointer size (the natural type for sizes and indices). | 8 bytes on 64-bit |
In performance-sensitive or cross-platform code, prefer the explicit-width forms (i32, u64, f64) so the layout is unambiguous. The shorthand aliases exist for ergonomics.
string is a NUL-terminated raw byte pointer matching the C ABI. Inside the standard library, length-typed UTF-8 is modelled by Str (borrowed) and String (owned). The translation between them happens at the FFI boundary in ffi::cstr.
2.2 Pointer Types
A pointer holds a memory address. Pointer types are written by suffixing the pointee with *, mirroring the C convention.
const p: int* = &x; // pointer to int
const pp: int** = &p; // pointer to pointer
const v: void* = malloc(64); // type-erased
Raw pointers are unchecked. Validity, aliasing, and lifetime are the programmer's responsibility. For a pointer that is statically guaranteed non-null, see core::ptr::NonNull<T>. For owned heap allocations, prefer Box<T>. See section 15.
2.3 References
References use &. They appear principally as method receivers (&this for shared access, mut &this for exclusive, mutating access) and as function parameter types.
&int // shared reference to int
mut &int // exclusive reference to int
The receiver shape on a method is part of its signature: a &this method may not modify the receiver; a mut &this method may. Callers see this distinction without reading the body.
2.4 Array Types
Two distinct things share the word "array" in Cryo: the raw array type (a low-level fixed buffer) and the growable Array<T> in the standard library.
const buf: int[16]; // raw fixed-size buffer of 16 ints
const dyn: int[]; // raw dynamic array (FFI / unsized)
mut v: Array<int>; // growable, heap-backed; from collections::array
For everything except FFI and stack-allocated scratch buffers, prefer Array<T> from the standard library. The shorthand T[] desugars to Array<T> in expression position when the prelude is loaded.
2.5 Function Types
Functions are first-class values. A function type names its parameter types and its return type.
(int, int) -> int
(T) -> U
() -> void
Function types appear most often as parameter types for higher-order combinators:
function apply(f: (int) -> int, x: int) -> int {
return f(x);
}
This is how Option::map, Result::and_then, and the iterator combinators take their callback.
A function value can be a named function or a lambda expression:
(params) -> Ret { body }. The body is always brace-delimited; each parameter
and the return type are written out explicitly.
const inc: (int) -> int = (n: int) -> int { return n + 1; };
const add: (int, int) -> int = (a: int, b: int) -> int { return a + b; };
apply(inc, 41); // 42 (named-as-value)
apply((n: int) -> int { return n * 2; }, 21); // 42 (inline)
A lambda that references a binding from the enclosing scope captures it,
becoming a closure. Copy types (i32, u64, bool, char, references, and any
aggregate whose components are all Copy and which has no Drop impl - Copy
is decided structurally, never declared) are captured by value-copy; non-Copy types are
captured by move - the outer binding is consumed at the lambda's
construction site (subsequent use is E0452) and the closure-struct's
synthesized Drop releases the captured value at scope exit. The move
keyword stays valid as an explicit prefix (move (params) -> T { body })
but is no longer required for non-Copy captures: any non-Copy capture
implicitly flips the lambda to move semantics. The compiler synthesises an
anonymous struct holding the captured fields plus a __call__ method whose
body is the lambda body; the closure value is the struct instance and the
call site dispatches directly through __call__. Stack-allocated; no heap
allocation.
const bias: i32 = 10;
const add_bias = (x: i32) -> i32 { return x + bias; }; // captures `bias`
add_bias(32); // 42
A closure can also be passed to a (Args) -> Ret-typed parameter; the
compiler specialises the receiver function per concrete closure type so
the body still issues a direct call, never an indirect one. Named
functions and non-capturing lambdas continue to bind to the same
parameter as bare function pointers, with no overhead change:
function apply(f: (i32) -> i32, x: i32) -> i32 { return f(x); }
const bias: i32 = 10;
apply((x: i32) -> i32 { return x + bias; }, 32); // 42 - capturing closure
apply((x: i32) -> i32 { return x * 2; }, 21); // 42 - non-capturing lambda
apply(tentimes, 4); // 40 - named function pointer
Where a capturing closure may be passed
The specialisation above happens per call site, and in 1.0 it is wired for one call shape: a non-generic free function. Passing a capturing closure anywhere else is rejected at compile time with E0458 rather than silently falling back to an indirect call:
| Callee | Capturing closure | Non-capturing lambda / named fn |
|---|---|---|
Non-generic free function - apply(c, x) | yes | yes |
Generic free function - apply<T>(c, x) | E0458 | yes |
Method - obj.run(c) | E0458 | yes |
Scope-resolution call - Type::run(c) | E0458 | yes |
extern "C" callback parameter | never (no environment slot) | yes |
The distinction is capture, not syntax. A lambda that captures nothing is an
ordinary function pointer and binds anywhere a function pointer does, including
every row above; a lambda that captures becomes an anonymous struct value, and
only the first row knows how to specialise a receiver for it. This is why
opt.map((n: int) -> int { return n * 10; }) is fine while the same call with
a lambda that closes over a local is E0458 - the callee is a method.
Two ways around it:
// 1. Pull the call into a free function that takes the closure.
function run_with(f: (i32) -> i32, v: i32) -> i32 { return f(v); }
run_with((x: i32) -> i32 { return x + bias; }, 32);
// 2. Close over nothing - pass the value as an argument instead.
opt.map((n: int) -> int { return n * 10; });
These are deferred capabilities, not bugs: the grammar and the closure representation already accommodate them, only the receiver-specialisation paths for generic, method, and scope-resolution callees are unimplemented.
A combinator that infers a new type parameter from the callback's return
type - for example Option::map<U> - does so automatically: U is bound from
the callback's signature, so the type argument may be omitted. This works the
same whether the callback is a lambda or a named function:
const some: Option<int> = Option::Some(5);
const out: Option<int> = some.map((n: int) -> int { return n * 10; }); // U = int, inferred
function tentimes(n: int) -> int { return n * 10; }
const out2: Option<int> = some.map(tentimes); // U = int, inferred
The explicit form is still accepted (some.map<int>(...)) and is required only
when the type parameter appears nowhere the call can infer it from.
2.6 Tuple Types
A tuple groups a fixed number of values - of possibly different types - into one compound value. Tuple types and tuple literals both use parentheses:
type Pair = (int, string);
type Triple = (int, int, int);
const p: (int, string) = (42, "answer");
const x: int = p.0; // positional element access: .0, .1, ...
const s: string = p.1;
const y: int = p[0]; // `t[N]` indexing is equivalent to `t.N`
Because parentheses are also used for grouping, the unit type, and function types, the forms are:
()- the unit type / unit value, which also serves as the empty tuple (section 2.7).(T)/(expr)- grouping: justT/expr, not a 1-tuple.(T,)/(x,)- a 1-tuple: the trailing comma distinguishes it from grouping.(T, U),(T, U, V), ... - 2-, 3-, ... element tuples.(A, B) -> R- a function type, not a tuple (the->disambiguates).
Element access is positional with an integer literal - t.0, t.1, ... (or the
equivalent t[0], t[1]) - and the index is checked against the tuple's arity
at compile time. Chained access like t.1.0 works (element 1, then element 0 of
that).
Note: a pre-1.0 bracket spelling,
[T, U], was previously accepted in type position. It has been removed - a leading[in a type is now an error. Write(T, U). (The array suffixesT[]/T[N]are unaffected: they are postfix on an existing type, not a leading bracket.)
2.7 The Unit Type
The unit type () represents "a value that carries no information." It is distinct from void:
void: a function produces no value.(): a function produces a value, but the value has zero meaningful payload.
() appears in generic positions where a type parameter is required but no data is needed. The canonical example is Result<(), Error> for an operation that either succeeds (with nothing to return) or fails with an error.
2.8 Type Aliases
A type alias introduces a new name for an existing type. Aliases are transparent: the alias and the original are interchangeable.
type Byte = u8;
type StringResult<T> = Result<T, string>;
type Callback = (int) -> void;
2.9 Casting with as
Cryo never inserts an implicit numeric or pointer conversion. To convert between types, use the as keyword. The compiler does not insert range checks for narrowing casts; this is a deliberate choice that keeps the conversion's cost visible.
const a: i64 = 42;
const b: i32 = a as i32; // narrowing: programmer's responsibility
const p: u8* = some_string as u8*; // pointer reinterpretation
2.10 Optional Types (T?)
T? is shorthand for Option<T>. It is pure sugar - the parser rewrites T?
to Option<T>, so the two are the same type. A T? value carries all of
Option's methods (is_some, is_none, unwrap_or, map, ...) and is freely
assignable to and from Option<T>. The suffix works in every type position:
variable, parameter, return type, struct field, and generic argument.
const slot: int? = Option::Some(7); // int? is Option<int>
const back: Option<int> = slot; // interchangeable both ways
function first(xs: int[]) -> int? { // optional return
if (xs.length == 0) { return Option::None; }
return Option::Some(xs[0]);
}
type struct Config {
port: u16?; // optional field
}
T? nests like any other type argument: Option<int?> is
Option<Option<int>>.
2.11 Opaque Types (implement Trait)
implement Trait in a return type or a variable annotation is an opaque
type: it stands for one specific concrete type without naming it. The
compiler infers the real type and uses it everywhere; you simply do not have
to write it.
// The concrete iterator (`SliceIter<T>`) never appears in the signature.
iter(&this) -> implement Iterator<T> where T: Copy {
return SliceIter<T> { ptr: this.ptr, remaining: this.length };
}
// A caller binds the result without naming the concrete type either.
mut it: implement Iterator<i32> = arr.iter();
const n: u64 = it.count(); // trait methods are available
Because Cryo monomorphises, this is a purely static, zero-cost construct: an
implement Iterator<i32> value is the underlying concrete type (here
SliceIter<i32>), method calls dispatch statically, and there is no heap
allocation, vtable, or runtime indirection. It corresponds to "return some
single type that implements this trait", not to a dynamically-dispatched
trait object. (implement is the same keyword used for implement blocks; in
type position it introduces an opaque type, while at the start of a
declaration it introduces an implement block - the two never overlap.)
Where it is allowed. Two positions:
-
Return type - the concrete type is inferred from the body's first
returnexpression. That expression must currently be a struct literal (e.g.return SliceIter<T> { ... }), which is how every standard-library iterator is written. -
Variable binding -
mut it: implement Iterator<i32> = expr;. The type is taken from the initialiser, which is then checked to actually implement the named trait; a mismatch isE0200:mut it: implement Iterator<i32> = some_non_iterator; // E0200When the initialiser is a concrete static constructor (
mut it: implement Iterator<i32> = Range<i32>::new(0, 10);), you can re-adapt the local directly -it.take(3)specialises the adapter against the recovered concrete receiver. The binding's visible type is still the opaque trait; the compiler recovers the concrete initialiser type for combinator specialisation.This recovery only applies when the initialiser names its concrete type. When the producer itself returns an opaque iterator (
mut it: implement Iterator<i32> = arr.iter();, whereiterreturnsimplement Iterator<...>), the concrete cursor is hidden behind that opaque return, so the local has no concrete receiver to specialise against - chain the combinator on the producing expression instead (arr.iter().take(2).count()), or bind to the concrete adapter type when you need a named local (mut z: ZipIter<Range<i32>, Range<i32>> = a.zip(b);).
To accept any iterator as a parameter, use a generic with a trait bound instead - that is the tool for the input side:
function sum<I>(mut it: I) -> i32 where I: Iterator<i32> { ... }
Multiple bounds combine with +: implement Iterator<i32> + Clone.
One concrete type per site. An opaque return names a single underlying
type - every return in the body must produce the same one. Two iterators of
different concrete types cannot be returned from one implement Trait
function (that would require a dynamically-dispatched trait object, which Cryo
does not provide).
The trait argument carries the trait's associated item: Iterator<i32> is
sugar for Iterator<Item = i32>, so a binding annotated implement Iterator<i32> is verified both to implement Iterator and to have an
actual Item of i32. A genuine element-type mismatch (e.g. binding an
iterator of String to implement Iterator<i32>) is rejected as E0200.
The cross-check is category-level: it flags a structurally distinct item
(one user struct vs another, struct vs primitive) but stays silent when the
declared and actual items are implicitly inter-convertible (i32 vs i64)
or differ only in representation (String vs String<GlobalAlloc>).