Skip to content

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.

TypeSizeDescription
int81 bytesigned 8 bit integer
int162 bytessigned 16 bit integer
int324 bytessigned 32 bit integer
int648 bytessigned 64 bit integer
uint81 byteunsigned 8 bit integer
uint162 bytesunsigned 16 bit integer
uint324 bytesunsigned 32 bit integer
uint648 bytesunsigned 64 bit integer
float324 bytes32 bit floating point
float648 bytes64 bit floating point
bool1 bytetrue or false
char1 bytesingle ASCII character
stringfat ptrbuilt in string type (see below)
errorbuiltinbuilt in error type (see error handling)

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 bool

You can always annotate a type explicitly.

x: int32 = 5

Inference uses these defaults:

  • Integer literals become int64.
  • Float literals become float64.
  • For other types such as uint8 or float32, use a literal suffix or an annotation.

A suffix selects a non default type without an annotation.

a := 5u8 // uint8
b := 3.14f32 // float32
c := 200u64 // uint64

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.

widths.dusk
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
}

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 StringBuilder in std.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’s str_len does. The NUL keeps a string view compatible with C and the foreign interface.
  • The cstr builtin reinterprets a NUL terminated char buffer as a string at no runtime cost. std.string uses it to hand a StringBuilder’s *raw char buffer back as a string view.

Unicode handling is deferred past the 0.2.x line.

Two aggregate forms hold a sequence of a single element type T.

  • Fixed array T[N]. N elements 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 as string, which is effectively char[].
xs: int32[4] = [1, 2, 3, 4] // fixed array, 16 bytes inline
s: int32[] = xs[1..3] // slice viewing xs[1], xs[2], length 2
argv: 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 <= len against 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.
slices.dusk
@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
}

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 reassigned
mut y: int32 = 5 // mutable, can be reassigned

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

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 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 100
y: int64 = 10 + *p // explicit dereference

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

Since 0.2.1 there are two pointer layers.

  • A managed *T is a fat pointer: the data pointer paired with a remembered generation. The default heap writes a live generation in a header before each block, and free bumps 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 T and *void are one word pointers with no generation. They carry strings, slice data, receivers, and collection buffers, with ptr_add for 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.

Since 0.2.2 the checker tracks each managed pointer binding as an owner or a borrow.

  • The binding created from alloc is the owner. A plain copy of an owner is rejected; the error points at ref to alias or move to transfer.
  • move(x) transfers ownership and invalidates the source, so a later use of the moved from binding is rejected.
  • A ref binding 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, *void and *raw T, is exempt. The runtime generation check backstops what the static pass cannot see.
ownership.dusk
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
}

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.

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_abs.dusk
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.