TYCHO Search

Search documentation

    ↑ ↓ to move · Enter to open · Esc to close

    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:

    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:

    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

    BuiltinSignatureKindSemantics
    print(s)string -> voidSigWrite s's bytes to stdout; no implicit newline.
    println(s)string -> voidSigprint(s) followed by a single "\n". String-only, like print; use println(str(x)) for a non-string.
    eprint(s)string -> voidSigWrite s's bytes to stderr; no newline, no exit.
    eprintln(s)string -> voidSigWrite s's bytes and one trailing newline to stderr; no exit.
    input()-> stringSigRead one line from stdin, with the trailing newline stripped.
    read_all()-> stringSigRead all of stdin as one string.
    args()-> [string]SigThe command-line arguments; args()[0] is the program name.
    getenv(name)string -> stringSigThe environment variable's value, or "" if unset.
    die(msg)string -> voidSigPrint msg to stderr and terminate the process with exit status 1; never returns (§29.12).
    exit(code)int -> voidSigTerminate 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.

    BuiltinRoleKind
    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_f32numeric -> 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.

    BuiltinSignatureKindSemantics
    len(s)string -> intmagicByte length. Overloaded (§29.6).
    substr(s, a, b)(string, int, int) -> stringSigA fresh copy of the byte range [a, b); out-of-range bounds are clamped, not an error.
    find(s, sub)(string, string) -> intSigByte index of the first occurrence of sub, or -1.
    char_at(s, i)(string, int) -> charSigThe 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]SigSplit 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.

    BuiltinSignatureKindSemantics
    len(a)[T] -> intmagicElement count. Also accepts string, bytes, a map, or a soa.
    push(a, v)([T], T) -> voidmagicAppend v in place; a MUST be a mutable array (or soa) place. Grounds a still-pending array type to [typeof(v)].
    pop(a)[T] -> TmagicRemove and return the last element; aborts at run time if empty. a MUST be mutable.
    reserve(a, n)([T], int) -> voidmagicCapacity 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.

    BuiltinSignatureKindSemantics
    keys(m)[K: V] -> [K]magicThe map's live keys as an array, for iteration; a newtype key stays wrapped.
    m.get(k)([K: V], K) -> VmagicValue 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) -> VmagicValue for k, or the default d if absent.
    hash(x)K -> int for any map-key type KmagicThe 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

    BuiltinSignatureKindSemantics
    zero$(T)-> TmagicThe 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.

    BuiltinSignatureKindSemantics
    size_of$(T)-> intmagicThe size of the packed or align(N) struct T in bytes (§17.1b).
    from_bytes$(T)(b)bytes -> TmagicRead 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.

    BuiltinSignatureKindSemantics
    wait(t)Task(T) -> TmagicJoin 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)magicCreate 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) -> voidmagicDeep-copy v into the channel; blocks when full; aborts if the channel is closed.
    recv(ch)Channel(T) -> Option(T)magicBlocking receive (deep-copied out); None means the channel is closed and drained.
    close(ch)Channel(T) -> voidmagicClose 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()-> intSigThe 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.

    BuiltinSignatureKindSemantics
    read_file(path)string -> stringSigThe whole file as a string, or "" if it cannot be opened.
    write_file(path, s)(string, string) -> boolSigWrite s's exact bytes, truncating; false if it cannot be opened.
    list_dir(path)string -> [string]SigEntries excluding . and .. (filesystem order); empty if the directory cannot be opened.
    clock()-> intSigMonotonic nanoseconds — differences are meaningful; the absolute value is not.
    now()-> intSigWall-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:

    BuiltinSignatureKind
    sqrt(x)float -> floatSig
    pow(x, y)(float, float) -> floatSig
    floor(x)float -> floatSig
    fabs(x)float -> floatSig

    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:

    BuiltinStatusWritesUse
    die(msg)always 1msg + "\n" to stderran abort with a reason
    exit(code)codenothingan 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.