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. The operators that run on these types, the full set and its precedence ladder, live on the operators page.

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
rune4 bytesone Unicode scalar value (see below)
stringfat ptrbuilt in string type (see below)
errorbuiltinbuilt in error type (see error handling)

The four unsigned widths uint8, uint16, uint32, and uint64 became real surface in 1.13.0, after the whole bootstrap reserved the names. Each carries a value in [0, 2^w - 1], and arithmetic wraps at the width exactly as signed arithmetic does, so an unsigned type adds no new trap and no new fault. The matching literal suffixes u8, u16, u32, and u64 pin a literal to one without an annotation, so 255u8 is a uint8 and 18446744073709551615u64 is written directly.

Signedness rides the integer type as a flag rather than a separate kind, so uint32 and int32 both lower to an LLVM i32 but are distinct type identities that never interchange. Everything sign sensitive follows the operand’s own type: division and remainder, the right shift, the four ordered comparisons, widening, both float conversions, printing, and hash each take their unsigned form, so a uint8 holding 255 widens to 255 rather than the -1 a signed extension would give. A negative literal has no unsigned value at any width and is refused, literal -1 does not fit in uint32; an unsigned type holds no negative value, cast if the wrap is meant, and negating an unsigned value is refused the same way, since it would wrap to the top of the range rather than go below zero. An index and a range endpoint are int64 offsets, so an unsigned one is cast at the index rather than widened by a signedness the position never states. See numeric casts for the four uint*(v) conversions and No silent width mixing below for the mixing rule.

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.

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 int8 or float32, use a literal suffix or an annotation.

A suffix selects a non default type without an annotation.

a := 5i8 // int8
b := 3.14f32 // float32
c := 200i16 // int16
d := 255u8 // uint8, since 1.13.0

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. Signedness never mixes either, on the same operations: an int32 beside a uint32 is arithmetic mixes int32 and uint32; cast to one signedness, a width disagreement still ending ; match the widths so the message names which of the two rules broke. A bare literal adapts to the signedness beside it as well as the width, so 5u32 + 1 is a uint32 and the unsuffixed operand never drags the result back to the signed twin.

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

Each of the twelve scalar type names doubles as a call form that converts a value to that type explicitly: int8(v), int16(v), int32(v), int64(v), uint8(v), uint16(v), uint32(v), uint64(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. 1.13.0 added the four uint*(v) conversions beside the unsigned types. 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, the reinterpretation across signedness at one width, and the unchecked rune.

All twelve 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.

numeric-casts.dusk
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
}

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 assignment s[i] = c is rejected at check, a string is immutable; build a new one with a StringBuilder, since the bytes live in read only storage. 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.

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.

Since 1.11.1 an f before the opening quote makes an interpolated string, f"total: {n}", an expression of type string whose holes render inline. It follows the r'...' rune precedent: the f sits on the quote, a chunk between holes reads like a plain literal with the same escapes and \u{...} decoding, {{ and }} cook to single braces, and a hole {expr} holds an arbitrary expression. The rendered bytes are byte identical to what println("{}", v) writes for each value, so a hole’s printable set is exactly a println argument’s: an integer, a float, a bool, a char, a char[N], a char[], a rune, a string, an error, and a Display struct through its toString.

An f-string is an ordinary heap string. It mints through the same allocation class as +, frees with a plain free, and feeds the string + operator, so it is a value, not a format string: passed to println, it is one already rendered string and is never rescanned for holes.

fstring.dusk
@import std.string
func main() -> int32 {
n: int64 = 3
who := "dusk"
s := f"{who} has {n} paradigms"
println(s) // dusk has 3 paradigms
free(s)
return 0
}

Five spellings are rejected, each named. An empty hole f"a {} b" is an interpolation hole needs an expression; '{}' is the format placeholder for println, an f-string writes the expression inline, which is the one that separates it from a format string’s {}. A nested f-string is an interpolated string cannot nest inside another; bind the inner one first, a bare } in a chunk is unmatched '}' in an interpolated string; write '}}' for a literal brace, an unclosed one is unterminated string literal, and an await inside a hole is refused as every mid expression await is. A hole holding an unprintable value reuses the print diagnostics, so an enum, a bare pointer, or a void call each reject with the message they get as a println argument.

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.

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.

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

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.

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 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 @link directive 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, an export "C" function a C caller reaches directly, is covered in C libraries.