Types
Memory: Newtypes cost nothing at runtime, and every value — scalar or aggregate — copies deeply on assignment.
Tycho is statically typed, and every value has a type known at compile time. This page covers the scalar types, the bytes buffer, distinct type newtypes, and how inference lets you leave most annotations off. The compound types — arrays, structs, maps, tuples, enums — each have their own page.
Scalars
| Type | Values |
|---|---|
int | 64-bit signed integer |
float | 64-bit IEEE-754 double |
bool | true / false |
string | an immutable, length-counted byte string |
char | a single byte (0–255) |
u32 / u64 | 32- / 64-bit unsigned integer, wraps at 2³² / 2⁶⁴ |
f32 | 32-bit IEEE-754 single-precision float |
int and float never mix implicitly — there is no automatic widening. Convert explicitly with to_float(n) and to_int(x) (the latter truncates toward zero). The operators each type supports are in Basics.
u32 / u64 / f32
Fixed-width numerics for bit-twiddling, hashing, and crypto (corelib/sha256 is written in u32). They behave like int/float but at a defined width:
- Wrap is defined.
u32/u64arithmetic (+ - * << ~…) wraps modulo 2³²/2⁶⁴ — no masking needed.>>is a logical shift;/and%abort cleanly on a zero divisor. - Distinct and non-mixing, like
char. Au32never silently becomes anint. Bridge explicitly withto_u32(x),to_u64(x),to_f32(x),to_int(x),to_float(x)(each takes any numeric scalar). A bare int literal adapts to au32/u64operand and a numeric literal tof32, soh ^ 16777619andx & 255need no cast on the constant; a non-literalintdoes (state[i]→to_u32(state[i])). - Arrays (
[u32],[f32], …), struct fields,str(...), and f-strings all work. - They also cross FFI as their real C type (
u32↔uint32_t, etc.).
char
A char is one byte, written with a quoted literal — 'x', with the escapes \n \t \r \0 \\ \' and \xNN (exactly two hex digits, either case: '\x41' is 'A'). That set is not the one a string literal takes. It interoperates with int deliberately and narrowly:
char ± intis achar(a byte offset —'a' + 1is'b'). The result wraps to0..255, likeu8:'A' + 255wraps to'@'(64).string + charappends the byte in place, without allocating, so building a string one character at a time is a byte-write, not an allocation per character:
s := ""
for d := 0; d < 10; d += 1:
s = s + ('0' + d) # zero-allocation one-byte append per digit
char does not silently become int elsewhere — str(c) and c == n (mixing char and int) are type errors, so the byte/number distinction is never lost by accident.
bytes
bytes is an immutable binary buffer. Like string, it is length-counted and can carry interior \0 bytes; the difference is intent and API contract — bytes is the binary-safe choice for hashing, crypto and binary I/O. to_bytes(s) and to_str(b) bridge the two; to_bytes(xs) over an [int] (each element & 0xFF) builds a binary buffer from computed bytes. bytes also crosses the FFI boundary as a (pointer, length) pair.
It takes the same operators as string, with the same meanings:
| Form | Result | Notes |
|---|---|---|
len(b) | int | byte count, O(1) |
b[i] | int | the byte as 0..255; out of bounds aborts; not assignable |
b[i:j] | bytes | a fresh sub-buffer; out-of-range bounds clamp silently, like a string slice |
a + b, b + 'c' | bytes | concatenation; appending a char adds its one byte |
a == b, a != b | bool | byte-wise |
Because the slice clamps, a slice that returned normally does not prove its range existed. When that is the thing being checked, use strings.slice_bytes(b, start, stop), which returns an Err instead (spec §5.2.6).
String escapes
A string literal takes exactly five escapes; anything else is a compile error that names the set — unsupported escape \q (use \n \t \r \\ \").
| escape | byte | |
|---|---|---|
\n | 10 | newline |
\t | 9 | tab |
\r | 13 | carriage return |
\\ | 92 | backslash |
\" | 34 | double quote |
There is no \0, \', \a, \b, \f, \v, \e, \xNN or \uNNNN. A single quote needs no escape — "it's" is a valid literal. For any other byte, build it: chr(27) is a one-byte string holding ESC, and to_bytes(xs) over an [int] is the way to a bytes with interior NULs. An f"..." lexes the same five and rejects the same rest. A char literal is a different set — it takes \0, \' and \xNN, and refuses \".
fn main():
println("tab:\there")
println("quote:\" backslash:\\")
println(str(len("a\rb")))
tab: here
quote:" backslash:\
3
String interpolation
An f"..." string interpolates {expr} holes, desugaring to ordinary concatenation — f"point=({p.x},{p.y}) sum={a + b}" becomes "point=(" + str(p.x) + .... A plain "..." is never interpolated, so it needs no brace escaping; inside an f"...", {{ and }} are literal braces. A hole may hold any expression — including one with its own string literals or a nested f-string — and must evaluate to a value str accepts: an int, a u32/u64/f32, a float, a bool (printed true/false), or a string.
Distinct newtypes (type)
type Meters = float declares a distinct type: same runtime representation as its underlying type (zero cost — a Meters *is* a double in the generated C), but type-incompatible with float and with every other newtype. There is no transparent alias: type always declares a distinct type.
type Meters = float
type Seconds = float
fn area(w: Meters, h: Meters) -> Meters:
return w * h # arithmetic stays in Meters
fn main():
w := Meters(3.0)
a := area(w, Meters(4.0))
print(str(a)) # 12.0 -- str sees the underlying float
# area(3.0, 4.0) -> error: a float is not a Meters
# w + Seconds(1.0) -> error: can't mix two newtypes
A newtype supports its base type's arithmetic, ordering, ==, and str — but only between two values of the *same* newtype, so Meters and Seconds never mix by accident. That is the point: zero-cost unit and ID safety. Construct one with Meters(x); recover the raw value with to_int / to_float / to_str / to_bool per the underlying scalar.
The underlying type may also be an aggregate — type Ids = [int], type Env = [string: int], type Pos = Pt (a struct). Wrap with Ids(v), unwrap with the generic to_under(x) (zero-cost, works on any newtype), and value semantics carries through the wrapper unchanged. A newtype over string or int is also a valid map key.
Type inference (bidirectional)
A local infers forward from its initializer — x := e gives x the type of e. In the other direction, every position with a known destination type — declarations, assignments, call arguments, return, stores, literal elements — *checks* the expression against it. This local inference lets the annotation-light forms work:
import "core:iter"
fn tally(m: [string: int], extra: [int]) -> int:
return len(m) + len(extra)
em := []string: int
f := 1.5
xs : [int] = [] # bare [] takes the expected array type
_n := tally(em, []) # ...in argument position too
_g := f + 2 # an int literal adapts to a float context (f : float)
_doubled := iter.map(xs, fn(x: int) -> int: x * 2) # lambda params from the expected fn type
ys := [] # a bare decl stays pending...
push(ys, 3) # ...until its first grounding use types it ([int])
Each line is typed on its own — from its parts, or against the destination it feeds — so errors stay local. The cost is that a value with *no* way to determine its type is rejected rather than guessed — a bare x := None or x := [] with no grounding use is a compile error; annotate it. Function signatures are always explicit: they are the module's interface and the inference's seeds.