Skip to main content
Version: 0.8.0

Type System

Metel is statically and strongly typed. Types are checked at compile time. There are no implicit conversions.

Primitive Types

TypeDescriptionExample
i6464-bit signed integer42
f6464-bit floating point3.14
booleanBooleantrue
StringUTF-8 string"hello"
CharUnicode scalar value'a'
()Unit — represents no value()

The unit type () is only written explicitly when needed as a type parameter (e.g. Result<(), Error>). Functions that return nothing omit the -> annotation entirely.

Sized Numeric Types

Availability: Since v0.8.0 (RFC-0007).

Metel provides exact-width numeric types for low-level and systems programming. i64 and f64 are the default integer and floating-point types in ordinary code.

Signed integers:

TypeWidth
i88-bit
i1616-bit
i3232-bit
i6464-bit

Unsigned integers:

TypeWidth
u88-bit
u1616-bit
u3232-bit
u6464-bit

Floats:

TypeWidth
f3232-bit IEEE 754
f6464-bit IEEE 754

Sized literals use a suffix: 42i32, 3.14f32, 255u8. All casts between sized numeric types are explicit (as). Array indices must be u64; indexing with an i64 requires an explicit as u64 cast.

Unsuffixed literals are polymorphic. When the expected type is known from context (annotation, function parameter, struct field, return type, or the other operand in arithmetic/comparison), an unsuffixed numeric literal adopts that type automatically. When no context is available, the literal defaults to i64 (integer) or f64 (float).

let a: i32 = 10; // 10 is i32
let b: u8 = 255; // 255 is u8
let c: f32 = 1.5; // 1.5 is f32

fun scale(x: f32, factor: f32) -> f32 { x * factor }
let r = scale(2.0, 3.0); // both literals are f32

let x: i32 = 10i32;
let y = x + 5; // 5 adopts i32 from x; y is i32

This also applies to mut reassignment — the right-hand side of m = expr adopts m's declared type:

let mut count: i32 = 0;
count = 99; // 99 is i32

Char

Availability: Since v0.8.0 (RFC-0007).

Char represents a single Unicode scalar value. Character literals use single quotes: 'a', '\n', '\u{1F600}'.

fun main() {
let c: Char = 'a';
let code: u32 = c.to_u32();
let back: Perhaps<Char> = Char::from_u32(code);
}

Char is not u32 and not a string — no implicit coercions exist. Use c.to_u32() to get the Unicode scalar value and Char::from_u32(n) (returns Perhaps<Char>) to construct from a code point.

Type Inference

Types are inferred using the Hindley-Milner algorithm with let-polymorphism. Annotations are optional for all bindings, including function parameters and return types. They may be written explicitly for documentation or to restrict a binding to a less general type.

Annotations are required only where there is no expression to infer from:

  • Struct and enum field types
  • Aspect method signatures
fun add_annotated(a: i64, b: i64) -> i64 { a + b }
fun add_inferred(a, b) { a + b }

fun main() -> i64 {
let x = 42; // inferred: i64
let name = "Vlad"; // inferred: String
let y: f64 = 3.14; // explicit annotation (optional here)
let total = add_annotated(x, 1) + add_inferred(2, 3);
if (name == "Vlad") { total + (y as i64) } else { 0 }
}

Tuples

Tuples are lightweight anonymous product types.

fun main() -> i64 {
let coord: (i64, i64) = (10, 20);
let triple: (String, i64, boolean) = ("yes", 42, true);
return coord.0 + triple.1;
}

Positional field access uses .0, .1, etc.:

fun main() -> i64 {
let coord: (i64, i64) = (10, 20);
let x = coord.0;
let y = coord.1;
return x + y;
}

() is the zero-element tuple (unit type).

Tuples can be destructured in match:

fun main() -> i64 {
let coord: (i64, i64) = (10, 0);
match coord {
(0, y) => y,
(x, 0) => x,
(x, y) => x + y,
}
}

Arrays

Array<T> is the built-in ordered sequence type. The shorthand T[] is preferred.

fun main() -> i64 {
let nums: i64[] = [1, 2, 3];
let names: Array<String> = ["alice", "bob"];
if (array_len(names) == 2) { nums[0] } else { 0 }
}

Index access uses [] with an i64 index. Out-of-bounds access causes a panic.

fun main() -> i64 {
let nums: i64[] = [1, 2, 3];
let first = nums[0];
return first;
}

Arrays are usable in for-in loops.

Fixed-size arrays

[T; N] is an array type whose length N is a non-negative integer literal known at compile time. [T; N] coerces to T[] (not the reverse). N must be a non-negative integer literal; variables are not permitted.

fun main() {
// Repeat construction: every element is the same value.
let zeros: [i64; 3] = [0; 3];

// Literal construction with an explicit sized type.
let ones: [i64; 3] = [1, 2, 3];

// Coerces to T[] when a T[] is expected.
fun first(xs: i64[]) -> i64 { xs[0] }
let v = first(ones); // [i64; 3] → i64[]
}

Indexing and for-in work identically to T[]. Array patterns match sized arrays:

fun sum(xs: [i64; 3]) -> i64 {
match xs {
[a, b, c] => a + b + c, // exact-count pattern on [T; 3]
}
}

Fixed-size array type [T; N]: since v0.8.0 (RFC-0053).

Pointers

Regular pointer types provide explicit aliasing for non-linear values.

fun main() -> i64 {
let mut value = 1;
let p: *i64 = &value;
let q: *mut i64 = &mut value;
*q = *p + 1;
return *q;
}

Metel has two regular pointer types:

  • *T — readable pointer to T
  • *mut T — readable and writable pointer to T

*mut T coerces to *T. The reverse coercion does not exist.

Regular pointers are first-class values, but they are distinct from the pointee type. There is no implicit dereference for ordinary reads or writes.

Regular pointers are only for non-linear aliasing. They cannot target linear values.

&mut accepts arbitrary addressable lvalue paths — struct fields, tuple elements, array elements, and chains thereof. Writes through the resulting *mut T propagate back to the original storage location:

struct Counter { value: i64 }

fun main() -> i64 {
let mut c = Counter { value: 0 };
let p: *mut i64 = &mut c.value;
*p = 42;
return c.value; // 42
}

&mut for lvalue paths: since v0.8.0 (RFC-0045).

List<T>

Availability: Since v0.8.0 (RFC-0054).

List<T> is the standard growable-sequence type. Use it when you need to append, pop, or otherwise mutate a sequence. Use T[] when the sequence is fixed after construction.

fun main() {
let mut xs: List<i64> = List::new();
xs.push(1);
xs.push(2);
xs.push(3);
println(xs.len().to_string()); // 3
let last = xs.pop(); // Perhaps::Some { value: 3 }
}

Construction:

FormDescription
List::new()Empty list
List::from(arr)Construct from a T[] — copies elements

Methods:

MethodSignatureDescription
push(&mut self, value: T)Append an element
pop(&mut self) -> Perhaps<T>Remove and return the last element, or None
len(&self) -> i64Number of elements
get(&self, index: i64) -> Perhaps<T>Bounds-checked access
as_slice(&self) -> T[]View as an immutable array (no copy)

List<T> does not implicitly coerce to T[]. Call .as_slice() to get a read-only view.

Type Ascription

Availability: Since v0.2.0.

The : operator asserts that an expression has a given type without performing any runtime conversion. It is a pure type-inference hint — no code is emitted at runtime.

Type ascription is mainly an ergonomics feature. Most code should type-check from its surrounding context alone; : is for the cases where spelling out the intended type inline is clearer than introducing a separate annotated binding.

fun main() -> i64 {
let xs = [] : i64[];
let x = 1 : i64;
if (array_len(xs) == 0) { x } else { 0 }
}

Ascription fails at compile time if the inferred type of the sub-expression cannot be unified with the ascribed type. For example, 1 : String is invalid. Use as to convert between types; use : only when the value already has the target type.

fun main() -> i64 {
let y = 1 : String;
return 0;
}

When ascription helps

Type inference uses surrounding expected types. That expected type can come from a let annotation, a function return type, a callee's parameter types, or the surrounding expression context.

Because of that, ambiguous literals like [] and None often type-check without explicit ascription when the context already determines their type:

fun zip_lengths(a: i64[], b: String[]) -> i64 {
return array_len(a) + array_len(b);
}

fun make_row(use_default: boolean, fallback: i64[]) -> i64[] {
return match use_default {
true => [],
false => fallback,
};
}

fun first_or_default(items: i64[], fallback: Perhaps<i64>) -> i64 {
return match fallback {
Perhaps::Some { value } => value,
None => if (array_len(items) > 0) { items[0] } else { 0 },
};
}

fun main() -> i64 {
let total = zip_lengths([], ["a", "b"]);
let row = make_row(true, [1, 2, 3]);
let first = first_or_default([1, 2, 3], None);
return total + array_len(row) + first;
}

Ascription is still useful when no surrounding context fixes the type:

fun main() -> i64 {
let arr = [] : i64[];
let value = None : Perhaps<i64>;
match value {
Perhaps::Some { value } => value + array_len(arr),
Perhaps::None => array_len(arr),
}
}

Without such context, ambiguous literals remain a type error. For example, let x = None; does not provide enough information to infer the element type.

fun main() -> i64 {
let x = None;
return 0;
}

Type Casting

The as operator casts between any two numeric primitive types. It desugars to a call to the From aspect and is infallible — the result is the target type directly.

fun main() {
let x: i32 = 1000i32;
let b: i8 = x as i8; // wraps: 1000 mod 256 → -24
let f: f32 = x as f32; // 1000.0f32
let u: u64 = x as u64; // 1000u64

let pi: f64 = 3.14;
let n: i32 = pi as i32; // truncates toward zero → 3
}

All pairwise casts among i8, i16, i32, i64, u8, u16, u32, u64, f32, f64 are supported. Narrowing integer casts wrap (two's-complement truncation). f64-to-integer casts truncate toward zero.

Because as desugars to From, user-defined types become castable by implementing From<SourceType> for the target type.

Generics

Availability: User-defined generic functions and types: since v0.3.0. Built-in generic types (Perhaps<T>, Result<T, E>, T[]): since v0.1.0.

Types and functions can be parameterized with <T> syntax.

struct Stack<T> {
items: T[],
}

fun first<T>(arr: T[]) -> Perhaps<T> {
if (array_len(arr) == 0) {
return None;
}
return Perhaps::Some { value: arr[0] };
}

fun main() -> i64 {
let stack = Stack { items: [1, 2, 3] };
match first(stack.items) {
Perhaps::Some { value } => value,
Perhaps::None => 0,
}
}

Never Type

! (Never) is the bottom type — the type of an expression that never produces a value because it diverges (runs forever, panics, or exits). A loop with no reachable break has type !:

fun main() -> i64 {
let result: i64 = loop { break 42; };
return result;
}

! is not a type users write in practice; it appears as an inferred type when the typechecker determines a branch or expression cannot return. It is the type of return, panic!, and loop { } with no reachable break.

Perhaps<T>

Perhaps<T> is the built-in optional type. There is no null — all absence is expressed via Perhaps<T>.

The type of None is Perhaps<T> for some T that must be determinable from context. If no context constrains T — for example, a bare let x = None with no annotation and no subsequent use that pins the element type — the program is a type error. An explicit annotation is required in that case:

fun main() -> i64 {
let x: Perhaps<i64> = None;
match x {
Perhaps::Some { value } => value,
Perhaps::None => 0,
}
}
fun main() -> i64 {
let result: Perhaps<i64> = None;
let value: Perhaps<i64> = 42;
match value {
Perhaps::Some { value } => value,
Perhaps::None => match result {
Perhaps::Some { value } => value,
Perhaps::None => 0,
},
}
}

Use match to unwrap safely:

struct User {
id: i64,
}

fun find_user(id: i64) -> Perhaps<User> {
if (id == 1) {
return Perhaps::Some { value: User { id: 1 } };
}
return None;
}

fun main() -> i64 {
match find_user(1) {
Perhaps::Some { value } => value.id,
Perhaps::None => 0,
}
}

.yolo() unwraps, panicking if the value is None:

struct User {
id: i64,
}

fun find_user(id: i64) -> Perhaps<User> {
if (id == 1) {
return Perhaps::Some { value: User { id: 1 } };
}
return None;
}

fun main() -> i64 {
let user = find_user(1).yolo();
return user.id;
}

Result<T, E>

Result<T, E> represents the outcome of a fallible operation:

fun divide(a: f64, b: f64) -> Result<f64, String> {
if (b == 0.0) {
return Result::Err { error: "division by zero" };
}
return Result::Ok { value: a / b };
}

fun main() -> i64 {
match divide(8.0, 2.0) {
Result::Ok { value } => value as i64,
Result::Err { error } => 0,
}
}

Use match to handle both cases, or ? to propagate errors.

.yolo() also works on Result<T, E>, panicking on Err.