Odin · types & printing

Types & printing, four levels deep

A type is a fixed number of bytes plus a meaning for them. i32 is "4 bytes, read as a signed integer." f32 is "the same 4 bytes, read as a float." The name fixes the width once, at compile time. Pick a type and watch its slot — and read its measured size_of.

Real widths, measured by size_of from a compiled Odin program. Cell count = the type's byte width.
bytes the value occupies the second word (pointer-sized)

The whole ladder

typesize_of
bool1
i8 · u8 · byte1
i16 · u16 · f162
i32 · u32 · f32 · rune4
i64 · u64 · f648
int · uint · uintptr8
cstring8
string16

What you're seeing

int is not a separate width — it's pointer-sized int and uint aren't a mystery fourth integer size; on this 64-bit toolchain size_of(int) == 8, the same as i64. They're the "natural word" — pick them for loop counters and lengths, and reach for a fixed width (i32, u8, u16…) when the byte count itself matters: a field on disk, a value in a tight array, a number with a hard range. byte is just another name for u8, and rune is a 4-byte integer that means "one Unicode code point."

Level 1 was what a type is. Level 2 is why it's worth pinning one down: the value carries its type, and nothing slips a number into a slot that can't hold it. No silent widening, no silent truncation — a value of the wrong type, or a constant out of range, stops the build.

A u8 holds 0–255: one byte, eight bits. Drag a value past that line and the byte physically can't represent it. Pick a number and see where it lands:

u8 capacity
0 … 255
the value

Caught at compile time, with the range named. A constant that doesn't fit never reaches the running program — the build stops and tells you the exact limit:

y: u8 = 300

Error: Cannot convert numeric value '300' from '300' to 'u8' from 'untyped integer'
    The maximum value that can be represented by 'u8' is '255'

And no value quietly changes type, either. An int does not flow into a u8 on its own — even if today's value would fit. You write the conversion, so a narrowing is always something you asked for, never something that happened behind your back:

x: int = 300
y: u8 = x          // no automatic narrowing

Error: Cannot assign value 'x' of type 'int' to 'u8' in a variable declaration

The fix is to say it out loud: y := u8(x) compiles — and now the narrowing is explicit. If x is 300, the conversion wraps it into the byte (u8(300) is 44), but that's a wrap you wrote, visible at the call site, not a surprise the machine handed you. The type stays honest because every step across a boundary is a step you typed.

The counterfactual: if every variable were the same untyped "number," none of these checks could exist — the compiler would have nothing to check the value against. Pinning a width is what gives the compiler a rule to enforce; the byte count is the contract. The few extra keystrokes of an explicit conversion buy you a class of bug that simply can't reach runtime.

declaring a type: := vs : T = a := 42 infers the type from the literal — an integer literal gives you int, so a is an int. b: f32 = 3.14 is the explicit form, for when you want a specific width rather than the default (a bare float literal would default to f64). Both forms still produce a fully typed variable — inference picks the type for you, it doesn't leave the value loose.

Levels 1–2 were about the bytes. Level 3 is how you look at them: the verb decides the rendering, not the value. One number, eight ways to show it — and the bits never move.

Here is a single int holding 255. Each button is a fmt.printf verb. The stored value is identical every time; only the way printf reads it out changes:

The workhorse is %v — "any value, default formatting." It reads the argument's type and picks a sensible rendering on its own, so it's the verb you reach for when you don't need a specific base or width. %d says "as a decimal integer," %x "as lowercase hex," %b "as binary," %c "as the character at that code point," and %q "as a quoted string." The value 255 is unchanged through all of them.

The same value, the same type, read differently: a rune holds the integer 65 for 'A'. Print it with %d and you get 65; with %c (or %v) you get A. The rune is one value; the verb chooses whether you see the number or the glyph.

A verb can ask for the impossible — and it's caught

Hand a verb an argument it can't render — %d (a decimal integer) on a string — and nothing goes undefined. fmt is type-aware: at run time it notices the mismatch and prints a loud marker naming exactly what went wrong, instead of misreading the bytes:

fmt.printf("%d\n", "hello")

%!d(string=hello)   // the verb wanted an int; it got a string, and says so

That marker reads as "the %d verb was misapplied to a string whose value is hello." The program keeps running; you get a flag you can't miss in the output rather than a silently wrong number.

The last level is the property that emerges from all of it: because every value is fully typed (L1–L2) and fmt reads that type (L3), the type travels with the value into the print. You can always print anything, and you can ask any value what it is.

fmt.println needs no verbs at all — it asks each argument's type how it wants to be shown and uses that default formatting, separating args with a space and ending the line. Five different types, one call:

a := 42       // int
b: f32 = 3.14  // f32
c := "ok"     // string
d := true     // bool
r := 'A'      // rune

fmt.println(a, b, c, d, r)
42 3.14 ok true A

And the type is readable back out. %T prints a value's static type — the answer the compiler settled on, recovered at the print. So you can confirm what inference gave you without guessing:

fmt.printf("%T %T %T %T %T\n", a, b, c, d, r)
int f32 string bool rune

That's the confirmation of every claim from Level 1: 42 really is an int, 'A' really is a rune, and inference never left anything loose.

a fresh value is its zero value, never garbage Declare a variable with no initializer and it isn't undefined — Odin zero-initializes by default. An int starts at 0, an f32 at 0, a bool at false, a string as the empty string "". So printing a just-declared value is always meaningful:
zi: int; zf: f32; zb: bool; zs: string
fmt.println("zero:", zi, zf, zb, zs == "")
zero: 0 0 false true

The emergent payoff: printing in Odin can't go undefined and can't lie about a type, because the type isn't a comment you keep in your head — it's attached to the value and the formatter reads it. %v prints whatever you hand it, %T reports what that was, and a mismatched verb gets flagged instead of misreading bytes. Debugging is "print it and look," with no ritual and no way for the print itself to mislead you.

That's the arc: L1 a type is a fixed byte width with a meaning → L2 that width is pinned and enforced — no silent narrowing, out-of-range constants rejected at compile time → L3 printing chooses a rendering of those bytes by verb, the value never moves → L4 the type rides along into the print, so you can show anything and ask anything what it is.

probes reproduce with odin run · sizes, verb outputs & the L2/L3 errors are real compiler output (claims/lessons/02-types-and-printing)