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:
- Nominal —
struct,enum,newtype, andhandletypes are identified by their declaration. Two distinct declarations are distinct types even with identical structure; a newtype is distinct from its underlying type (§5.4). - Structural — arrays, fixed-size arrays, tuples, maps,
Option,Result,soa, function types, and the channel/task handle types are identified by their structure. Two of them are the same type iff they are built from the same constructor applied to the same component types.
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:
| Form | Result | Notes |
|---|---|---|
len(b) | int | byte count, from the length header (O(1)) |
b[i] | int | the 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] | bytes | a fresh sub-buffer; clamps out-of-range bounds exactly as a string slice does (§16.6) |
a + b | bytes | concatenation |
b + 'c' | bytes | appends the char's single byte; one-directional, like string + char (§13.2) |
a == b, a != b | bool | byte-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:
string;int;- a newtype whose underlying type is
intorstring; - a fieldless enum (hashed by its variant tag); and
- a composite (
struct, tuple, or array) all of whose leaf types are hashable — where a hashable leaf isint,float,bool,char,string,bytes, or (recursively) a struct/tuple/array of hashable leaves.
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:
- it is constructed by a zero-argument
Ok(), andOk(x)is an error; - it is matched by a bare
Ok:arm, the same shape a fieldless enum variant uses, andOk(x)as an arm is an error; - nothing can bind one:
x := f() or_returnon such aResultis rejected as binding a void value, and the statement formf() or_returnis what replaces it (§4.3.1); - a
Result's ERROR type may NOT bevoid—Erralways carries a value.
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.)