Type system
Dusk is statically typed. Every type is known at compile time, and inference is a compile time operation with no runtime type resolution. This page walks through the primitive types, the literal and width rules, strings, arrays and slices, immutability, the two pointer layers, and the foreign boundary. Sum types have their own page at enums, and allocation itself is covered under memory management. The operators that run on these types, the full set and its precedence ladder, live on the operators page.
Primitive types
Section titled “Primitive types”| Type | Size | Description |
|---|---|---|
| int8 | 1 byte | signed 8 bit integer |
| int16 | 2 bytes | signed 16 bit integer |
| int32 | 4 bytes | signed 32 bit integer |
| int64 | 8 bytes | signed 64 bit integer |
| float32 | 4 bytes | 32 bit floating point |
| float64 | 8 bytes | 64 bit floating point |
| bool | 1 byte | true or false |
| char | 1 byte | single ASCII character |
| rune | 4 bytes | one Unicode scalar value (see below) |
| string | fat ptr | built in string type (see below) |
| error | builtin | built in error type (see error handling) |
The unsigned widths uint8 through uint64, and the matching u literal suffixes, are reserved rather than usable. Naming one is a compile error, unsigned integers are reserved; use the signed widths. The signed widths cover the surface, and unsigned support remains reserved for later work.
A rune is a 4 byte primitive holding one Unicode scalar value, wide enough for any character in Unicode where a char is one ASCII byte. You write one with r'...', as in r'a', r'中', or r'\u{1F600}'. A rune and an integer convert both ways under the same rule char follows, a rune widening to any integer width and a wide integer truncating back, and since 1.5.0 rune(v) converts to one explicitly (see numeric casts below). That cast is unchecked: it takes any 32 bit value, a negative one or one above U+10FFFF that no Unicode scalar carries among them, and nothing validates the result, so a rune that arrived through a cast holds a real scalar only if you checked it yourself. A rune literal is the direction that is checked, since \u{...} rejects a surrogate and a value above the Unicode maximum at compile time. A rune and a char do not mix in either direction without a cast, since a byte and a scalar are different things. println on a rune prints the codepoint number rather than a glyph, so println(r'中') prints 20013. This is the opposite of how a char, a char[N], and a char[] print, which write their bytes as text (see strings below): a char is one byte of a string’s text while a rune is a 4 byte scalar with no text form of its own, so only std.unicode’s encode_rune turns a scalar into displayed text. No user defined type may be named rune; the name is reserved. The Unicode and runes guide covers the \u{...} escape, decoding, and encoding, and std.unicode lists the library.
Type inference
Section titled “Type inference”Compile time inference uses the := operator. The compiler infers the type from the right hand side.
x := 5 // inferred as int64 (default integer type)y := 3.14 // inferred as float64 (default float type)z := true // inferred as boolYou can always annotate a type explicitly.
x: int32 = 5Inference uses these defaults:
- Integer literals become
int64. - Float literals become
float64. - For other types such as
int8orfloat32, use a literal suffix or an annotation.
Literal suffixes
Section titled “Literal suffixes”A suffix selects a non default type without an annotation.
a := 5i8 // int8b := 3.14f32 // float32c := 200i16 // int16No silent width mixing
Section titled “No silent width mixing”Numeric widths never mix silently. Arithmetic, comparison, assignment, and argument passing take operands of one width, so an int32 next to an int64 is a compile error rather than a truncation. A bare literal adapts to the width beside it, and a literal that cannot fit its annotated or suffixed width is rejected.
func main() -> int32 { x := 5 // int64, the default integer type y := 3.14 // float64, the default float type a := 5i8 // int8, selected by suffix b := 3.14f32 // float32, selected by suffix n: int32 = 200 // explicit annotation, the literal adapts println(x) println(y) println(a) println(b) println(n) return 0}Numeric casts
Section titled “Numeric casts”Each of the eight scalar type names doubles as a call form that converts a value to that type explicitly: int8(v), int16(v), int32(v), int64(v), char(v), rune(v), float32(v), and float64(v). The first five arrived in 1.2.0 as integer width casts and stopped at the integer family. 1.5.0 widened the set to the whole scalar side of the table above, so the source may be an integer of any width, a char, a rune, a bool, a float32, or a float64, and a cast crosses between the integer and float families in both directions. A pointer, a string, a struct, or any other non scalar rejects by name, a numeric cast takes an integer, char, rune, or float value; string does not cast, and each cast takes exactly one argument.
What a given conversion does depends on the operand and the target, and the operators page carries that rule per kind: the truncation and extension between integer widths, the truncation toward zero and the saturation coming down from a float, and the unchecked rune.
All eight names are reserved as builtin call forms, so a function cannot be declared with one of them, 'int32' is a primitive type name; a function cannot take it, since a call to the name would otherwise be ambiguous between the cast and the function. A variable or a struct field may still carry the name; only a function declaration collides.
func main() -> int32 { n: int32 = int32(300) // 300, fits small: int8 = int8(300) // 44, truncated c: char = char(101) // 'e' f: float64 = float64(65) // an integer read as a float back: int64 = int64(3.9) // 3, truncated toward zero r: rune = rune(66) // an integer to a codepoint, unchecked println(n) println(small) println(c) // e, a char prints as text println(f) // 65 println(back) // 3 println(r) // 66, a rune prints its codepoint number return 0}Strings
Section titled “Strings”A string is a read only view of a NUL terminated buffer of char. String literals do not heap allocate, since the literal bytes live in static storage.
s: string = "hello" // a view of the NUL terminated bytes- A string value is immutable. Reading a byte with
s[i]is fine, but an index assignments[i] = cis rejected at check,a string is immutable; build a new one with a StringBuilder, since the bytes live in read only storage. The growableStringBuilderinstd.string, added in 0.2.0, builds and concatenates strings on the heap. - A string’s length is found by scanning to the NUL, which
std.string’sstr_lendoes. The NUL keeps a string view compatible with C and the foreign interface. - The
cstrbuiltin reinterprets a NUL terminatedcharbuffer as astringat no runtime cost.std.stringuses it to hand aStringBuilder’s*raw charbuffer back as a string view.
Comparison and concatenation
Section titled “Comparison and concatenation”Since 1.2.0 two strings compare by content and join with +. == and != do a byte for byte comparison up to each string’s NUL, not a comparison of the pointers, so a string built at runtime compares equal to an identical literal, and a null operand, an error’s empty message among them, reads as the empty string rather than crashing. Strings carry no ordering: <, <=, >, and >= between two strings reject at check, strings compare with == and !=; they have no ordering. + and += concatenate: a + b mints a fresh heap string holding both operands’ bytes back to back, freed with an ordinary free, and += rebinds a mut string. + is the only arithmetic operator a string accepts; -, *, /, % and the other comparisons still reject, and mixing a string with another type stays a kind mismatch. The operators page carries the full comparison table.
Printing char as text
Section titled “Printing char as text”A char, a char[N], and a char[] print as the text they hold, not as numbers. Since 1.1.0 print, println, and printerr write a char’s single byte, a char[N]’s bytes, or a char[]’s bytes straight to the stream, so s := "hi"; println(s[0]) prints h, not 104, and a char[5] prints its five glyphs. A multibyte UTF-8 sequence prints its glyph whole and an embedded NUL passes through. A rune is the exception: it still prints its codepoint number, since a char is one byte of a string’s text while a rune is a 4 byte scalar with no text form of its own, and only std.unicode’s encode_rune turns a scalar into displayed text. Reading a char’s numeric value is one annotated binding away, b: int64 = c.
Filling a char[N] from a literal
Section titled “Filling a char[N] from a literal”A string literal initializes a char[N] directly when its byte length is exactly N, in let and assignment position: s: char[5] = "Hello" copies the five bytes with no NUL appended and no padding. A byte count that does not match rejects and names both counts, the string literal has 6 byte(s); the annotation says char[5]. Only a literal converts this way; a string typed value never does and stays 's' has a type annotation that does not match its value. The conversion applies at a let binding and an assignment, a struct field or an index place among them, so mut m: char[3] = "abc" followed by m = "xyz" and m[0] = 'q' all work, while a call argument, a return, a struct literal field, and a tuple member keep the ordinary mismatch a string and a char[N] otherwise have.
func main() -> int32 { s := "hi" println(s[0]) // h, the byte as text, not 104
greeting: char[5] = "Hello" // a literal fills the array exactly println(greeting) // Hello
a := "ab" + "c" // a fresh heap string if a == "abc" { println("equal") // strings compare by content } free(a) return 0}String range slices
Section titled “String range slices”A range slice of a string, s[lo..hi], is bounds checked. Since 1.1.0 it validates lo <= hi <= len against the same NUL scanned length and faults, index out of bounds, on an out of range window rather than minting a slice over foreign bytes; an empty window like s[2..2] still yields a zero length slice. A raw pointer or *void has no length to check against, so a range slice of one is rejected at compile time, cannot take a range slice of a raw pointer; it has no length to check the range against. An ordinary index read through a raw pointer stays legal; only the range form is refused.
Since 0.5.2 a string literal is validated as UTF-8 at compile time and rejected if it is malformed, string literal is not valid UTF-8. Earlier releases replaced the bad bytes with U+FFFD and compiled on; now the compiler stops. The representation itself did not change. A string is still a byte view, s[i] still reads one byte and not one scalar, and walking a string scalar by scalar is a decoding step rather than an index. See Unicode and runes for the rune type, the \u{...} escape, and decode_rune, and std.unicode for the library.
Arrays and slices
Section titled “Arrays and slices”Two aggregate forms hold a sequence of a single element type T.
- Fixed array
T[N].Nelements stored inline. The size is known at compile time. Stack allocated like any value, passed by value as a copy. - Slice
T[]. A fat pointer{ ptr: *T, len: int64 }that views a contiguous run of elements without owning them. Same shape asstring, which is effectivelychar[].
xs: int32[4] = [1, 2, 3, 4] // fixed array, 16 bytes inlines: int32[] = xs[1..3] // slice viewing xs[1], xs[2], length 2argv: string[] // slice of strings, as passed to main- Slice length is always known from the fat pointer. No scanning, no NUL terminator.
- Every array and slice index is bounds checked and traps when it misses, negatives included.
- A range slice validates
lo <= hi <= lenagainst its base, so a slice can never claim a length past its backing. - A growable array is provided in the standard library as
std.vector, a heap backed generic type. See collections.
@paradigm procedural
func main() -> int32 { xs: int32[4] = [1, 2, 3, 4] // fixed array, 16 bytes inline s: int32[] = xs[1..3] // slice viewing xs[1] and xs[2] println(s.len) // 2, stored in the fat pointer
mut sum: int32 = 0 for x in xs { sum = sum + x } println(sum) // 10 return 0}Immutability and mutability
Section titled “Immutability and mutability”All variables are immutable by default. Mutability is declared with mut, which requires @paradigm procedural (see the paradigm system).
x: int32 = 5 // immutable, cannot be reassignedmut y: int32 = 5 // mutable, can be reassignedImmutability covers projections. An element or field store, xs[i] = v or p.x = v, needs its root binding declared mut, the same as the bare xs = v form. A store through a pointer dereference or through a slice writes the buffer the binding views, not the binding, so it is governed by the pointee’s rules instead.
Function scope restriction
Section titled “Function scope restriction”A mutable variable is only mutable within the function it was declared in. Nested function definitions and closures can read it but cannot mutate it. This fragment does not compile, by design:
func outer() -> void { mut x: int32 = 5 x = 10 // allowed, same function
func inner() -> void { x = 15 // COMPILE ERROR, x not mutable in this scope y := x + 1 // allowed, reading x is fine }}Scope here means the declaring function body. Ordinary blocks in the same function, such as loop bodies and if branches, can mutate the variable. Only nested function definitions and closures lose mutation rights. So mut x = 0 followed by a for loop that runs x = x + 1 is allowed, while mutating x from inside a nested inner() is not. This forces explicit data passing into inner scopes and prevents hidden state mutation through closures.
Pointers
Section titled “Pointers”Pointers exist only as the result of an explicit heap allocation through alloc. There is no address of operator for stack variables; stack variables are passed by value. A pointer binding is itself immutable: once assigned it cannot be reassigned to a different address. Dereferencing is explicit with the * prefix operator.
p: *int64 = alloc(100) // p points to a heap int64 initialized to 100y: int64 = 10 + *p // explicit dereferenceAfter free(p), the binding p is consumed. Using it again is a compile error where statically determinable, and otherwise the generation check faults the dereference at runtime, in every build.
Managed and raw layers
Section titled “Managed and raw layers”Since 0.2.1 there are two pointer layers.
- A managed
*Tis a fat pointer: the data pointer paired with a remembered generation. The default heap writes a live generation in a header before each block, andfreebumps it. Every managed dereference compares the remembered generation against the header and faults on a use after free, a double free, or a stale pointer to a reused block, in every build. *raw Tand*voidare one word pointers with no generation. They carry strings, slice data, receivers, and collection buffers, withptr_addfor byte arithmetic. The raw layer is unchecked.
A generation of zero is the untracked sentinel, so a using allocator hands back unchecked memory and custom allocators keep working. See memory management for the allocator interface and the memory guide for a walkthrough.
Single owner, ref, and move
Section titled “Single owner, ref, and move”Since 0.2.2 the checker tracks each managed pointer binding as an owner or a borrow.
- The binding created from
allocis the owner. A plain copy of an owner is rejected; the error points atrefto alias ormoveto transfer. move(x)transfers ownership and invalidates the source, so a later use of the moved from binding is rejected.- A
refbinding is a non owning alias, and a pointer parameter borrows. Freeing or moving a borrow is rejected, since only the owner frees or moves. - The raw layer,
*voidand*raw T, is exempt. The runtime generation check backstops what the static pass cannot see.
struct Box { n: int64,}
func bump(b: *Box) -> void { (*b).n = (*b).n + 1 // a parameter borrows; it cannot free or move}
func main() -> int32 { owner: *Box = alloc(Box { n: 1 }) bump(owner) ref alias: *Box = owner // non owning alias println((*alias).n) // 2 moved: *Box = move(owner) // ownership transfers, owner is dead println((*moved).n) free(moved) // only the owner frees return 0}Escape analysis
Section titled “Escape analysis”Since 0.2.3 the checker rejects values that would outlive their frame.
- Returning a slice that views a frame local fixed array is a compile error, since the array is reclaimed with the frame. A heap backed slice or a slice parameter still returns fine. Returning an array literal where a slice is expected is caught the same way.
- Returning a closure that captures a frame local is a compile error, while a capture free closure is a plain function pointer and may be returned.
- Pointer escapes need no static rule, since every pointer is heap allocated and the generation check covers them at runtime.
Foreign functions
Section titled “Foreign functions”Added in 0.2.4. A foreign block declares functions that live outside Dusk, so dusk code can call into C. The functions have no body. Each binds at link to a C symbol of the same name in anything the binary links, which is libc and the dusk runtime today. The standard library uses this to bind the runtime’s cool_* shims.
foreign "C" { func abs(n: int32) -> int32 func labs(n: int64) -> int64}
func main() -> int32 { a: int32 = abs(-5) b: int64 = labs(-7) println(a) println(b) return 0}The boundary is the raw pointer layer only. A parameter or return type is a scalar, a *raw T, or a *void. A managed *T is rejected, since it is a fat value carrying a generation that C cannot read, so a buffer crosses as *raw T and an opaque pointer as *void. Once declared, a foreign function is called like any other function. A named struct also crosses by value under a narrower rule of its own, and so does a function type, as a callback; both are covered in foreign functions.
A *raw T passes anywhere *void is expected; both are the same bare word. The reverse binding is rejected, since a *void that could become a typed *raw T would let a managed pointer launder through *void into a dereferenceable alias the generation check cannot see. A managed *T that round trips through *void back to a managed annotation comes back untracked, with no generation for the check to read, so everything through it afterward is the raw layer’s honor system. Keep managed pointers on the managed layer.
- Only the
"C"calling convention is supported. - The boundary has widened well past this sketch since 0.2.4. A variadic C function and a
@linkdirective that names a third party library for the linker arrived in 1.4.0, a C plain struct crossing by value in 1.4.1, and a dusk function crossing as a bare C function pointer in 1.4.2. Each carries rules of its own, and foreign functions is the normative reference for all of them. Reading the boundary the other way, anexport "C"function a C caller reaches directly, is covered in C libraries.