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.
| type | size_of |
|---|---|
| bool | 1 |
| i8 · u8 · byte | 1 |
| i16 · u16 · f16 | 2 |
| i32 · u32 · f32 · rune | 4 |
| i64 · u64 · f64 | 8 |
| int · uint · uintptr | 8 |
| cstring | 8 |
| string | 16 |
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:
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.
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.
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.
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.