Odin · using & procedure groups

Two ways to flatten a name

using on an embedded struct field promotes the inner struct's field names one level up, into the outer struct's scope. Write e.x and the compiler reaches through to e.xform.x for you — same storage, two names. Pick a field below and watch which path it travels.

Field values, sizes & offsets measured from a compiled Odin program (claims promotion-access, promotion-sizes).
Entity
the outer struct — what you hold and access
Transform
embedded as using xform: Transform
promoted from Transform Entity's own field the named field (still there)

Layout (size_of)

Transform  = 12
Entity     = 24
plain struct = 24

using adds zero bytes — Entity is the same size as a struct that names xform with no promotion. xform at offset 0, hp at offset 16.

What you're seeing

it's a source rewrite, not a lookup e.x isn't a field on Entity — the compiler rewrites it to e.xform.x while parsing. The named field xform never disappears, so e.xform.x and e.x are the same bytes: writing e.x = 9 then reading e.xform.x gives 9, and writing e.xform.y = 7 then reading e.y gives 7. No reflection, no extra indirection, no runtime cost — just a shorter name for a path you'd otherwise spell out.

Level 1 was the struct form. The same keyword does the same job — pull inner names up one scope — in two more places. Then comes the one sharp edge: when two promotions reach for the same name.

One keyword, one rule (the inner names become available one level up), three spots it shows up:

1 · on a struct field — fields promoted onto the outer struct

using xform: Transform inside Entity (Level 1). e.x reaches through to e.xform.x.

2 · on a procedure parameter — fields promoted into the body

print_entity :: proc(using e: Entity) lets the body write x, y, z, hp with no e. prefix — the parameter's fields read like locals. Calling it on the Level-1 entity prints entity at (1, 2, 3) hp=100.

3 · as a statement — an enum's labels into the current scope

using Color brings Red, Green, Blue into scope as bare identifiers, so c: Color = Red (no Color., no .Red). This form is off by default — see the gotcha below.

The gotcha — two promotions, one name. Promote a field name into the outer scope and it now lives there as a bare name. So if a second using struct promotes a field with the same name, the bare name is ambiguous and the build stops. Add Health :: struct { x: int, hp: int } and embed it alongside the Transform, both with using — both want to own the bare name x:

Entity :: struct {
    using xform:  Transform,  // promotes x, y, z
    using health: Health,     // also promotes x  ← collision
}

main.odin(6:2) Error: 'x' is already declared in 'struct {using xform: Transform, using health: Health}', through 'using' from 'Health'

The fix: drop one of the usings and reach that struct through its named field (e.health.x). The promotion is an opt-in convenience for one obvious base; the moment two of them fight over a name, the convenience is gone and the explicit path is clearer anyway.

the statement form is fenced off on purpose using Color as a bare statement (and using on a procedure parameter) is disallowed by default in current Odin — the build is rejected before you can even hit a collision: 'using' has been disallowed as it is considered bad practice to use as a statement outside of immediate refactoring. The message points you at the per-file opt-in #+feature using-stmt (line 1 of this lesson's file turns it on). The default-off status is the language steering you toward the explicit selector c: Color = .Red instead of pouring labels into a broad scope. Struct-field using (Level 1) needs no flag — that one's always on.

A different flattening: a procedure group binds several type-specialized procs under one name. area :: proc{area_circle, area_square, area_triangle} — and the compiler picks the matching member by the argument's type, at compile time. Pick a call and watch which concrete proc fires.

You declare each specialization as a normal proc with its own name, then list them inside proc{...}. The group has no parameter list of its own — its members carry the signatures. At each call site the compiler matches the argument type against the members and emits a direct call to the one that fits:

one name area · three members · the compiler routes by argument type
call sitecompiler picksresult (f32)

It's resolved, then it's gone. Once the compiler picks area_circle for area(c), the call site is a plain direct call to area_circle — indistinguishable from writing that name by hand. The group is a compile-time alias: there's no stored proc value, no table lookup, no runtime dispatch. The original member names also still work directly, and area_circle(c) produces the identical result as area(c) — same call, two spellings.

Where you've already met this. Every standard-library name that "looks like one proc but takes many types" is a group. fmt.println routes to per-type printers; append routes between append_elem, append_elems, append_string, and more. The convention is fixed: the user-facing name is the group, and the implementations are named <group>_<thing> and listed inside the braces. When a core: proc seems to take "anything", search for name :: proc{ and you'll find the exact dispatch list.

The payoff emerges from the group being a closed, listed set. There's no best-match scoring and no silent conversion to wedge an argument in: an argument either matches a member's type or the call doesn't exist. When none match, the compiler hands you the whole considered list and says why.

Call area with a type no member accepts — say a string — and the build stops. The error isn't a vague "no such function": it enumerates every candidate it weighed and the argument type it couldn't place:

area :: proc{area_circle, area_square}
// ...
_ = area("hello")   // a string — no member takes one

main.odin(19:6) Error: No procedures or ambiguous call for procedure group 'area'
    that match with the given arguments
    Given argument types:
     • untyped string
Did you mean one of the following overloads?
    main.area_circle :: proc(c: Circle) -> f32
    main.area_square :: proc(s: Square) -> f32

The emergent payoff: the candidate set is closed and visible from one declaration. Nobody can extend area from another file by happening to declare a proc with a matching name — the only members are the ones inside the braces. And because each candidate has a unique name and an exact-match-or-nothing rule (no implicit conversions to argue over), the resolution has no surprises: the compiler's job is "find the one whose parameter type fits", not "score N near-misses and hope you guessed the same winner". Reading a call, you can find the dispatch list; reading the error, you see exactly what was tried.

a group is not a procedure value You can't store a group untyped in a variable: f := area fails with Error: Cannot determine type from overloaded procedure 'area', because the group has no single signature to infer a type from — it has three. A group is compile-time dispatch (the type is known where you write the call); a procedure value held in a variable is runtime dispatch and a different tool — that's the next lesson. Different problems, two halves of the same coin.

That's the arc: L1 using promotes inner field names up a scope — same bytes, shorter name, zero cost → L2 the same keyword works on a parameter and (opt-in) on an enum, and its one sharp edge is two promotions colliding on a name → L3 a procedure group bundles type-specialized procs under one name and the compiler routes by argument type, then compiles to a direct call → L4 that group is a closed, listed set, so dispatch has no surprises and a bad argument gets the whole considered list back.

probes reproduce with odin run · field values, sizes, offsets, dispatch traces & the L2/L4 errors are real compiler output (claims/lessons/12b-using-and-procedure-groups)