Structs, Enums & Unions
Four shapes are covered here, and they answer four different questions: how do I group fields (struct), how do I name a fixed set of options (enum), how do I track a handful of yes/no flags (bit_set), and how do I say "one of these types, but I am not sure which" (union).
Grouping Data with struct
A struct is a bundle of named fields stored together. It is the most common way to describe anything in Odin, from a two-dimensional point to the entire state of an application.
Defining a Struct
Fields that share a type are grouped, exactly as in variable and parameter declarations, and the fields are separated by commas:
// Two numbers that belong together.
Vector2 :: struct {
x, y: f32,
}
// Fields of different types, one per line for readability.
Player :: struct {
name: string,
health: int,
score: f64,
}
// A struct can contain another struct — this is how real models grow.
Rectangle :: struct {
origin: Vector2,
size: Vector2,
}
Notice the :: again: a struct definition is a compile-time entity, like a constant or a procedure. Defining one costs nothing at runtime — it is a description of memory, not memory itself.
Building Values
There are two ways to build a struct value, and one of them is almost always better:
a := Vector2{1, 2} // positional: values in field order
b := Vector2{x = 1, y = 2} // named: field = value, in any order
// Nested structs use the same rules at every level.
box := Rectangle{origin = {x = 0, y = 0}, size = {x = 10, y = 5}}
fmt.println(a) // Vector2{1, 2}
fmt.println(b) // Vector2{1, 2}
Prefer the named form as soon as a struct has more than two or three fields. It survives somebody reordering the fields, it tells the reader what each value means, and the compiler checks the names for you. The positional form is fine for small things whose field order will never change, like a point.
Reaching the Fields
A field is reached with a dot and behaves like an ordinary variable — you can read it, assign to it, and use it in any expression:
p := Player{name = "Ada", health = 100}
fmt.println(p.name) // Ada
p.health -= 25 // fields are ordinary variables
fmt.println(p.health) // 75
One property deserves a demonstration, because it is where Odin differs from languages where objects are always references: a struct value is a value, so assigning one copies it.
q := p // the whole struct is copied
q.health = 1 // only the copy changes
fmt.println(p.health) // 75 — p is untouched
fmt.println(q.health) // 1
This is a real difference from slices, maps, and dynamic arrays, which are references. We meet that rule in full in the next lesson; for now, hold on to this: two struct variables never secretly share their fields.
Zero by Default, Again
You met the zero-value guarantee in the types lesson. It applies to structs too, and it is what makes them safe to declare without writing a single value:
// Not one value given, and yet every field is valid.
blank: Player
fmt.println(blank.name) // "" — an empty string
fmt.println(blank.health) // 0
fmt.println(blank.score) // 0.0
// #assert checks the layout at compile time — free documentation.
#assert(size_of(Vector2) == 8) // two f32 fields, four bytes each
Because Odin has no constructors, there is no half-built object and no hidden setup step. A struct value is its fields; if a field holds nonsense, someone put the nonsense there.
Sharing Fields with using
Sometimes a struct is mostly another struct with a little extra. Odin's using prefix lets the inner fields behave as though they belonged to the outer one, which removes a layer of dots from your code.
Field Promotion
Mark a field with using and its fields are promoted — reachable directly, as if they were declared on the outer struct:
Pos :: struct {
line, col: int,
}
Token :: struct {
using pos: Pos, // the `using` prefix
text: string,
}
t := Token{pos = {line = 3, col = 12}, text = "hello"}
// Reach through the field explicitly...
fmt.println(t.pos.line) // 3
// ...or directly, because `using` promoted the fields.
fmt.println(t.line) // 3
fmt.println(t.col) // 12
fmt.println(t.text) // hello
That is genuinely useful for values that share a common core: a token has a position, an entity has a transform, an event has a timestamp. Without using you would write t.pos.line everywhere; with it, the common fields read as though they were part of the outer type.
using as Subtyping
Promotion has a second consequence, and it is the closest Odin comes to inheritance — without any of inheritance's machinery. Because the promoted fields are reachable, a value of the outer type can be used where the inner type is expected:
// A procedure that only cares about a position.
render :: proc(p: Pos) {
fmt.printfln("line %d, column %d", p.line, p.col)
}
t := Token{pos = {line = 3, col = 12}, text = "hello"}
render(t) // line 3, column 12 — passing the Token where a Pos is wanted
Use this sparingly and honestly. It fits when the outer type genuinely is a thing of the inner kind — a token really is something with a position. It is not a mechanism for building class hierarchies, and trying to use it as one will produce code that is harder to follow than plain composition.
Enumerations
An enum names a fixed set of options. Reach for one whenever a value can only ever be one of a known list: a compass direction, a connection state, a suit of cards.
Naming a Set of Options
Nowhere in this page will you write the number three to mean "South". That is the entire point of an enumeration:
// A closed set: these four, and nothing else.
Direction :: enum {
North,
East,
South,
West,
}
// Enum members are written with a leading dot when the type is known.
heading: Direction = .North
fmt.println(heading) // North
Compared with a bare int, an enum buys you two things. The valid values are written down in one place for the reader, and switch over an enum is checked for exhaustiveness — the self-maintaining checklist you met in the control-flow lesson.
switch heading {
case .North:
fmt.println("going up the map")
case .South:
fmt.println("going down the map")
case .East, .West:
fmt.println("going sideways")
}
Values and Ordering
Members are numbered from zero upwards unless you say otherwise, and Odin can tell you how many there are. That count is a compile-time constant, which makes it useful in assertions and array sizes:
// The default numbering: 0, 1, 2, 3, 4.
Grade :: enum { A, B, C, D, F }
fmt.println(len(Grade)) // 5 — the number of members
// Explicit values, when the number itself carries meaning (HTTP status codes).
Http_Status :: enum {
OK = 200,
Forbidden = 403,
NotFound = 404,
}
Members compare with == and !=, and they also have an order — the order you declared them in. That is a design opportunity rather than a detail: declare Grade from best to worst, and any future sorting or "is this at least a B?" comparison will mean what a reader expects.
Enum-Indexed Tables
An array can be indexed by an enum, which turns a lookup into a table and removes the branching entirely. Name the index with a dot and the compiler knows which slot you mean:
Suit :: enum { Hearts, Diamonds, Clubs, Spades }
// One slot per enum member — the size comes from the enum.
symbols := [Suit]rune{
.Hearts = '♥',
.Diamonds = '♦',
.Clubs = '♣',
.Spades = '♠',
}
fmt.println(symbols[.Spades]) // the spade symbol
The idiom is worth remembering: a table indexed by an enum is often clearer than a switch, and it is noticeably faster — one indexed read instead of a chain of comparisons. The trade is that the compiler will not warn you about a slot you forgot to fill, so write the initialisers in enum order and keep the list visibly complete.
bit_set — Sets of Flags
In the operators lesson you packed flags into a u8 by hand, with names like 0b0000_0001. Odin has a type for exactly that pattern, and it reads far better.
Declaring a Bit Set
A bit set names the possible members and then holds whichever of them are true. The natural source of members is an enum:
Permission :: enum { Read, Write, Execute }
Permissions :: bit_set[Permission]
// A set literal names the members it contains.
perms: Permissions = { .Read, .Write }
fmt.println(perms) // Permissions{Read, Write}
Under the surface this is a small integer with one bit per possible member — so a set over eight options fits in a single byte, and every test or update is a handful of machine instructions. You no longer have to invent mask constants or remember which bit meant what.
Testing, Adding, and Removing
Membership uses the in and not_in operators you met in the operators lesson, and adding a member uses +, which reads surprisingly naturally:
perms: Permissions = { .Read, .Write }
// Ask whether a member is present.
fmt.println(.Read in perms) // true
fmt.println(.Execute in perms) // false
fmt.println(.Execute not_in perms) // true — the question the other way round
// Adding a member: "this set, plus that one".
perms = perms + { .Execute }
fmt.println(perms) // Permissions{Read, Write, Execute}
& and &~ have set meanings here. We will show those operations next to a picture of the bits in the data-oriented lessons, where the hardware reason for each one becomes visible. For now, membership and union are the two you will use constantly.
One last shape worth knowing: a bit set does not require an enum. A range of small integers works too, which is handy when the flags are numbered by a hardware specification:
// Eight flags, numbered 0 to 7 — no enum needed.
Flags :: bit_set[0..<8]
Unions
Sometimes a value can be one of several types and you only discover which at the moment you look. That is what a union expresses, and Odin's version is checked rather than scary.
One Value, Several Possible Types
A union declares the possibilities, and the compiler keeps track of which one is live:
// The value is either an int or an f64 — nothing else.
value: union{int, f64}
value = 42 // right now it holds an int
// Ask what it holds, and take the value out only if the answer is yes.
if i, ok := value.(int); ok {
fmt.println("it is an int:", i)
} else {
fmt.println("not an int")
}
That .(type) form is a type assertion, and it has the two-value shape you already know from map lookups — the value, and whether it was the type you asked for. When you would rather have a fallback than a branch, or_else says it in one line:
// "Give me the int if that is what it is, otherwise -1."
i := value.(int) or_else -1
fmt.println(i) // 42
Why the Compiler Is Careful Here
Internally a union reserves room for its largest member plus a small tag recording which member is currently stored. Odin tracks that tag, which is exactly why you must ask before taking a value out: reading a union as the wrong type is not a mistake the language allows you to make silently.
Odin also has a companion form used for optional values — a union that can also be "nothing" — which is how the standard library expresses "a value that may or may not be there". You will meet it in the errors lesson, together with the ? shorthand it shares its syntax with.
A Taste of Layout Control
Because Odin talks about memory honestly, a struct can carry directives that change how it is laid out. You will not need these for months — but seeing them now explains why this track keeps mentioning sizes and alignment.
Packed and Aligned
Fields are normally placed so that each one begins on a boundary its type prefers, which leaves small gaps of padding between them. Two directives let you override that, one for exactness and one for speed:
// #packed removes the padding: the struct occupies exactly the sum of its fields.
Header :: struct #packed {
tag: u8,
size: u32,
}
#assert(size_of(Header) == 5) // one byte plus four, with nothing between
// #align(N) makes the struct start on a boundary you choose —
// 64 bytes is one cache line on most modern processors.
Aligned_Block :: struct #align(64) {
data: [64]u8,
}
#packed exists because binary file formats and network protocols describe bytes, not structures with padding — when you read such a file, the layout must match exactly. The trade is real: an unaligned field can be slower to read, and some processors refuse it outright, so packing is a decision about the data you exchange rather than a general habit.
#align exists for the opposite reason: performance. When a hot data structure begins exactly on a cache-line boundary, the processor stops straddling two lines to reach it. In Phase 4 we will measure what choices like these actually cost and save, on real layouts, with real timings.
Where This Goes Next
You can now describe data: a single value, a bundle of fields, one of a closed set of options, a collection of flags. The next lesson is about holding many of something — the three container types Odin gives you, and the difference between them that matters most, which is which ones are copies and which are references.
The Page in One Breath
structgroups named fields together; fields sharing a type are grouped, and#assert(size_of(T) == n)documents the layout for free.- Build struct values positionally or by field name — prefer names beyond two or three fields.
- A struct is a value: assignment copies it, and two struct variables never secretly share fields.
usingpromotes a nested struct's fields, and lets the outer value stand in where the inner type is expected.enumnames a closed set of options;len(EnumType)counts the members, and an array can be indexed by the enum.bit_set[Enum]holds flags in one small integer: test within, add with+.union{...}holds one of several types at a time; ask withvalue.(Type)or supply a fallback withor_else.#packedand#align(N)control the byte layout — a decision about formats and performance, not a default.
Suit and a rank, build the table of suit symbols indexed by the enum shown earlier, then print a hand of five cards. Everything you need is on this page.
Continue with Arrays, Slices & Dynamic Arrays →