Skip to content
CryoCryo home
LanguageFoundations

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

TypeDescriptionSize
voidNo value; only valid as a return type.0
booleantrue / false. Not interchangeable with integers.1 byte
char8-bit character (byte).1 byte
stringNUL-terminated raw string (u8*). FFI-shaped.pointer
intDefault signed integer; alias for i32.4 bytes
i8 i16 i32 i64 i128Signed integers of fixed width.1 / 2 / 4 / 8 / 16 bytes
uintDefault unsigned integer; alias for u32.4 bytes
u8 u16 u32 u64 u128Unsigned integers of fixed width.1 / 2 / 4 / 8 / 16 bytes
floatAlias for f32 (single-precision IEEE 754). Distinct from double. Bare float literals still default to f64.4 bytes
f32 f64IEEE 754 floats.4 / 8 bytes
doubleAlias for f64.8 bytes
usize isizePointer-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:

CalleeCapturing closureNon-capturing lambda / named fn
Non-generic free function - apply(c, x)yesyes
Generic free function - apply<T>(c, x)E0458yes
Method - obj.run(c)E0458yes
Scope-resolution call - Type::run(c)E0458yes
extern "C" callback parameternever (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: just T / 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 suffixes T[] / 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 return expression. 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 is E0200:

    mut it: implement Iterator<i32> = some_non_iterator;  // E0200

    When 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();, where iter returns implement 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>).