TYCHO Search

Search documentation

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

    5. Types

    Every Tycho expression has a type, determined at its own site by the inference rules of §6. Every value has value semantics: assignment, argument passing, and return copy the value deeply, so two variables never share storage (§9). This chapter defines the types themselves — their values, ranges, and identity — and defers their construction and operations to the chapters noted.

    Tycho is defined with exact scalar widths independent of any compilation target (§1.1). A conforming implementation MUST provide the widths and behavior stated here even where its backend's native types differ (for example, a C backend MUST realize int as a 64-bit type even on a target where C long is 32 bits).

    5.1 The type-identity model

    Two types are the same type iff they are identical (the reference implementation interns every type to a single integer id and compares by equality). Type constructors fall into two groups:

    This distinction is normative: it determines exactly when two values may be compared, assigned, or unified across match/if arms (§5.5, §6).

    5.2 Scalar types

    5.2.1 int

    int is a 64-bit two's-complement signed integer, with values −2^63 through 2^63 − 1. This width is required, not target-dependent: a conforming implementation MUST realize int at exactly 64 bits through a fixed-width 64-bit lowering — a C backend MUST emit a fixed-width 64-bit type (int64_t / long long), never a type whose width varies by platform such as C long (§F.3, Appendix F). Arithmetic that overflows this range wraps with two's-complement semantics; overflow is fully defined and never traps (§30). Division and modulo by zero abort; (−2^63) / −1 aborts (quotient overflow) while (−2^63) % −1 is 0. The only integer literal is decimal (§3.9.1), denoting a non-negative value; −2^63 is obtained only by computation.

    5.2.2 float

    float is an IEEE-754 binary64 (double-precision) value. Its arithmetic, rounding, and special values (signed zero, infinities, NaN) follow IEEE-754. / is true division and does not trap.

    Division never traps: 0.0/0.0 is NaN, 1.0/0.0 is +inf, and -1.0/0.0 is -inf. NaN is unordered — NaN == NaN is false, and every ordering comparison with a NaN operand is false. The float *values* are fully defined by IEEE-754; only the *textual* form str produces for NaN/inf (e.g. -nan) derives from the C library and is implementation-defined (Appendix F).

    5.2.3 bool

    bool has the two values true and false. It is comparable with ==/!= and usable with str (via has_str), but is not ordered (§5.5).

    5.2.4 char

    char is a single byte, an integer value 0 through 255. It is written as a character literal (§3.9.3) and arises by inference; there is no char type keyword. char interoperates narrowly with int: char ± int has type char, and the result wraps to a byte (0..255, like u8) so the value never escapes the type's range. char is comparable and ordered (§5.5). str(char) yields the one-byte glyph string (so a char interpolates in an f-string); to_int(char) / to_u32(char) yield the byte value; to_char(n) converts the other way, aborting rather than wrapping for an n outside 0..255 (§29.4).

    There is no char type keyword, and its absence is normative — it is restated at §3.6 and §4.2. Every char-valued expression therefore reaches its type by inference: a character literal, char_at, to_char, or arithmetic on those. char's operator set is narrower than int's (+ and - only, §13.2).

    Indexing a string does not produce a char: s[i] yields int (§5.2.5). The char-typed reader is the builtin char_at(s, i) (§29.5), so char_at(s, 1) == 'e' is the way to compare a string's byte against a character literal — s[1] == 'e' is a type error, because int and char are distinct types and Tycho does not compare across types (§13.2).

    5.2.5 string

    string is an immutable, length-counted, byte-safe sequence of bytes. Its length is stored explicitly, so interior NUL bytes are preserved and all length-sensitive operations (comparison, concatenation, search, indexing) use the stored length, not a NUL terminator. Indexing s[i] yields the i-th byte as an int in 0..255 and aborts if i is out of bounds; a string is not assignable through an index (s[i] = v is a compile error). Operations are detailed in §16.

    s[i] yields int, not char. The builtin char_at(s, i) (§29.5) reads the same byte with the same out-of-bounds abort but with static type char, so char_at(s, i) == 'e' is the supported spelling and to_int(char_at(s, i)) == s[i] holds for every in-range i.

    Rationale: an int result avoids silent wrapping in arithmetic and indexing.

    5.2.6 bytes

    bytes is an immutable binary buffer — the same length-counted, byte-safe representation as string but a distinct type. It is produced by to_bytes from a string (a zero-cost reinterpret) or from an [int] of byte values (each truncated & 0xFF; §8) — the latter being the only way to construct a binary bytes with interior 0x00 bytes. There is no bytes literal. bytes exists primarily to cross the FFI boundary as a (pointer, length) pair (§24).

    Operators. bytes supports exactly the operator set string does, and with the same meanings, because it is the same buffer:

    FormResultNotes
    len(b)intbyte count, from the length header (O(1))
    b[i]intthe i-th byte as 0..255, never a 1-length bytes — the same read as s[i] (§5.2.5), same out-of-bounds abort, and not a place (b[i] = v is a compile error)
    b[i:j]bytesa fresh sub-buffer; clamps out-of-range bounds exactly as a string slice does (§16.6)
    a + bbytesconcatenation
    b + 'c'bytesappends the char's single byte; one-directional, like string + char (§13.2)
    a == b, a != bboolbyte-wise, by the length headers

    A bytes slice is not a bounds check. The clamp in the b[i:j] row is silent: on a 5-byte bytes, b[2:10] is three bytes and b[7:9] is zero, and neither form reports that the requested range did not exist. An array slice written the same way aborts instead — [1,2,3,4,5][2:10] dies tycho: slice [2:10] out of bounds (len 5) (§16.6). The two MUST differ: a bytes or string slice is exactly substr (§29), the function form, which clamps by definition, while an array slice has no such equivalent. A program therefore MUST NOT infer from a slice that returned normally that its bounds were in range. Where the range is itself the thing being verified — a parser reading a fixed-width field, say — strings.slice_bytes(b, start, stop) and strings.slice_str(s, start, stop) (§32.4) take the same two bounds and return Result(_, SliceErr), refusing what the clamp would have repaired.

    There is no implicit conversion between string and bytes in either direction: b + "s" and s + b are type errors. The boundary is crossed only by to_bytes / to_str, both zero-cost reinterprets (§8). Arithmetic other than + is a type error naming this set. bytes has no other operators: iteration (for x in b) and in are not provided.

    Constructing a bytes from computed byte values — including an interior 0x00, which a string cannot hold — reading bytes back out by index, taking a sub-buffer, and reinterpreting to string (the two share the length-counted buffer):

    fn main():
        b := to_bytes([72, 105, 0, 255])     # 'H' 'i' NUL 0xFF; each element & 0xFF
        s := to_str(b)                        # reinterpret: same buffer, byte-safe
        println(str(len(b)))                  # 4 — the interior NUL is preserved
        println(str(s[0]) + " " + str(s[2]) + " " + str(s[3]))
        println(str(b[2]) + " " + str(len(b[1:3])) + " " + str(b[1:3][1]))
        j := b + to_bytes([33]) + '?'        # bytes + bytes, then bytes + char
        println(str(len(j)) + " " + str(j[4]) + " " + str(j[5]))
    
    4
    72 0 255
    0 2 0
    6 33 63
    

    5.2.7 Fixed-width integers u8/u16/u32/u64, i8/i16/i32/i64

    The fixed-width integer family gives unsigned (u8, u16, u32, u64) and signed (i8, i16, i32, i64) integers of exactly 8, 16, 32, and 64 bits. Each is a distinct type from int and from every other; they do not mix implicitly with int or with each other. Arithmetic wraps at the type's width (defined — modular for the unsigned types, two's-complement for the signed via -fwrapv). The right shift >> is logical on the unsigned types and arithmetic (sign-preserving) on the signed; a shift count ≥ the width yields 0 and a negative count aborts (§13.2); division and modulo by zero abort. A value is produced by the matching to_u8 … to_i64 conversion or by adapting an integer literal in that type's context (§8). int (64-bit signed) remains the default for general arithmetic; the fixed-width types are for bit-level work, packing, and matching a C ABI. They cross the FFI boundary as their exact C width (§24).

    5.2.8 f32

    f32 is an IEEE-754 binary32 (single-precision) value, distinct from float. It is produced by to_f32 or by adapting an integer or float literal in an f32 context (§8); it promotes to float (binary64) for str.

    5.2.9 ptr

    ptr is an opaque FFI pointer (void*). Tycho never dereferences it; it supports only the null literal, being passed to and returned from extern functions, null-comparison, and is_null (§24).

    5.3 Composite types

    The type-level definition of each composite is given here; its construction, indexing, mutation, and place semantics are specified in Part VI (§16–§19) and its arena behavior in §9–§11.

    5.3.1 Arrays [T]

    [T] is a growable, value-semantic array of elements of type T; T may be any type. Two array types are the same iff their element types are the same. An empty array literal requires an element type ([]int). Indexing is bounds-checked (out-of-bounds aborts). void is not permitted as a bracket-array element type in a type position. bool is permitted as a dynamic element — [bool] is a supported array type — and is rejected only in the inline fixed-capacity forms [N]T, [$N]T and bounded[N]T, which have no bool codegen.

    5.3.2 Fixed-size arrays [N]T

    [N]T is a fixed-size array of exactly N elements, stored inline and copied by value. N is a positive integer literal or an int const; it is part of the type, so [3]int and [4]int are distinct types and len is the compile-time constant N. Indexing, index assignment, ==, value copy, str and for … in iteration are all supported. push, pop and slicing are not, and MUST be rejected at compile time — an inline array has no separate buffer to grow or to take a view of. The generic form [$N]T is a const generic (§7.4).

    5.3.3 Tuples

    A tuple (T1, …, Tn) is an anonymous product of 2 to 8 elements, identified structurally. Its elements are accessed by position (t.0, t.1) and are assignable places. Multiple return values and destructuring use tuples (§17).

    A tuple is the shape for "a value AND a classification." Since §6.2(7) a tuple literal's elements are checked against the declared element types, so a Result element may be written inline (return (Err(Timeout), buf)) without a typed local.

    Rationale: a tuple needs neither a caller-declared out-parameter nor a nominal wrapper type per call site.

    # The value and the verdict leave together; nothing is a sentinel.
    fn parse_port(s: string) -> (int, bool):
        if len(s) == 0:
            return (0, false)
        n := 0
        for i := 0; i < len(s); i += 1:
            d := s[i] - 48
            if d < 0 or d > 9:
                return (0, false)
            n = n * 10 + d
        if n > 65535:
            return (0, false)
        return (n, true)
    
    fn main():
        p, ok := parse_port("8080")
        println(str(p) + " " + str(ok))
        q, bad := parse_port("80x0")
        println(str(q) + " " + str(bad))
    
    8080 true
    0 false
    

    Examples in the corelib use this shape: strings.split_once, path.split_path, datetime.parse_offset, bignum.divmod, and httpd.read_request_capped. For a C function that must classify a payload the FFI has its own shape, since a tuple does not cross the boundary — see §24.1.1.

    5.3.4 Structs

    A struct is a nominal product type with named fields, constructed positionally in field-declaration order. A field may be recursive only through a container (e.g. children: [Node]); a direct by-value self-field (next: Node or next: Option(Node) directly) is rejected, because an inline self-embedding would be infinitely sized (§17). The same rule applies to a *cycle* of by-value fields (A holding a B that holds an A), which is rejected with the same infinite-type diagnostic.

    Type declarations are order-independent within a file, as functions are: a field may name a struct, enum, type or handle declared further down.

    5.3.5 Maps [K: V]

    A map [K: V] associates keys of type K with values of type V. The value type V is unrestricted (any type). The legal key types are exactly:

    All other key types are rejected, including a bare float, bool, char, or bytes key, an enum variant carrying a payload, and a map-typed key. Note that char and bytes are hashable as composite *leaves* but are not permitted as a top-level key type.

    Map operations (m[k] as a place, absent-key read yielding the value's zero, k in m, delete m[k], m.get) are specified in §18.

    5.3.6 Enums, Option, Result

    An enum is a nominal sum type; each variant is uniquely named within its package (§19.1 is normative for the scoping rule) and may carry a payload of up to 8 types. Option(T) is the built-in enum with variants Some(T) and None; Result(T, E) is the built-in enum with variants Ok(T) and Err(E). Enums are consumed by exhaustive match and support or_return for Option/Result (§19). Recursive payloads are permitted (they are arena-allocated, hence finite).

    Result(void, E). A Result's ok payload MAY be void, for an operation that either fails with a reason or succeeds with nothing to report. void is a type with no values, so:

    void is spellable in no other position, and the permission does not nest: Result(Option(void), E), Result([void], E) and Result((void, int), E) are all rejected, as is a void parameter, field, element or return annotation.

    fn touch(x: int) -> Result(void, string):
        if x < 0:
            return Err("negative")
        return Ok()
    
    fn chain(x: int) -> Result(void, string):
        touch(x) or_return          # propagate the error; bind nothing
        return Ok()
    
    fn report(r: Result(void, string)) -> string:
        match r:
            Ok:                     # no binding: there is no value here
                return "done"
            Err(e):
                return e
    

    Ok(1) at that return type is rejected — Result(void, string) carries no ok value -- write Ok(), not Ok(x) — and so is an Ok(v) arm against it.

    5.3.7 soa

    soa [S] is a struct-of-arrays collection whose element type S MUST be a struct; it presents the same value-semantic array interface while storing each field in its own backing array (§17).

    5.3.8 Function types

    fn(P1, …, Pn) -> R is a first-class function type (up to 8 parameters), identified structurally. A parameter type may not be void. A function *value* is a closure over captured state (§15); function values are not comparable (§5.5). A function that has an inout parameter cannot be used as a first-class value.

    5.3.9 Typed handles

    A handle type is a nominal, affine, opaque FFI resource — a void* with a declared C free function that runs at scope exit. A handle value cannot be copied, stored in any aggregate, captured by a closure or parallel for, or returned from a Tycho function (§25). The concurrency handle types Task(T) and Channel(T) are similarly affine and non-storable (§20).

    5.3.10 bounded[N]T

    bounded[N]T is an inline, fixed-capacity, variable-count collection: it holds between 0 and N elements of type T stored *inside* its container — never in a separate arena buffer — and is copied by value. It differs from [[N]T](#532-fixed-size-arrays-nt) in carrying a runtime element count, and from [[T]](#531-arrays-t) in that the count MUST NOT exceed the compile-time capacity N. The capacity is part of the type: bounded[4]int and bounded[8]int are distinct types, and both are distinct from [4]int and from [int].

    The capacity MUST be written either as a positive integer literal or as the name of a positive int const — the same two spellings a fixed-size [[C]T](#532-fixed-size-arrays-nt) accepts (§5.3.2). A capacity of 0, a non-integer literal, an absent capacity, a negated literal (-1 is a unary minus applied to a literal, not a literal), a name that is not a const, and a const whose value is not a positive integer MUST all be rejected.

    The element type T MUST NOT be bool or void, mirroring the bracket-array element-type restriction of §5.3.1. An affine handle type — including Task(T) and Channel(T) — MUST NOT appear as a bounded element, because a handle cannot be stored in any aggregate (§5.3.9). Every other type is a valid element, including an aggregate one: a struct, a tuple, a map, a soa, an Option, a Result, bytes, a fixed-size array [N]E, a dynamic array [E], a function type, an enum, and a nested bounded[M]E. A bounded holding an aggregate is valid in every stored position — a local, a parameter, a struct field and a return type.

    Because a bounded[N]T stores its elements inline, T MUST be a type of known size at that point: a type whose own definition reaches back to the bounded that contains it (struct Node: kids: [2]Node) is an infinite type and MUST be rejected, exactly as a directly self-containing struct is (§5.3.2 applies the same rule to [N]T). Use a dynamic array or an Option for the indirection.

    len yields the runtime count, not N. push appends and, when the collection is already full, aborts the program rather than growing; that trap is the point of the type. An array literal longer than N is rejected at compile time. Indexing, index assignment, ==, value copy, str, and for … in iteration behave as they do for a fixed-size array. pop, slicing, and reserve MUST be rejected on a bounded value.

    5.3.11 vector[N]T

    vector[N]T is a fixed-size array whose arithmetic is defined to lower to a single machine vector operation rather than to a loop over its lanes. In every other respect it behaves as a [[N]T](#532-fixed-size-arrays-nt): it is an inline value of exactly N elements, copied by value, initialised from an array literal, indexed and index-assigned, compared with == lane by lane, and len yields N.

    The count N MUST be written either as an integer literal or as the name of an int const, and its value MUST be a power of two between 2 and 64. A count of 1 is a power of two and is nevertheless rejected: a one-lane vector is a scalar under another spelling. The count is part of the type, so vector[4]float and vector[8]float are distinct types, and both are distinct from [4]float.

    A width no target register holds is not an error: an implementation splits it across as many registers as the machine has. vector[8]float is legal on a machine with 128-bit registers.

    The element type T MUST be int, float or f32; a type parameter $T is not in it, so a vector never appears in a generic template's own signature.

    Rationale: every admitted type must survive lane-wise application by one operator.

    Element-wise arithmetic (§16.8) applies with the same operator set a [N]T has, including the scalar broadcast and its operand order. A vector and an array of the same element type and length are distinct and MUST NOT be mixed in one element-wise expression, even though they hold the same values.

    Integer / and % keep the runtime divide guard of §16.8 and are therefore evaluated lane by lane; every other admitted operator is a single vector operation.

    core:math's min(a, b), max(a, b) and clamp(x, lo, hi) accept a vector in place of their scalar $T: every argument MUST be the same vector type, and the result is that type. They are lane-wise, and each lane is the value the scalar function gives for that lane: min is a where a < b and b otherwise, max is a where a > b and b otherwise, and clamp is lo where x < lo, else hi where x > hi, else x -- so with lo > hi a lane below lo is lo, exactly as the scalar is. Each is a whole-vector compare and select, not a loop over the lanes. Their where comparable(T) names the scalar domain and is not applied at a vector: every admitted element type is ordered. A vector is otherwise not comparable, so no other comparable(T) generic accepts one.

    Lanes MAY be swizzled: v.(0, 1) = v.(1, 0) names two of them at once and assigns simultaneously (§17.5). A vector of four lanes or fewer also names them .x.y.z.w, or .r.g.b.a for the same four, wherever a field access is accepted.

    fn main():
        a: vector[4]float = [1.0, 2.0, 3.0, 4.0]
        b: vector[4]float = [10.0, 20.0, 30.0, 40.0]
        c := a * 2.0 + b
        println(str(len(c)) + " " + str(c[0]) + " " + str(c[3]))
    
    4 12.0 48.0
    

    5.4 Newtypes

    TypeDecl ::= "type" IDENT "=" Type NEWLINE
    

    A newtype type X = U introduces a distinct type X over an underlying type U. U MUST be one of: int, float, string, bool, an array type, a map type, or a struct type. It MUST NOT be an enum, a tuple, a sized numeric (u8…u64/i8…i64/f32), char, bytes, ptr, an Option/Result, a function type, a soa, a channel, a task handle, a typed handle, or another newtype. The permitted list is closed, so a shape named in neither list — soa was one — is refused by the same rule.

    X is type-incompatible with U and with every other newtype: passing a U where X is expected, or mixing X with U in arithmetic, is a compile error. The distinctness is enforced only in the type system; a newtype is erased in lowering (an X over float is represented exactly as a float, at zero cost). Arithmetic, ordering, ==, and str on a newtype are permitted only between two values of the *same* newtype, and the result keeps the newtype. Unwrapping to the underlying value uses the base-specific to_int/to_float/ to_str/to_bool or the generic to_under (§8). A newtype over int or string is a valid map key carrying its wrapped identity (§5.3.5).

    5.5 Equality and ordering

    Equality (==, !=). Two values may be compared iff they have the same type (§5.1); void values are not comparable. Equality is structural and deep: scalars compare by value; string, bytes, arrays, structs, tuples, maps, enums, Option, and Result compare by content, recursing through nesting — so a == b holds exactly when b is an independent deep copy of a. Function values are not comparable: applying ==/!= directly to a function value is a compile error. Compare the values a function produces instead.

    Rationale: closures with different captured environments but identical behavior are indistinguishable. A function stored *inside* an aggregate does not block that aggregate's equality: the aggregate still compares structurally, and a function field within it compares by identity. Comparing values of two different types is a compile error.

    Ordering (<, >, <=, >=). Both operands MUST have the same type, and that type's underlying scalar MUST be int, float, char, string, or a sized numeric (u8, u16, u32, u64, i8, i16, i32, i64, f32) — or a newtype over one of these. Structs, tuples, arrays, maps, enums, and bool are not ordered. String ordering is byte-lexicographic.

    One asymmetry follows and is intentional: bool is comparable and str-able but is not ordered. (char is comparable, ordered, and str-able — its str is the one-byte glyph.)