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
neverThe type of an expression that does not return. Only valid as a return type, and written by functions that always panic, trap, or diverge.0
()The unit type. A value of this type carries no information.0

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.

import std::core::intrinsics::{ malloc };

const p:  int*  = &x;          // pointer to int
const pp: int** = &p;          // pointer to pointer
const v:  void* = malloc(64);  // type-erased

A pointer converts to another pointer type without a cast only when the pointee is the same type (T* to const T*, never back), or when either side is void*. A pointer to any other type is written with as, including a pointee that converts to the wanted one: an i32 converts to an i64, but an i32* addresses four bytes, so it is not an i64* (nor is an i32** an i64** or a void**). A value passed where a pointer parameter wants its address must be of the pointee type itself. The rule is the same wherever a value meets a declared type: an initializer, an assignment, a return, a struct field, and an argument of every kind of call - a method, a free or static function, a function reached by its path and a generic one (E0214 at an argument, E0200 elsewhere).

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 14.

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.

Reading the value a reference refers to is explicit in arithmetic and comparison: *x == 7, *x % 2. Writing x == 7 or x % 2 with x: &int is an error, never a comparison of the reference itself (section 5.2). Member access reads through the reference: h.tag with h: &Heap.

2.4 Array Types

Cryo has two array types: the fixed array [T; N], which holds its N elements in place, and the dynamic array [T], growable and heap-backed. The dynamic array is the standard library's Array<T> with the default allocator: [T] is a spelling of that type, not a second one, so a [T] and an Array<T> are interchangeable everywhere. An array with another allocator is written out, Array<T, A>; [T] never names one.

An array type is written in brackets: [T] is a dynamic array and [T; N] a fixed one of N elements. The element between the brackets is a whole type, so [&Node] is an array of references, &[Node*] a reference to an array of pointers, [(i32) -> i32] an array of functions, and [[u64; 2]; 3] three rows of [u64; 2] - the length written nearest the element belongs to the inner array. A , after the first type, [T, U], is refused (E0104): a tuple is written (T, U).

An array is never written after its element: a type followed by [ is refused (E0104, "array types are written [T]: write [i64]"), and the diagnostic carries the bracketed spelling as a fix that an editor can apply. The fix writes nested lengths outside-in, so u64[2][3] becomes [[u64; 2]; 3], and drops parentheses the brackets make unnecessary, so (&Node)[] becomes [&Node]. new u8[n] is not an array type but an allocation yielding a pointer (section 14), and keeps its spelling.

const buf: [int; 16];   // fixed: 16 ints held in place
mut   v:   [int];       // dynamic: `Array<int>`, growable, heap-backed
mut   w:   Array<int>;  // the same type as `v`, written out

A fixed array does not convert to a dynamic one: const d: [int] = buf; is refused (E0200). A dynamic array has Array's methods, iterates with for as Array does, and is indexed through Array's Index implementations: xs[i] is *(xs.index(i)), for an index of any integer type, in a generic body as anywhere else. An index out of range panics, and the report names the xs[i] that wrote it (![caller_location], section 4.5).

Diagnostics and the editor spell an array the way the annotation that declared it wrote it: one written [i32] shows as [i32], one written Array<i32> as Array<i32>. The spelling is for display only. A type no annotation wrote, an inferred one, shows as [T] when its allocator is the default and as Array<T, A> otherwise.

An array literal written where an Array<T> with the default allocator is expected builds that array, in every form: [a, b], [], and [value; n] with n fixed or known only at run time. The literal allocates the storage, so an Array<T, A> with another allocator is not initialised by one. A literal passed for a [T] parameter whose T the call infers is the Array of its own element type, and its elements are what bind T: for function generic_len<T>(xs: [T]) -> i64, generic_len([true, false]) calls generic_len::<boolean>. An array literal whose elements are all numeric literals is as width-polymorphic as one numeric literal: when another argument or the expected type binds T, its elements are typed as that T instead of their defaults, so count_eq([1, 2], wide) with count_eq<T>(xs: [T], y: T) and wide: i64 binds T = i64 and passes an [i64]. An element that cannot be that T (a float literal for an integer T, an integer out of its range) is refused as a single literal would be.

An array's length and capacity, read as fields or through length() and capacity(), are i64. Array's methods take an index or an element count as a u64.

The N in [T; N] is a compile-time integer: an integer literal, a named const (declared in this module or another, written bare or qualified), an enum variant, a cast between integer types, or arithmetic, comparison, logical, and bitwise operators over those. The repeat expression [value; N] accepts exactly the same set and yields [T; N], so a field and the initializer that fills it can share one name for their length; a count that is not a compile-time integer is legal there and yields a dynamic [T] sized at run time. A size that is neither is E0239. value is evaluated once and the same bits are stored in every slot, so its type must be a plain-copy type (one that satisfies Copy); an element that releases what it owns would be released once per slot, and is refused (E0453).

const CAPACITY: i64 = 512;
type struct Grid { cells: [boolean; CAPACITY]; }
const cells: [boolean; CAPACITY] = [false; CAPACITY];

sizeof/alignof are not available in this position: an array's size is fixed before any layout is computed. static_assert runs after layouts exist and does accept them (section 17.5).

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. A closure with a non-Copy capture implements Drop - an empty drop, after which the captures are released as any aggregate's fields are

  • so a written f.drop() releases what it captured exactly once, and so does leaving its scope; a closure whose captures are all Copy is itself Copy.
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

Naming a function as a value. A named function used without a call is a function value. When the name is overloaded (section 4.6), the expected function type picks the overload; with no expected type the use is ambiguous (E0154), and the error's help shows the annotation that picks one:

function twice(x: i32) -> i32 { return x * 2; }
function twice(x: f64) -> f64 { return x * 2.0; }

const f: (i32) -> i32 = twice;    // the `twice(i32)` overload
const g = twice;                  // error[E0154]: `twice` is overloaded, and nothing here says which overload is meant
                                  // help: give the variable a function type to pick one: `const g: (i32) -> i32 = twice;`

A generic function used as a value infers its type arguments from its use, as a call infers them from its arguments. The expected function type supplies them, and the turbofish names them explicitly:

function idf<T>(x: T) -> T { return x; }

const h: (i32) -> i32 = idf;         // idf::<i32>
const k = idf::<i64>;                // (i64) -> i64
const n: i32 = h(3);                 // 3

A bare name in expression position is a value. An identifier written alone in an expression is looked up among values only. A name that declares only a type is refused there - as a value (E0201) and as a callee (E0231) - so Point(1, 2) is an error and Point { x: 1, y: 2 } is the literal.

Calling something that is not a function. A call whose callee has a non-function type is refused with E0213, whether the callee is a call's result, a local or a field:

type struct H { n: i32; }
function one() -> i32 { return 1; }

const x: i32 = 3;
const h: H = H { n: 1 };
const a: i32 = one()(3);   // error[E0213]: expected function, found `i32`
const b: i32 = x(3);       // error[E0213]: expected function, found `i32`
const c: i32 = h.n(4);     // error[E0213]: expected function, found `i32` (`n` is a field)

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

A capturing closure is not a function pointer, so a slot of function type cannot hold one either. A variable, field or array element typed (Args) -> Ret, a place of that type assigned to, and a function returning one are each refused where the capturing closure meets them (E0458). A variable with no annotation takes the closure's own type and holds it:

const bias: i32 = 40;
const f = (x: i32) -> i32 { return x + bias; };              // ok: the closure's own type
const g: (i32) -> i32 = (x: i32) -> i32 { return x + bias; }; // error[E0458]: a capturing closure cannot be held by a variable of function type `(i32) -> i32`

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 leading [ in a type starts an array type, [T] or [T; N], so [T, U] is refused (E0104). Write (T, U).

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.

The expression () is the one value of the unit type, and it has type ():

function done() -> () { return (); }

const u: () = done();
const r: Result<(), i32> = Result::Ok(());

() is only the unit value. It is not a null pointer: written where a pointer is expected (const p: i32* = ();, an assignment, an argument), it is a type mismatch. The null pointer is null.

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;

A function's parameter and return types written as aliases are the types they name, as everywhere else: a parameter cb: Callback is called as a function, and a call returning Byte takes the operators of u8. So two overloads whose parameters differ only by an alias and its target, pick(x: u8) and pick(x: Byte), have one signature, and the second is a redefinition (E0206).

A path through an alias names what the same path through its target names, type arguments included: with type PI = Pl<i32>;, PI::plain(8) is Pl::<i32>::plain(8), and with type Hue = Color;, Hue::Green is Color::Green. An alias of an alias is followed. An alias with no parameters of its own fixes its target's arguments, so arguments written on it, PI::<u8>::size(), are too many and refused (E0307); a path with other arguments is written through the target, Pl::<u8>::size(). An alias of a type that is no named type (Callback) names no path. A path through an alias with parameters of its own, StringResult::<i32>::Ok(1) or StringResult::Ok(1), is refused where it is written (E0233); the path is written through the type the alias names. So is a struct literal through one: with type Keyed<T> = Pair<T, i64>;, Keyed::<boolean> { ... } and Keyed { ... } are refused (E0233) and the literal is written Pair::<boolean, i64> { ... }, while a non-generic alias's literal, Fixed { ... } with type Fixed = Pair<boolean, i64>;, builds its target. Such an alias is a type like any other.

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

A cast to a struct, tuple or array value is a cast from a value of that same type, and nothing else: p as Point with p a pointer, or one aggregate cast to another, is refused (E0208); *p reads the value a pointer points at.

A struct, tuple or array value is not cast to a pointer or a scalar either (E0208): v as void* is refused, and the value's address is written &v (and converted as &v as void*). Inside a method taking &this, this is that address already, so this as void* is allowed.

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 as u64 };
}

// 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>).