Skip to content

Language tour

This is a whirlwind pass over Dusk as of 1.4.0. Each stop shows a small program that compiles today and links to the reference page that covers the details. If you have not built the toolchain yet, start with Getting started. Every complete program below runs with dusk run <file>.

tour_hello.dusk
func main() -> int32 {
println("hello, dusk")
return 0
}

main returns an int32 exit code, and 0 means success. println is a builtin, always available with no import. A file with no @paradigm directive defaults to procedural, so this file needs no top-of-file syntax at all. See Builtins and Source files.

:= infers a variable’s type from the right-hand side at compile time. Integer literals default to int64 and float literals to float64; an annotation or a literal suffix picks any other width. Variables are immutable by default, and an unused variable is a compile error.

variables.dusk
func main() -> int32 {
x := 5 // inferred int64, the default integer type
y := 3.14 // inferred float64, the default float type
ok := true // inferred bool
small: int32 = 5 // explicit annotation picks another width
f := 2.5f32 // literal suffix picks float32
println(x)
println(y)
println(ok)
println(small)
println(f)
return 0
}

See Types for the full primitive table.

Numeric widths never mix silently. Arithmetic, comparison, assignment, and argument passing all take operands of one width, so there is no implicit widening or truncation.

a: int32 = 1
b: int64 = 2
c := a + b // error: arithmetic mixes int32 and int64; match the widths

This fragment is a compile error, shown here as a fragment for exactly that reason. A bare literal adapts to the width beside it, so a + 1 is fine, and a literal that cannot fit its annotated width is rejected. See Types.

Since 0.4.2 every operator sits on one precedence ladder, thirteen levels from loosest to tightest. Alongside the arithmetic you have already seen, you get the bitwise family (& | ^ ~ << >>), the compound assignments (+=, -=, and the rest), postfix ++ and --, exponent **, the pipe |>, and the inclusive range ..=.

tour_operators.dusk
@paradigm procedural
func main() -> int32 {
mut n: int64 = 2 ** 5 // exponent, 32
n += 8 // compound assignment, 40
n++ // postfix increment, statement only, 41
xs: int64[4] = [10, 20, 30, 40]
s: int64[] = xs[0..=2] // inclusive range, three elements
println(n & 255) // bitwise and, 41
println(s.len) // 3
return 0
}

The pipe |> rewrites x |> f(a) into f(x, a) at parse time, a spelling for threading a value through calls. See Operators for the full ladder and every family.

Since 1.2.0, break and continue are statement keywords, both gated to @paradigm procedural like the loop forms themselves. break leaves the innermost loop at once, and continue skips to its next iteration; in a for loop the index still advances on a skipped turn, so the loop moves forward rather than stalling on the same element.

tour_loops.dusk
@paradigm procedural
func main() -> int32 {
xs: int64[] = [4, -1, 9, 200, 7]
mut sum: int64 = 0
for x in xs {
if x < 0 { continue } // skip negatives, the index still advances
if x > 100 { break } // stop at the first value over 100
sum = sum + x
}
println(sum) // 4 + 9 = 13, then 200 breaks the loop
return 0
}

Used outside any loop each is a compile error, and a lambda body is its own function boundary, so a break inside a lambda does not reach a loop the lambda runs under. See the paradigms guide.

Since 1.2.0, + on two strings mints a fresh heap string, and += rebinds a mut string to the joined result. + is the only arithmetic operator a string takes, and the string it returns is freed with an ordinary free.

tour_concat.dusk
@paradigm procedural
func main() -> int32 {
name := "dusk"
msg: string = "hello, " + name // + mints a fresh heap string
println(msg) // hello, dusk
free(msg)
return 0
}

Also since 1.2.0, == and != on strings compare their bytes rather than pointer identity, so a string you build at runtime compares equal to an identical literal. Strings have no ordering, so < and its family reject. See Types.

Functions are declared with func. All parameters are passed by value, always; there are no reference types and no overloading. A struct is a plain data container, available in every paradigm.

point.dusk
struct Point {
x: int64,
y: int64,
}
func manhattan(p: Point) -> int64 {
return p.x + p.y
}
func main() -> int32 {
p := Point { x: 3, y: 4 }
println(manhattan(p))
return 0
}

Generic functions are written func id<T>(x: T) -> T and are monomorphized per use. Methods attach to structs through impl, and interfaces provide polymorphism; both impl blocks and interface declarations need @paradigm oop. See Functions and OOP.

An enum is a tagged union: a value is exactly one of several named variants, each optionally carrying payload fields. match inspects one and must be exhaustive, so a missing variant is a compile error.

shapes.dusk
enum Shape {
Circle(radius: int64),
Rect(w: int64, h: int64),
Empty,
}
func area(s: Shape) -> int64 {
match s {
Circle(r) => return r * r * 3,
Rect(w, h) => return w * h,
Empty => return 0,
}
}
func main() -> int32 {
println(area(Shape.Circle(5)))
println(area(Shape.Rect(4, 6)))
println(area(Shape.Empty))
return 0
}

Enums are paradigm-agnostic and back the standard library’s Maybe and Either. See Enums.

There are no exceptions and no panic. A fallible function returns a (T, error) tuple, and the call site must bind and use the error: inspect it with exists(), handle it with check(...), or discard it explicitly with ignore(). An unhandled error binding is a compile error.

tour_divide.dusk
func safe_div(a: int64, b: int64) -> (int64, error) {
if b == 0 {
return (0, error { message: "division by zero" })
}
return (a / b, error {})
}
func main() -> int32 {
q, e := safe_div(10, 2)
if e.exists() {
printerr(e)
return 1
}
println(q)
q2, e2 := safe_div(1, 0)
e2.check(lambda (err: error) -> void { printerr(err) })
println(q2)
return 0
}

error {} is the empty, non-error value. There is no _ suppression; ignore() is the visible, greppable replacement. See Error handling and the errors guide.

Memory is explicit. alloc heap-allocates through the in-scope allocator, sized by the declared type on the left-hand side, so you never pass a byte count. Dereference is explicit with the * prefix. defer runs cleanup at function exit, in reverse order of registration, including on an early return.

tour_heap.dusk
struct Point {
x: int64,
y: int64,
}
func main() -> int32 {
n: *int64 = alloc(41)
defer free(n)
println(*n + 1)
p: *Point = alloc(Point { x: 3, y: 4 })
defer free(p)
println((*p).x + (*p).y)
return 0
}

Since 0.2.0 the default heap is generational: every managed pointer carries a generation checked at each dereference, so a use after free or double free faults instead of corrupting memory. See Memory and the memory guide.

A lambda is an anonymous function and a first-class value. It captures outer variables by immutable copy, taken at creation. There is no capture by reference.

factor := 3
triple := lambda (n: int64) -> int64 { return n * factor }

The functional builtins map, filter, reduce, fold, and foreach take lambdas and require @paradigm functional:

tour_pipeline.dusk
@paradigm functional
func main() -> int32 {
nums: int64[] = [1, 2, 3, 4, 5]
doubled := map(nums, lambda (n: int64) -> int64 { return n * 2 })
evens := filter(doubled, lambda (n: int64) -> bool { return n % 4 == 0 })
sum := fold(evens, 0, lambda (acc: int64, n: int64) -> int64 { return acc + n })
foreach(doubled, lambda (n: int64) -> void { println(n) })
println(sum)
return 0
}

See Functional concepts for monads and do notation.

A file declares the paradigms it uses with one or more @paradigm directives, and they stack: the builtins and syntax you get are the union of every paradigm named. functional unlocks the builtins above, procedural unlocks for, while, and mut, and oop unlocks interface declarations, impl blocks, and composition, so a file that names two gets both sets at once. Gating is per file, so a paradigm’s builtin or keyword is only allowed where that paradigm is declared. A file with no directive at all defaults to procedural, but naming a paradigm explicitly replaces that default rather than extending it, so a file that declares only functional gives up mut.

tour_stacked.dusk
@paradigm procedural
@paradigm functional
func main() -> int32 {
nums: int64[] = [1, 2, 3, 4]
doubled := map(nums, lambda (n: int64) -> int64 { return n * 2 })
mut i: int64 = 0
while i < 4 {
println(doubled[i])
i = i + 1
}
return 0
}

Gating is per file only. Functions and types you define are paradigm-agnostic and callable from any file, whatever its directives. See Paradigm system and the paradigms guide.

  • The memory guide covers arenas, the allocator interface, and the generational heap in depth.
  • The concurrency guide covers spawn, join, channels, mutexes, and the thread pool added across the 0.3.x line.
  • The packages guide covers imports and the dawn package tool.
  • The unicode guide covers the rune primitive and how Dusk handles UTF-8 strings.
  • Examples walks the runnable programs in the repository’s examples/ directory.
  • Or try code in the playground without installing anything.