29. Builtins
A builtin is a function that is part of the Tycho *language* rather than of the corelib: it is in scope in every module with no import, and its name resolves without qualification. This chapter is the complete, normative catalog of the builtins — grouped by category, each with its argument and result types and a one-line semantics, and each marked Sig or magic (§29.2).
The construction, mutation, and place semantics of the aggregates these builtins operate on are specified in Part VI (§16–§19); their arena and value-copy behavior in §9; the conversions' full accepts→yields table in §8.2. Operators and keywords that read as calls but are not builtins — m[k], k in m, delete m[k], for x in xs, &place, or_return — are specified in their own chapters and are out of scope here.
29.1 Builtins are part of the language
Every builtin named in this chapter is always available. A conforming implementation MUST make it resolvable in any module with no import and no package qualifier, and MUST NOT require a builtin to be declared, imported, or enabled by a flag. Builtins share the ordinary call syntax; they are distinguished from user functions only by being predefined.
The line between the language and the corelib is drawn precisely:
- A name is a builtin (part of the language) iff
register_builtinsregisters it or a case inresolve_expr/ codegen special-cases it by name (§29.2). Such a name is available with no import. - Every other predefined function is a corelib procedure, reached only through
import "core:…"and specified in Part XVIII (the corelib). Corelib is *layered on* the language; it is not part of it.
This distinction is normative: it fixes which names a conforming implementation MUST provide unconditionally (the builtins) versus which it MAY split across the core and extended tiers (§1.3).
29.2 Sig builtins and magic builtins
Builtins fall into two kinds, and each entry below is marked with one:
- Sig — registered in
register_builtinswith a fixed, monomorphic signature and resolved by ordinary signature lookup, arity check, and argument type-checking, exactly like a user function (§15). ASigbuiltin is never overloaded and takes its arguments by value. - magic — recognized by name in
resolve_expr(and in codegen) *before* signature lookup. A magic builtin MAY be polymorphic (its result or accepted types depend on an argument's type), MAY have a restricted parse form (e.g.channelonly as a declaration RHS), MAY require an lvalue argument, or MAY lower to a literal or an operator rather than a call. Because it is intercepted ahead of theSigtable, a magic builtin is not an ordinary signature and its checking rules are stated per entry.
Three names — str, to_ptr, to_i32 — carry both a Sig registration and a resolve_expr special-case; the special-case governs. Classification here follows observable behavior: str is polymorphic and is marked magic; to_ptr keeps a single fixed int signature and is marked Sig; the fixed-width conversions to_u8…to_i64/to_f32 (including to_i32) are numeric-polymorphic like str.
29.3 I/O and process
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
print(s) | string -> void | Sig | Write s's bytes to stdout; no implicit newline. |
println(s) | string -> void | Sig | print(s) followed by a single "\n". String-only, like print; use println(str(x)) for a non-string. |
eprint(s) | string -> void | Sig | Write s's bytes to stderr; no newline, no exit. |
eprintln(s) | string -> void | Sig | Write s's bytes and one trailing newline to stderr; no exit. |
input() | -> string | Sig | Read one line from stdin, with the trailing newline stripped. |
read_all() | -> string | Sig | Read all of stdin as one string. |
args() | -> [string] | Sig | The command-line arguments; args()[0] is the program name. |
getenv(name) | string -> string | Sig | The environment variable's value, or "" if unset. |
die(msg) | string -> void | Sig | Print msg to stderr and terminate the process with exit status 1; never returns (§29.12). |
exit(code) | int -> void | Sig | Terminate the process with exit status code, printing nothing; never returns (§29.12). |
print, println, eprint, and eprintln accept a string only; they do not implicitly stringify. All nine are Sig builtins with fixed signatures.
input() cannot distinguish EOF from an empty line; both produce "". Use the streaming core:io line reader when EOF is a distinct outcome. print and println are stdio-buffered, so a pipe reader may not see output until the process exits; io.flush_stdout() is the explicit flush.
29.4 Conversions
Tycho performs no implicit value conversion (§8); every value-level conversion is one of the builtins below. The full accepts→yields table — the exact set of source types each accepts and the type each yields — is §8.2 and is not duplicated here; this section states only each builtin's kind and one-line role.
| Builtin | Role | Kind |
|---|---|---|
str(x) | Polymorphic value → string for int, any sized int, f32, bool, float, string (through base_of); identity on string; a char → its one-byte glyph string. | magic |
to_int(x) | float/u32/u64/f32 or an int-newtype → int (float truncates toward zero). | magic |
to_float(n) | int/u32/u64/f32 or a float-newtype → float. | magic |
to_u32(x) / to_u64(x) / to_f32(x) | Any numeric scalar (int/char/float/u32/u64/f32, through base_of) → the sized type. | magic |
to_str(x) | bytes, or a string-newtype → string (zero-cost reinterpret). | magic |
to_bytes(x) | string/bytes (through base_of) → bytes (same buffer, distinct type — zero-cost reinterpret); or [int] → bytes, each element truncated & 0xFF into a fresh buffer (the only way to construct a binary bytes with interior 0x00); or a packed struct → bytes, its exact size_of$(T) bytes, little-endian (§17.1b). | magic |
to_bool(x) | A bool-newtype → bool (unwrap). | magic |
to_under(x) | Any newtype → its underlying type (generic zero-cost unwrap). | magic |
chr(n) | int -> string: the one-byte string for byte value n (0–255); a value outside 0..255 aborts. | Sig |
to_char(n) | int -> char: the byte value n (0–255) as a char; a value outside 0..255 aborts, with chr's message — it is the same conversion, differing only in whether the byte comes back as a char or as a one-byte string. | Sig |
to_ptr(n) | int -> ptr: an opaque FFI sentinel pointer, never dereferenced (§24). | Sig |
to_u8…to_i64, to_f32 | numeric -> the named fixed-width type (§5.2.7); narrow/reinterpret, total. | Sig |
is_null(p) | ptr -> bool, and handle -> bool: test an opaque FFI pointer or a typed handle for NULL (§24). On a handle it is how an opener's failure is detected, and it reads true after close(h) (§25). | Sig |
The base-specific to_int/to_float/to_str/to_bool and the generic to_under are the newtype unwrappers (§8.3, §5.4). str(char) yields the char's one-byte glyph string (§5.2.4).
to_char is the only way into char from an int, and its result type cannot be written down — char has no type keyword (§5.2.4), so to_char, like char_at, is reached through inference alone. Its out-of-range answer is an abort, not a wrap, following chr and to_int(float): those are conversions with a *domain*, and a value outside it is a program error. The to_u8…to_i64 family wraps instead, but those are documented as total reinterpretations — a different category, not a counter-example. Two limits apply: the abort message names chr even when the call was to_char, and to_char is not in the UFCS builtin set, so to_char(n) is the only spelling — n.to_char() is not.
29.5 Strings
string is a byte buffer (§5.2.5); every string builtin is byte-oriented, not Unicode-aware. len is the overloaded length builtin shared with arrays, bytes, maps, and soa (§29.6); on a string it yields the byte count.
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
len(s) | string -> int | magic | Byte length. Overloaded (§29.6). |
substr(s, a, b) | (string, int, int) -> string | Sig | A fresh copy of the byte range [a, b); out-of-range bounds are clamped, not an error. |
find(s, sub) | (string, string) -> int | Sig | Byte index of the first occurrence of sub, or -1. |
char_at(s, i) | (string, int) -> char | Sig | The byte at i as a char. Same read and same bounds abort as s[i] (§5.2.5); only the STATIC type differs — s[i] yields int. Aborts at run time if i is out of bounds. |
split(s, sep) | (string, string) -> [string] | Sig | Split on a non-empty separator; n separators yield n+1 fields. An empty sep aborts at run time (§29.12). |
char_at is the char-typed companion to string indexing. s[i] yields int and MUST keep yielding int, so s[i] == 'e' is a type error; char_at(s, i) == 'e' is the supported spelling. The two read the same byte with the same checks, so neither is faster or safer than the other, and to_int(char_at(s, i)) == s[i] for every in-range i. See §5.2.5 for why the wart exists.
29.6 Arrays
All four array builtins are magic. len is overloaded across [T], string, bytes, maps, and soa. push, pop, and reserve require a mutable aggregate: their first argument MUST resolve to a place whose root is a mutable variable or an owned field — a read-only borrowed parameter is rejected — so they may grow or shrink the value in the owning arena.
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
len(a) | [T] -> int | magic | Element count. Also accepts string, bytes, a map, or a soa. |
push(a, v) | ([T], T) -> void | magic | Append v in place; a MUST be a mutable array (or soa) place. Grounds a still-pending array type to [typeof(v)]. |
pop(a) | [T] -> T | magic | Remove and return the last element; aborts at run time if empty. a MUST be mutable. |
reserve(a, n) | ([T], int) -> void | magic | Capacity hint: preallocate room for n elements. len is unchanged and pushing past n still grows; an unallocatable capacity aborts. Restricted to arrays of scalars, structs, tuples, or nested arrays (not soa), and to maps — reserve(m, n) pre-sizes a map's entry + index arrays. |
29.7 Maps
Map key/value rules and the map operators are in §5.3.5 and §18. The two map builtins proper are keys and the .get method sugar; both are magic.
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
keys(m) | [K: V] -> [K] | magic | The map's live keys as an array, for iteration; a newtype key stays wrapped. |
m.get(k) | ([K: V], K) -> V | magic | Value for k, or the value-type zero on a miss. Desugars to the m[k] rvalue read. |
m.get(k, d) | ([K: V], K, V) -> V | magic | Value for k, or the default d if absent. |
hash(x) | K -> int for any map-key type K | magic | The hash the map keys use, exposed: equal-by-== values hash equal; returns the full 64-bit hash as a signed int. |
hash(x) accepts exactly the map-key types (§5.3.5) — int, string, their newtypes, fieldless enums, and composites of hashable leaves; a bare float/bool/char/bytes is refused (it is a hashable *leaf* but not a legal top-level key). It is deterministic — fixed keys/seed, so the same value hashes the same on every run and every machine (equal-by-== values always hash equal) — which makes it usable as a checksum / content-address over composites, not just an in-process table hash. The MAP's internal hashing stays per-process seeded (hash-flooding defense); only the user-facing hash(x) is deterministic. A composite arg gets its per-type hash function on demand, so hashing a struct that is never a map key still works.
m.get is method sugar on a map receiver: m.get(k) is equivalent to the m[k] rvalue read and m.get(k, d) to the default-read form. It resolves on a map receiver only; on any non-map type get falls through to ordinary UFCS resolution.
The map operators m[k] (as a place and as an absent-key read yielding the value's zero), k in m, and delete m[k] are not builtins — they are operators/keywords specified in §18. The former functions map_set, map_get, map_has, and map_del have been removed: a user-written call to any of them is a hard parse error directing the author to m[k] = v, m.get(k, default), k in m, and delete m[k] respectively. (The identically-named nodes that the desugars build internally are not user-callable.)
29.8 Type builtins
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
zero$(T) | -> T | magic | The zero value of a defaultable scalar type T. |
zero$(T) uses the explicit type-argument call form (name$(T, …), §7.1) and is the one builtin that consumes it specially. T MUST be one of the four defaultable scalar types — int (0), float (0.0), bool (false), string ("") — matching the defaultable where-predicate (§7.2, §7.5); a newtype or any other type is rejected (fails closed). It takes no value arguments and is lowered to the scalar zero literal, so it needs no runtime support. It exists to seed a generic accumulator (acc := zero$(T)) that must work on an empty input.
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
size_of$(T) | -> int | magic | The size of the packed or align(N) struct T in bytes (§17.1b). |
from_bytes$(T)(b) | bytes -> T | magic | Read a packed struct back out of exactly size_of$(T) bytes. |
Both use the same explicit type-argument call form as zero$(T). size_of$(T) accepts a packed struct or an align(N) struct (or a newtype over either); from_bytes$(T) accepts a packed struct (or a newtype over one). No other type has a stated layout to read or write. from_bytes$(T) requires the length to match exactly; any other length is a runtime error naming the type and both lengths. The byte order is little-endian on every host. §17.1b is the normative description.
There is no empty$(T) builtin. An empty() returning [$T] is an ordinary user-written generic, and empty$(int) is merely the name$(…) call form applied to it (§7.5).
29.9 Concurrency
The concurrency model — tasks, channels, and their ordering guarantees — is §20; this section catalogs only the builtins and their static rules. Task(T) and Channel(T) are affine, non-storable handle types with no type syntax (§5.3.9); the builtins below are their only consumers.
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
wait(t) | Task(T) -> T | magic | Join a spawned task and yield its result, deep-copied into the waiting scope. The argument MUST be a task variable or a spawn expression; exactly one wait per task. |
channel(T, cap) | -> Channel(T) | magic | Create a bounded channel of capacity cap (an int). Legal only as the direct RHS of a declaration (ch := channel(T, cap)); any other position is a compile error. |
send(ch, v) | (Channel(T), T) -> void | magic | Deep-copy v into the channel; blocks when full; aborts if the channel is closed. |
recv(ch) | Channel(T) -> Option(T) | magic | Blocking receive (deep-copied out); None means the channel is closed and drained. |
close(ch) | Channel(T) -> void | magic | Close a channel: receivers drain then see None. Also close(h) on a handle variable (§25): run its destructor early and suppress the scope-exit free. |
ncpu() | -> int | Sig | The number of online CPUs, or the value of TYCHO_THREADS when that variable is set to an integer ≥ 1. This is the *requested* worker count, not the width a parallel for will actually use: the chunk count is min(ncpu(), N) and an implementation MAY cap it further — the reference caps it at 64 (§22). A program that sizes a buffer or a work split from ncpu() MUST NOT assume that many chunks run. |
send, recv, and close also have method-sugar forms on a channel-typed local (ch.send(v), ch.recv(), ch.close()), rewritten to the free-call forms; wait likewise as t.wait(). close is overloaded across a channel and an FFI handle; ncpu is the sole Sig builtin here.
29.10 Filesystem and time
All five are Sig builtins with fixed signatures. The file and directory builtins fail soft — an unopenable path yields the empty result rather than aborting.
| Builtin | Signature | Kind | Semantics |
|---|---|---|---|
read_file(path) | string -> string | Sig | The whole file as a string, or "" if it cannot be opened. |
write_file(path, s) | (string, string) -> bool | Sig | Write s's exact bytes, truncating; false if it cannot be opened. |
list_dir(path) | string -> [string] | Sig | Entries excluding . and .. (filesystem order); empty if the directory cannot be opened. |
clock() | -> int | Sig | Monotonic nanoseconds — differences are meaningful; the absolute value is not. |
now() | -> int | Sig | Wall-clock seconds since the UNIX epoch. |
29.11 Float math (libm)
The float-math builtins are the entire libm surface exposed as language builtins — exactly the four below, all Sig, all over float:
| Builtin | Signature | Kind |
|---|---|---|
sqrt(x) | float -> float | Sig |
pow(x, y) | (float, float) -> float | Sig |
floor(x) | float -> float | Sig |
fabs(x) | float -> float | Sig |
Other numeric functions — min, max, clamp, and the trigonometric functions — are not builtins; they are provided by the corelib (§31) and require an import.
29.12 Abnormal termination
Tycho has exactly two user-callable process terminators, and both MUST NOT return:
| Builtin | Status | Writes | Use |
|---|---|---|---|
die(msg) | always 1 | msg + "\n" to stderr | an abort with a reason |
exit(code) | code | nothing | an ordinary answered exit, including exit(0) |
exit(code) passes code to the host's process-exit facility, so on POSIX only the low 8 bits are observable by the parent (wait(2)), exactly as in C. Buffered stdout written before the call is flushed. exit exists because die cannot answer --help: a program that has done what it was asked needs status 0, and routing that through die would report failure. There is no assert, panic, or abort builtin — no such name is registered in register_builtins and none is special-cased in resolve_expr (verified by searching ).
29.12.1 Both are DIVERGING calls
die and exit are declared -> void (Tycho has no bottom type), but the compiler additionally models them as diverging: control does not leave the call. A conforming implementation MUST therefore accept either one as the tail of a value if/match branch, even though the branch produces no value:
import "core:net"
host := "127.0.0.1"
port := 8080
_srv := match net.listen(host, port):
Ok(fd): fd
Err(e): die("cannot bind")
A diverging tail takes no part in branch unification (§6.5) and is not rewritten into an assignment to the destination — it stays the plain statement it is. If every branch diverges there is nothing to bind and the declaration is rejected; write the if/match as a statement instead.
Divergence is recognized by name, and that is sound rather than convenient: die/exit are registered builtins, so a program that defines either name is rejected (error: 'die' is already defined) and the name cannot mean anything else. A qualified pkg.die or a call through a function *value* is not diverging.
The runtime does abort on its own for defined error conditions — an out-of-bounds index, a pop on an empty array, division or modulo by a zero *value*, a split on an empty separator, a send on a closed channel, and the other cases enumerated in §30. Those aborts are internal: they are emitted by the runtime, not exposed as callable functions, and a conforming program cannot invoke them directly. This is the language's fail-closed posture (§1.3) — abnormal conditions terminate rather than proceed into undefined behavior.