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.
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 |
| uint8 | 1 byte | unsigned 8 bit integer |
| uint16 | 2 bytes | unsigned 16 bit integer |
| uint32 | 4 bytes | unsigned 32 bit integer |
| uint64 | 8 bytes | unsigned 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 |
| string | fat ptr | built in string type (see below) |
| error | builtin | built in error type (see error handling) |
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
uint8orfloat32, use a literal suffix or an annotation.
Literal suffixes
Section titled “Literal suffixes”A suffix selects a non default type without an annotation.
a := 5u8 // uint8b := 3.14f32 // float32c := 200u64 // uint64No 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 := 5u8 // uint8, 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}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. The growable
StringBuilderinstd.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.
Unicode handling is deferred past the 0.2.x line.
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 *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. - A struct passed by value across the boundary, a variadic foreign function, and linking a third party library are deferred to a later interop release.