Types
Grayscale is statically typed. Every variable and expression has a type known at check time.
Primitive Types
Section titled “Primitive Types”Integer Types
Section titled “Integer Types”Grayscale’s integer types are named by signedness and width:
| Type | Width | Range |
|---|---|---|
i8 |
8-bit | -128 to 127 |
i16 |
16-bit | -32,768 to 32,767 |
i32 |
32-bit | -2^31 to 2^31-1 |
i64 |
64-bit | -2^63 to 2^63-1 |
u8 |
8-bit | 0 to 255 |
u16 |
16-bit | 0 to 65,535 |
u32 |
32-bit | 0 to 2^32-1 |
u64 |
64-bit | 0 to 2^64-1 |
An integer literal with no other type to take is an i64. Arithmetic is overflow-checked: overflow or underflow produces a runtime panic rather than silent wrapping.
do main() {mut small i64 = 42mut large i64 = 9223372036854775807 // max i64mut count u64 = 18446744073709551615 // max u64mut flags u8 = 0xFF}A literal outside the declared type’s range, or a negative literal assigned to an unsigned type, is a check-time error. Byte data is u8: a [u8] stores one byte per element, and the stdlib’s byte-oriented functions (io.read_bytes, encoding, binary, uuid.to_bytes) take and return [u8].
Use cast to convert between integer types: cast(value, i32), cast(value, u16). A narrowing cast is range-checked at runtime.
The names int, uint, float, and byte are not built in. Code that prefers them declares them as aliases:
alias int = i64alias uint = u64alias float = f64alias byte = u8
do main() {}Floating-Point Types
Section titled “Floating-Point Types”| Type | Width | C Type | Precision |
|---|---|---|---|
f32 |
32-bit | float |
~7 decimal digits (IEEE 754 single-precision) |
f64 |
64-bit | double |
~15 decimal digits (IEEE 754 double-precision) |
A float literal with no other type to take is an f64. Use f32 when interfacing with C APIs that expect single-precision or when memory is constrained.
Division by zero with floating-point operands produces a runtime panic. However, special IEEE 754 values (NaN, Infinity, -Infinity) can appear through stdlib math functions (e.g., edge cases in trigonometric or logarithmic functions). Use math.is_nan(), math.is_infinite(), and math.is_finite() to check for these values.
do main() {mut pi f64 = 3.14159mut ratio f32 = 0.5}Integer values are implicitly promoted to a floating-point type when the target type is f32 or f64. This applies to variable declarations, assignments, function arguments, map literal values, and return statements:
do main() {mut x f64 = 5 // 5.0mut y f32 = 1 // 1.0x = 42 // 42.0}No explicit cast is needed. The promotion is lossless for values within the floating-point range.
String Type (string)
Section titled “String Type (string)”The string type represents a UTF-8 encoded byte sequence. String indexing (str[i]) returns the byte at byte position i, not a Unicode codepoint. len() returns the byte length, not the character count.
For ASCII strings, one byte equals one character, so indexing works as expected:
do main() {mut greeting string = "Hello, World!"mut first_char char = greeting[0] // 'H'}For multi-byte UTF-8 strings, individual bytes may not form complete characters:
do main() {mut s string = "日本語"println(len(s)) // 9 (byte length, not 3 characters)println(char_count(s)) // 3 (Unicode character count)println(to_char(s, 0)) // 日 (the character at codepoint index 0)println(cast(to_char(s, 0), i64)) // 26085 (its Unicode codepoint value)}Use to_char() to access characters by codepoint index and char_count() to get the true character count. to_char() returns a char; apply cast(c, i64) to it for the numeric codepoint.
Boolean Type (bool)
Section titled “Boolean Type (bool)”The bool type has exactly two values: true and false.
do main() {mut flag bool = truemut result bool = 10 > 5 // true}Character Type (char)
Section titled “Character Type (char)”The char type is a 32-bit integer holding a single Unicode codepoint in the range U+0000–U+10FFFF. It is distinct from u8 (8-bit, 0–255) and from the other integer types. At the C boundary a char is int32_t.
do main() {mut letter char = 'A' // U+0041mut newline char = '\n' // U+000Amut eacute char = '\u{E9}' // U+00E9 émut cjk char = char(26085) // U+65E5 日}cast(c, i64)yields the numeric codepoint;char(n)converts an integer codepoint to achar. A value ofnoutside U+0000–U+10FFFF is a compile-time error for a constant argument and a runtime panic otherwise.- Byte-indexing a string (
s[i]) yields the raw byte as acharin 0–255. Useto_char(s, i)for the codepoint at codepoint indexi. charvalues compare and order by codepoint. Arithmetic oncharis evaluated asi64.charis a primitive, hashable type: valid as a map key, inwhen, and formutarray/map literal inference.
Wide Integer Types (i128, u128, i256, u256)
Section titled “Wide Integer Types (i128, u128, i256, u256)”Grayscale provides portable wide integer types backed by struct-based arithmetic (no compiler extensions required):
| Type | Width | size_of |
|---|---|---|
i128 |
128-bit | 16 |
u128 |
128-bit | 16 |
i256 |
256-bit | 32 |
u256 |
256-bit | 32 |
Wide integers support all standard arithmetic (+, -, *, /, %) and comparison operators (==, !=, <, >, <=, >=). Values are constructed using the type name as a function:
do main() {mut a i128 = i128(42)mut b i128 = i128(100)mut c i128 = a + b // i128 additionprintln(c) // prints "142"println(type_of(c)) // "i128"println(size_of(i128)) // 16
mut x i64 = cast(c, i64) // cast back to i64mut s string = string(c) // convert to string}Wide integers use the same overflow-checked arithmetic as i64 and u64; overflow produces a runtime panic.
A negative literal assigned to u128 or u256 is rejected with E3036, the same as for u64.
Pointer Type (^Type)
Section titled “Pointer Type (^Type)”The pointer type ^Type represents a memory address pointing to a value of Type.
| Syntax | Meaning |
|---|---|
^i64 |
Pointer to an i64 |
^MyStruct |
Pointer to a MyStruct |
addr(x) |
Get the address of x |
p^ |
Dereference pointer p |
do main() {mut x i64 = 42mut p ^i64 = addr(x)println(p) // 0x16d1ab9f8, prints the address as hexprintln(p^) // 42, explicit dereference reads the pointeep^ = 100println(x) // 100}Printing a pointer value (println(p), print(p), etc.) outputs the address in hex (0x...) when non-nil and nil when null. Use p^ to access the pointee.
Dereferencing a nil pointer causes a runtime panic.
nil is assignable anywhere a pointer type is expected — variable initialization, assignment, struct fields, and function call arguments (including struct function calls and default parameter values):
const Node struct { val i64 next ^Node}
do make_node(parent ^Node) -> ^Node { n = new(Node) return n}
do main() { root = make_node(nil) // OK: nil satisfies the ^Node parameter println(root)}Const-sourced pointers: addr() can be called on a const-declared variable. The resulting pointer allows reading the value, but the compiler rejects any attempt to write through it (p^ = ..., p^.field = ..., p.field = ..., p^ += ...). This protection follows the pointer wherever it goes — if q = p and p points to a const-declared variable, q inherits the restriction, and so does a pointer read back out of a function’s return value, a struct field, an array element, or a map value. Passing such a pointer to a function that writes through that parameter is rejected at the call. This matches the behavior of ref() on const sources — the address is safe to take, the mutation is not.
do main() { const x i64 = 42 mut p = addr(x) println(p^) // 42 — reading is allowed // p^ = 99 // ERROR — writing through a const-sourced pointer mut q = p // q^ = 99 // ERROR — const origin propagates through assignment println(q^)}Pointer aliasing: Calling addr() more than once on the same variable produces pointers that all refer to the same memory. Changing the value through one pointer changes it for all of them. In multithreaded code, protect shared variables with sync.lock() to avoid data races.
do main() {mut x i64 = 10mut p1 = addr(x)mut p2 = addr(x)p1^ = 99println(p2^) // 99 — p1 and p2 point to the same variable}Raw pointers with raw(): raw() takes the address of a variable just like addr(), but returns a raw pointer — an unsafe pointer with no safety guards. Dereferences skip the nil-check panic, and the compiler does not enforce const-source write protection. The same argument rules apply — raw() requires a variable, field, or index expression (not a literal or call result), and cannot take the address of a map index or a dynamic [T] array element.
// q^ dereference has no nil-check — if q were nil, behavior is undefined
do main() {const x i64 = 42mut p = raw(x)p^ = 99 // allowed — raw() bypasses const-source protectionprintln(p^) // 99
mut q ^i64 = raw(x)}raw() is considered unsafe and is intended for performance-critical code where nil checks are a measurable overhead and the programmer guarantees pointer validity. Prefer addr() in all other cases.
A call that returns a pointer can be dereferenced directly, without storing the pointer first. new(Foo)^ allocates a Foo and immediately gives you the value, and the same applies to any function that returns ^Type: return new(Foo)^ or mut val = make_thing()^.
The dot operator (.) automatically dereferences pointers to structs. If p is a ^MyStruct, writing p.field is equivalent to p^.field. This auto-dereference applies to field access and struct function calls but does not apply in other contexts: println(p) prints the address, and return p returns the pointer itself. Use explicit p^ when you need the pointee value rather than field access.
Sized Types
Section titled “Sized Types”These rules decide the type of every number value and where it may go. They are the same in every position a value can be written.
Literals take their type from context. A number literal — or an expression
built only from number literals, such as 200 + 100 - 50 or
1 bit_shift_left 9 — has no type of its own until something gives it one:
- the slot it is stored into (see below):
mut b u8 = 200makes200au8; - the other operand of a binary operator: in
x == 0.1withx f32,0.1is anf32; - the target of a
castit is the operand of: incast(1.0e300, f32), the literal is anf32.
Otherwise it becomes an i64, or an f64 if it contains a decimal literal.
An expression made only of literals is computed exactly (up to 256 bits)
before it takes its type, so mut a u8 = 200 + 100 - 50 stores 250. A literal
whose value does not fit the type it takes is E3036: mut b u8 = 300,
mut n u8 = -1, mut f f32 = 1.0e300, mut a u8 = 1 bit_shift_left 9. An
integer literal no integer type can hold (at or above 2^256, or below -2^255)
is E3046. An array, map or struct literal passes its element, key, value and
field types down to each entry, so mut xs [i8] = {a, 7} makes 7 an i8.
Every slot converts a value the same way. A slot is any place a value is
stored: a variable declaration, a reassignment, a compound assignment x op= v
(v is checked as x’s type), an element, map key or map value, a pointer’s
target, a field, a struct literal field, a field or parameter default, an enum
payload, a when arm (checked as the subject’s type), each position of a
multi-value declaration, an array or map literal entry, a call argument (user,
struct and func-typed functions, builtins and stdlib functions), a return
value, an array or string index (an i64), a map index (the key type), and a
range() bound or step (an i64, or the widest wide integer type among its
bounds). A value of type S stored into a slot of type T:
S to T |
Example | Result |
|---|---|---|
| the same type | i32 to i32 |
allowed |
| wider, same signedness | i32 to i64, u8 to u16, f32 to f64 |
allowed |
| unsigned to strictly wider signed | u32 to i64 |
allowed |
| integer to float | i64 to f64 |
allowed |
| narrower | i64 to i32, u64 to u8, i64 to u8, f64 to f32, i128 to i64 |
E3155 |
| other signedness, same or greater width | i64 to u64, u8 to i8, i8 to u64 |
E3019 |
| float to integer | f64 to i64 |
the position’s type mismatch error |
An array or map never converts its elements: a [i64] value is not a [u8].
A narrowing or signedness-crossing conversion is written with cast.
Binary operators keep their operands’ type. For + - * / %, the bitwise
operators and the comparisons:
- two operands of the same type compute (or compare) at that type:
f32 + f32isf32; - operands of one signedness and different widths compute at the wider type:
i8 + i32isi32,i128 + i256isi256; - a literal operand takes the other operand’s type:
x == 0.1withx f32compares asf32; - two literals give a literal (computed exactly, as above);
bit_shift_leftandbit_shift_righttake a count of any integer type, and the result has the left operand’s type; a count outside[0, width - 1]of that type panics (P0092);- any other pair of typed operands —
i64 + u8,i64 + f64,u64 == i64— is an error (E3002, orE3156for a comparison); convert one side withcast.
Arithmetic is overflow-checked at the result type, whatever expression or
compound assignment it appears in: xs[0] += 10 on a [u8] holding 250 panics,
whether xs is a variable, a field, a map value, or reached through a pointer.
Composite Types
Section titled “Composite Types”Arrays
Section titled “Arrays”Arrays are ordered collections of elements of the same type.
Dynamic arrays have variable length:
do main() {mut numbers [i64] = {1, 2, 3, 4, 5}mut empty [string] = {}}Fixed-size arrays have a length specified at declaration:
do main() {const fixed [i64, 3] = {10, 20, 30}}Fixed-size arrays must be declared with const. Providing fewer values than the declared size is permitted; providing more values than the declared size is an error.
do main() { const a [i64, 5] = {1, 2, 3} // OK (3 of 5 slots used, remaining zero-initialized) // const b [i64, 5] = {1, 2, 3, 4, 5, 6} // Error: 6 values exceeds size of 5 println(a)}The size specifier N may also be a compile-time integer constant of any integer type (i8–i64, u8–u64). The constant must be declared before the array and must resolve to a value greater than zero.
do main() {const SIZE i64 = 4const buf [u8, SIZE] = {0x01, 0x02, 0x03, 0x04}}Multi-dimensional arrays:
do main() {mut matrix [[i64]] = {{1, 2}, {3, 4}}mut cube [[[i64]]] = {{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}}}Array indexing is zero-based. Accessing an index outside the valid range produces a runtime error.
Maps are unordered collections of key-value pairs. The map keyword is optional — [K:V] and map[K:V] are identical. An empty map is written {:}; a bare {} is an empty array:
// Long form is also valid:
do main() {mut ages [string:i64] = { "alice": 30, "bob": 25}mut empty [string:i64] = {:} // Empty mapmut scores map[string:i64] = {"math": 95}}do main() {mut arr [i64] = {} // Empty arraymut m [string:i64] = {:} // Empty map}Bracket disambiguation:
| Syntax | Meaning | Example |
|---|---|---|
[T] |
Dynamic array of T |
[i64] |
[T,N] |
Fixed-size array of T |
[i64,3] |
[K:V] |
Map from K to V |
[string:i64] |
Maps must be declared with mut. Declaring a map with const is a compile-time error. If the keys are known at compile time, use a struct instead.
Keys must be of a hashable type: an integer or float type, string, bool, or char.
Accessing a key that does not exist produces a runtime error.
Structs
Section titled “Structs”Structs are user-defined composite types with named fields.
const Point struct { x i64 y i64}
const Person struct { name string age i64 active bool}
do main() {
}Struct and enum declarations must be at the top level of a file, never inside a function or block. Fields go on separate lines or, on one line, are separated by ;. Unlike functions and control flow, structs and enums define types, not logic, and types belong where they are visible, nameable, and reusable. Burying a type inside a function makes it invisible to the rest of your program and harder to find when reading code.
A field may be a fixed-size array ([T,N]), the same spelling used for a local const f [T,N]. Its length never changes: arrays.append, prepend, insert_at, remove, remove_at, remove_first, remove_last, clear, and deduplicate are all rejected on it, whether called directly or through a member-expression chain like o.inner.items. Reading and writing individual elements works as long as the containing instance is mut. A struct literal that under-initializes the field zero-fills the rest (W3003); over-initializing it is an error (E3052) — the same rules as a local fixed-size array.
const Buffer struct { data [u8, 256]}
do main() {
}Recursive Structs
Section titled “Recursive Structs”A struct may reference itself through a pointer field. Value-type self-reference is rejected at compile time.
const Node struct { val i64 next ^Node // OK: pointer field}
// Value-type self-reference is an error:// const Bad struct {// val i64// next Bad // error: struct 'Bad' cannot contain itself by value; use a pointer field '^Bad'// }
do main() {}To traverse a recursive struct, dot notation automatically dereferences pointer fields with no explicit ^ required. The ^ suffix is also accepted if preferred:
const Node struct { val i64 next ^Node}
do main() { mut a = new(Node) mut b = new(Node) a.val = 1 b.val = 2 a.next = b
println(a.val) // 1 println(a.next.val) // 2, implicit dereference println(a.next^.val) // 2, explicit dereference (also valid)}Mutual recursion through pointer fields is supported. Both structs must use pointer fields (^Type) to reference each other; value-type mutual reference is rejected at compile time.
Struct instantiation uses named field syntax:
const Point struct { x i64 y i64}
do main() { mut origin Point = Point{x: 0, y: 0} mut p Point = Point{} // Zero-initialized: x=0, y=0 println(origin) println(p)}Default Field Values
Section titled “Default Field Values”Struct fields may specify a default value using = expr after the type. When a struct is created with new() or a struct literal that omits a field, the default value is used instead of zero-initialization.
const Config struct { host string = "localhost" port i64 = 8080 verbose bool = false}
do main() { mut c = new(Config) // c.host = "localhost", c.port = 8080, c.verbose = false mut c2 = Config{port: 3000} // c2.host = "localhost", c2.port = 3000, c2.verbose = false}Grouped fields share the same default:
const Point struct { x, y i64 = 0 z i64 = 1}
do main() {
}Fields without a default value remain zero-initialized when omitted.
#json structs cannot have default field values. They are data-only and always zero-initialized from JSON input.
Fields are accessed using dot notation:
const Point struct { x i64 y i64}
do main() { mut origin Point = Point{x: 0, y: 0} mut x_value i64 = origin.x origin.x = 10 // Modification (if variable is mut) println(x_value) println(origin)}Enums define a type with a fixed set of named values.
Integer enums (default, auto-incrementing from 0):
const Direction enum { NORTH // 0 EAST // 1 SOUTH // 2 WEST // 3}
do main() {
}Explicit values can be assigned to enum variants. Subsequent variants without an explicit value auto-increment from the last:
const Foobar enum { BAZ = 10 // 10 QUX // 11 QUUX = 50 // 50 CORGE // 51}
do main() {
}Enum variants go on separate lines or, on one line, are separated by ;, as in const Color enum { RED; GREEN; BLUE }.
Enums are not integers. Even though integer enums are backed by numeric values under the hood, you cannot compare an enum variable with an integer (d == 0), assign an integer to an enum variable (d = 2), or perform arithmetic on enum values. Enums can only be compared with values of the same enum type using == and !=: use Direction.NORTH, .NORTH, or another Direction variable, never a raw number. Assigning an enum value to an i64 variable is allowed, and the enum is implicitly widened to its underlying integer value: mut status i64 = Direction.NORTH assigns 0.
To compare an enum value against an integer, use cast() to bridge the gap: if cast(Direction.NORTH, i64) == 0 { ... }. You can also cast the other way: cast(0, Direction).
Printing enum values depends on the enum’s backing:
| Enum kind | println(value) prints |
|---|---|
| Plain integer-backed (default) | The underlying integer (e.g. 0 for the first variant) |
| String-backed | The variant’s string value (e.g. "todo") |
#error_code-tagged (and ErrorCode itself) |
The variant’s name (e.g. "PAYMENT_DECLINED") — see ErrorCode |
const Color enum { RED GREEN BLUE}
const Status enum { TODO = "todo" DONE = "done"}
#error_codeconst PaymentErrors enum { PAYMENT_DECLINED PAYMENT_CANCELED}
do main() { println(Color.RED) // "0" — plain enums print their integer println(Status.TODO) // "todo" — string enums print their string println(PaymentErrors.PAYMENT_DECLINED) // "PAYMENT_DECLINED" — error-code enums print their name}A plain enum has no name table generated for it, so printing one falls back to its widened integer value — consistent with a plain enum’s underlying value being a real integer under the hood. String-backed and #error_code-tagged enums each carry an obvious human-readable form already (the string literal, or the compiler-owned error-code name table), so those print that instead.
Flags enums (powers of 2, annotated with #flags):
#flagsconst Permissions enum { READ // 1 WRITE // 2 EXECUTE // 4 DELETE // 8}
do main() {
}A #flags enum may have at most 63 variants — one per usable bit of int64 (bit 63 is the sign bit). More is rejected (E3143).
Enum values are accessed using dot notation:
const Direction enum { NORTH SOUTH}
const Status enum { TODO = "todo" DONE = "done"}
do main() { mut dir = Direction.NORTH mut status = Status.TODO println(dir) println(status)}Implicit enum selector (.VARIANT):
When the expected enum type is known from context, you can use the shorthand .VARIANT instead of the full EnumName.VARIANT. The compiler resolves the enum type automatically.
const Direction enum { NORTH EAST SOUTH WEST}
do move(d Direction) -> i64 { return 0 }do get_dir() -> Direction { return .NORTH }
// Struct literal fieldsconst Config struct { dir Direction}
do main() { // Variable declaration with type annotation mut dir Direction = .NORTH
// Assignment dir = .SOUTH
// Function arguments mut moved i64 = move(.EAST)
// When/is branches when dir { is .NORTH { println("north") } is .SOUTH { println("south") } default { println("other") } }
// Comparisons if dir == .WEST { println("west") }
// Return statements mut d Direction = get_dir()
mut c = Config{ dir: .EAST }
// Array literals mut dirs [Direction] = {.NORTH, .SOUTH} println(c) println(dirs) println(d)}The full EnumName.VARIANT form is always valid and is required when no type context is available.
Tagged Enums (Variants with Data):
Enum variants can carry associated data (payloads), making the enum a tagged union. A variant’s payload is declared with positional types in parentheses:
const Shape enum { Circle(f64) Rect(f64, f64) Point}
do main() {
}An enum becomes a tagged union if ANY variant has a payload. Variants without payloads (like Point above) are plain tag-only variants. Payloads and explicit values (= 5) are mutually exclusive per variant. String enums and #flags enums cannot have payloads.
Construction:
Tagged enum values are constructed by calling the variant with arguments:
const Shape enum { Circle(f64) Rect(f64, f64) Point}
do main() { mut s Shape = Shape.Circle(3.14) mut r Shape = Shape.Rect(10.0, 20.0) mut p Shape = Shape.Point}The implicit selector syntax also works with tagged constructors:
const Shape enum { Circle(f64) Rect(f64, f64) Point}
do main() { mut s Shape = .Circle(3.14)}Destructuring with when/is:
Use pattern destructuring in when/is to extract payload values. Use the fully-qualified form:
const Shape enum { Circle(f64) Rect(f64, f64) Point}
do main() { mut shape Shape = Shape.Circle(3.14) when shape { is Shape.Circle(radius) { println("Circle with radius ${radius}") } is Shape.Rect(w, h) { println("Rectangle ${w} x ${h}") } is Shape.Point { println("Just a point") } }}The implicit selector form (dot-prefix) also works:
const Shape enum { Circle(f64) Rect(f64, f64) Point}
do main() { mut shape Shape = Shape.Rect(1.0, 2.0) when shape { is .Circle(r) { println("radius: ${r}") } is .Rect(w, h) { println("${w}x${h}") } is .Point { println("point") } }}The number of bindings in a pattern must match the variant’s payload count. #strict exhaustiveness checking works with tagged enums.
Function References
Section titled “Function References”func is a type keyword that represents a reference to a named function. Function references are created with ()name or ref(name) and are always const. The func type is used in parameter declarations, struct fields, arrays, and maps to accept or store callable references.
do double(n i64) -> i64 { return n * 2 }
do main() { const f = ()double println(f(5)) // 10}A typed func signature specifies parameter and return types:
do apply(x i64, f func(i64) -> i64) -> i64 { return f(x)}
do main() {
}See Function References for full documentation including calling conventions, parameter usage, and struct field storage.
Type Inference
Section titled “Type Inference”Grayscale is a statically-typed language with type inference. The type of every variable is known at compile time, and explicit type annotations are optional in most contexts when the compiler can determine the type from the initializer.
Type inference works with:
- Standalone literals - The type is inferred from the literal value
- Function return values - The variable’s type is inferred from the function’s return type
- Struct literals - The type is known from the struct name
- Built-in constructors -
new(Type)(returns^Type) andcopy(value) - Multiple return values - Each variable’s type is inferred from the corresponding return type
mutarray and map literals of primitives -[T]is inferred from the element type,map[K:V]from the first pair
Array and map literal inference applies only to mut declarations whose elements are all primitives (integer and float types, string, bool, char) — for maps, both keys and values must be primitive. Empty literals ({}, {:}), const declarations, and literals containing structs, enums, pointers, or nested containers still require an explicit annotation (e.g. mut arr [Point] = {Point{x: 1, y: 2}}).
File-scope const declarations of primitive types and arrays require explicit type annotations. Type inference for const is only supported inside function bodies. For example, const MAX_SIZE i64 = 100 is required at file scope, while const x = 42 is valid inside a function.
const Point struct { x i64 y i64}
const Person struct { name string}
do sum(a i64, b i64) -> i64 { return a + b}
do divide(a, b i64) -> (i64, i64) { return a / b, a % b}
do main() { // Inferred from literals mut x = 42 // Inferred: i64 mut name = "Alice" // Inferred: string mut pi = 3.14 // Inferred: f64 mut flag = true // Inferred: bool
// Explicit annotations are always accepted mut y i64 = 42 // Explicit: i64
// Inferred from function return type mut result = sum(1, 2) // Inferred: i64 println(type_of(result)) // Output: i64
// Inferred from struct literal const p = Point{x: 1, y: 2} // Inferred: Point
// Inferred from built-in constructors mut val = new(Person) // Inferred: ^Person (pointer) mut dup = copy(val^) // Inferred: Person (copy() needs a value, not a pointer)
// mut array/map literals of primitives are inferred mut arr = {1, 2, 3} // Inferred: [i64] mut letters = {'a', 'b', 'c'} // Inferred: [char] mut scores = {"alice": 10, "bob": 7} // Inferred: map[string:i64] mut fixed [Point] = {Point{x: 1, y: 2}} // Explicit: non-primitive elements
// Multiple return values mut quotient, remainder = divide(10, 3) // Both inferred: i64}Explicit type annotations are generally optional but can be used for clarity or documentation. The exceptions are file-scope const declarations of primitive types and arrays, and any const or non-primitive array/map literal, which always require explicit type annotations.
Type Conversions
Section titled “Type Conversions”Explicit type conversions are performed with cast, string(), and char():
do main() {mut i i64 = cast('A', i64) // 65 - char to i64 (code point)mut f f64 = cast(42, f64) // 42.0 - i64 to f64mut s string = string(123) // "123" - i64 to stringmut c char = char(65) // 'A' - i64 to char}Conversions that would lose information or are invalid produce check-time or runtime errors.
The cast Keyword
Section titled “The cast Keyword”The cast keyword provides explicit type conversion for values and arrays:
do main() {mut small u8 = cast(42, u8)mut truncated i64 = cast(3.7, i64) // 3mut text string = cast(123, string) // "123"mut parsed i64 = cast("42", i64) // 42}A string cast to an integer or float type is parsed at runtime; a string that is not a number panics (P0084 for an integer target, P0085 for a float target).
For array conversions, cast converts each element to the target element type:
do main() {mut ints [i64] = {1, 2, 3}mut bytes [u8] = cast(ints, [u8])}Range constraints are enforced at runtime (e.g., u8 values must be 0-255).
cast truncates floats to integers; it does not round. cast(3.9, i64) gives 3, not 4.
Type Aliases
Section titled “Type Aliases”The alias keyword creates an interchangeable name for an existing type:
const Point struct { x i64 y i64}
alias Meters = f64alias Vec2 = Pointalias Names = [string]alias Lookup = map[string:i64]
do main() {}Rules:
- File-scope only — aliases cannot be declared inside functions.
- Public by default — prefix with
privateto restrict to the declaring file. - Erased at compile time — aliases produce no runtime overhead.
type_of()returns the underlying type name. - Transitive — aliases can chain:
alias A = i64thenalias B = AresolvesBtoi64. - Can alias: primitives, structs, enums, arrays (
[T]), maps (map[K:V]), and pointers (^T). - Cannot alias: module-qualified types (
mod.Type) or the wildcard type (?). - The alias name may not be a reserved type name or a builtin function name —
alias i64 = f64andalias println = i64are both rejected, the same way a struct or enum by those names is.
Aliases are fully interchangeable with the underlying type:
alias Meters = f64
do main() { mut d Meters = 10.5 println(type_of(d)) // "f64" println(d + 1.0) // 11.5}Struct and enum aliases work with constructors and member access:
const Point struct { x i64 y i64}alias Vec2 = Point
const Color enum { RED GREEN BLUE}alias Hue = Color
do main() { mut p Vec2 = Point{x: 1, y: 2} mut c Hue = Hue.RED}Private aliases restrict access to the declaring file:
private alias InternalID = i64
do main() {}