Skip to content

Builtins

Builtins are functions the compiler provides directly, so you never import them. They are always available regardless of paradigm directives unless noted. Two groups are gated: the functional builtins require @paradigm functional, and the procedural constructs require @paradigm procedural. See Paradigm system for how directives stack.

BuiltinSignatureDescription
allocalloc(value?) -> *Theap allocate through the in scope allocator
freefree(p: *T) -> voiddeallocate through the in scope allocator
printprint(...) -> voidprint to stdout, handles all primitive types
printlnprintln(...) -> voidprint to stdout with a newline
printerrprinterr(...) -> voidprintln to stderr
sizeofsizeof(T) -> int64size of a type in bytes at compile time
hashhash(v) -> int64deterministic 64-bit hash of a hashable scalar or string
spawnspawn(f: () -> void) -> (thread, error)start an OS thread running a lambda literal
joinjoin(t: thread) -> errorwait for a thread; retires the handle
submitsubmit(f: () -> void) -> errorqueue a lambda literal on the global thread pool
async_runasync_run(g(args)) -> Tcrank the event loop until a direct async call completes

Beyond this table, a handful of I/O and diagnostic builtins are also available everywhere without an import: read_file, write_file, read_line, read_all, and parse_float are documented in stdlib I/O, the debug allocator counters in stdlib memory, and the move builtin for ownership transfer in Memory.

alloc and free are not a fixed implementation. They lower to a call on the allocator that is in scope, which is the default heap allocator unless a using parameter designates another. The allocation size is inferred from the declared type on the left hand side, so the programmer never passes a byte size. The uninitialized form alloc() requires the pointer annotation, since the annotation is what sizes the block. free must run under the allocator that produced the pointer. See Memory for the allocator interface, defer, and the generational safety checks.

sizeof(T) is evaluated at compile time and returns the size of a type in bytes as an int64.

builtins-alloc.dusk
func main() -> int32 {
p: *int64 = alloc(100)
defer free(p)
println(*p)
println(sizeof(int64))
return 0
}

spawn starts an OS thread and join waits for it. Both take no paradigm directive. spawn accepts only a lambda literal written at the call site, since only the literal site knows the environment layout the runtime copies; a closure variable cannot be spawned. Its error fires when the operating system refuses the thread. join blocks until the body returns and retires the handle, so a second join of the same handle faults through the same check a use after free hits.

builtins-spawn.dusk
func main() -> int32 {
t, e := spawn(lambda () -> void {
println("worker")
})
if e.exists() {
printerr(e)
return 1
}
je := join(t)
je.ignore()
return 0
}

submit, added in 0.3.3, queues a task on the global thread pool. It shares spawn’s whole argument rule, returns only an error, and never blocks the submitter; its error exists only when the pool is not running. The pool is started and shut down through std.concurrent.pool. Capture rules, the memory model, and the pool lifecycle are covered in Concurrency.

async_run, added in 0.4.2, is the bridge from synchronous code into the event loop. It takes a direct call of an async func written at the call site, cranks the loop until that call’s future completes, and yields the value. Like spawn, join, and submit, it is gated behind no paradigm, so any file can call it. It carries one placement rule: it runs only outside an async body, since a task frame can await a call directly instead.

async func and await are keywords, not builtins, also added in 0.4.2 and ungated. async func marks a function that compiles to a state machine over a heap frame, and await suspends inside one at exactly four statement positions. Both are covered in full in the async reference.

int8, int16, int32, int64, char, rune, float32, and float64 double as builtin call forms that convert one scalar value explicitly. The first five arrived in 1.2.0 as integer width casts, and 1.5.0 added the other three and opened the cast across the integer and float boundary in both directions, so int32(v) and its siblings now take one integer, char, rune, bool, float32, or float64 and convert it to the named type. Each takes exactly one argument. A non scalar rejects, a numeric cast takes an integer, char, rune, or float value; string does not cast, and all eight names are reserved as builtin call forms, so a function cannot be declared with one of them, though a variable or field may still carry the name. See the type system and operators for the conversion rules per operand and target, including the float to integer saturation and the unchecked rune.

builtins-cast.dusk
func main() -> int32 {
a: int8 = int8(300) // 44, truncated to eight bits
b: char = char(101) // 'e'
c: float64 = float64(65) // 65, an integer read as a float
d: int64 = int64(-2.9) // -2, truncated toward zero
println(a)
println(b) // e, a char prints as text
println(c)
println(d)
return 0
}

hash(v), added in 1.5.1, returns a deterministic 64-bit hash of a hashable value: an integer of any width, a char, a rune, or a string. It is the key hash a generic map builds on, no paradigm gates it, and it takes exactly one argument. A float is refused, since a NaN is never equal to itself and would break the coherence between a hash and the equality a map compares its keys by. A struct, a pointer, a slice, and every other non scalar is refused too, cannot hash float64; a map key is an integer, char, rune, or string, naming the type it was handed. In a generic function hash(k) over a type parameter passes the surface pass, and a non hashable instantiation is rejected once the ground type is known, the same two pass shape a generic comparison takes.

A string hashes by its content rather than by its pointer, so two strings with the same bytes hash equal however they were built, matching the way == already compares strings. An integer, a char, or a rune hashes to its own value widened. The exact hash value is unspecified and may change between releases, so a program leans only on two guarantees: equal values hash equal, and a hash is stable within one run. Never store one or compare one against a number you wrote down.

hash reserves its name from a function declaration the same way the cast builtins do, 'hash' is a builtin; a function cannot take its name. That reject landed in 1.5.2, after a real soundness bug: the checker resolved a call to a user’s own hash while codegen resolved the bare name to the builtin unconditionally, so a two argument user hash silently hashed only its first argument and the program computed something the checker had never accepted. A variable or field named hash is still fine; only a function declaration collides.

builtins-hash.dusk
@import std.string
func main() -> int32 {
a: int64 = hash(int64(7))
b: int64 = hash(int64(7))
if a == b { println("equal ints hash equal") }
host: string = "xabcy"
mid: string = substring(host, 1, 4)
if hash("abc") == hash(mid) { println("a string hashes by content") }
if hash("abc") != hash("abd") { println("distinct content hashes apart") }
return 0
}

print writes to stdout with no newline, println appends one, and printerr writes to stderr with a newline. Each handles all primitive types, including strings.

Since 1.1.0 the print family writes a char, a char[N], and a char[] as their text bytes, not as numbers. A char prints its one byte as a glyph, a char[N] and a char[] print their bytes straight through, and a format hole {} does the same when the value behind it is one of these three types. So s := "hi"; println(s[0]) prints h, not 104, 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 it is a 4 byte scalar rather than a byte of string text, and std.unicode’s encode_rune is what turns a scalar into displayed text. Reading a char’s numeric value is one annotated binding away, b: int64 = c.

Any type that implements the Display interface can be passed to print and println.

interface Display {
toString() -> string;
}

Passing a struct with no Display impl to a print builtin is a compile error, as is printing an enum, a tuple, or a pointer. A slice is not printable either, with one exception: a char[], like a char[N] and a char itself, prints its bytes as text rather than being rejected, the rule described above. Print never emits silence for a value it cannot render. Like every dusk diagnostic, that error prints three lines: a header, the source line, and a caret run under the offending call, with the caret columns counted in Unicode scalar values.

Declaring an interface and writing an impl require @paradigm oop, so a file that gives its structs a Display impl declares that directive. Printing the value afterward needs no paradigm.

builtins-display.dusk
@paradigm oop
interface Display {
toString() -> string
}
struct Point {
x: int64,
y: int64,
}
impl Display for Point {
func toString() -> string {
return "point"
}
}
func main() -> int32 {
p := Point { x: 1, y: 2 }
print("point is ")
println(p)
return 0
}

An error value can also be passed to the print builtins, as the printerr(e) call above shows; its text comes from its toString, described in Error handling.

These require @paradigm functional in the calling file.

BuiltinDescription
mapapplies a function to each element
filterfilters a collection by predicate
reducereduces a collection to one value
foldfold left or right
foreachiterates for side effects

They take lambdas, which capture outer variables by immutable copy. fold takes an explicit initial value. reduce returns a (T, error) pair and guards the empty slice, so the caller resolves the error like any other.

builtins-functional.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(nums, lambda (n: int64) -> bool { return n % 2 == 0 })
sum := fold(nums, 0, lambda (acc: int64, n: int64) -> int64 { return acc + n })
prod, prod_err := reduce(nums, lambda (a: int64, b: int64) -> int64 { return a * b })
prod_err.ignore()
foreach(doubled, lambda (n: int64) -> void { println(n) })
foreach(evens, lambda (n: int64) -> void { println(n) })
println(sum)
println(prod)
return 0
}

Gating is per file. A file without @paradigm functional cannot call map directly, but it can call a user defined function that internally uses map. See Functional for the full set of functional concepts, including monads and do notation.

These require @paradigm procedural. A file with no @paradigm directive defaults to procedural, so they are available by default.

Builtin or keywordDescription
forfor loop
whilewhile loop
do whiledo while loop
mutdeclares a mutable variable

The raw pointer layer, *raw T and *void, carries strings, slice data, and collection buffers, and comes with three primitives. The specification’s builtins table does not enumerate alloc_bytes and ptr_add; they appear in the specification’s concurrency examples, the changelog for 0.2.1, and throughout the standard library sources, and are documented here from those.

BuiltinDescription
alloc_bytesalloc_bytes(n: int64) allocates n raw, uninitialized bytes through the in scope allocator
ptr_addbyte arithmetic over a raw pointer; takes a *raw T or *void, not a managed *T, returns the same type
cstrreinterprets a NUL terminated *char buffer as a string at no runtime cost

alloc_bytes is the base primitive for arenas and growable buffers; std.vector, std.map, and StringBuilder are built on it. The binding’s raw pointer annotation types the result, the same way an annotation sizes alloc(), and the block is released with free. cstr is what sb_cstr in std.string uses to hand back a string view of a builder’s buffer.

builtins-raw.dusk
func main() -> int32 {
buf: *raw char = alloc_bytes(3)
buf[0] = 'h'
buf[1] = 'i'
buf[2] = '\0'
s: string = cstr(buf)
println(s)
tail: *raw char = ptr_add(buf, 1)
println(cstr(tail))
free(buf)
return 0
}

Raw pointers are one word and carry no generation, so nothing checks a dereference through them: use after free and double free on the raw layer are the programmer’s responsibility. The managed *T layer, where every dereference is checked, is described in Memory.