Chapter 7 — Structs and Methods
A struct bundles several named, typed fields into one value.
struct Point {
x: float
y: float
fn distance(self, other: Point) -> float {
let dx = self.x - other.x
let dy = self.y - other.y
return sqrt(dx * dx + dy * dy)
}
}
fn main() -> int {
let a = Point { x: 0.0, y: 0.0 }
let b = Point { x: 3.0, y: 4.0 }
println("{a.distance(b)}") // 5
return 0
}
You construct a struct with Type { field: value, ... }, and you must set every field
exactly once — Ingle won’t let you forget one or leave it to chance. You read a field with
value.field.
Methods and self
A struct can carry methods, written inside its braces. A method takes self as its first
parameter, explicitly — that’s the receiver, the value the method was called on. Inside, you
read its fields as self.x and call its other methods as self.whatever(). Dispatch is
static: Ingle knows the exact type at the call site, so there’s no virtual-call overhead.
Notice distance calls sqrt, which is a built-in maths function — no import needed. (Its
friends pow, abs, floor, ceil, round, and random are all built in too; the rest of
the maths lives in the standard library.)
Changing a field
You can change a field, but only through a mutable place — a var binding, or a mut
parameter (full details in Chapter 12). Through a let
or a plain parameter, fields are read-only.
struct Pt { x: int y: int }
struct Line { a: Pt b: Pt }
fn main() -> int {
var ln = Line { a: Pt { x: 1, y: 2 }, b: Pt { x: 3, y: 4 } }
ln.a.x = 10 // mutate a nested field through the var
return ln.a.x + ln.b.y // 10 + 4 = 14
}
Notice you can reach right down a nested path — ln.a.x = 10 — and Ingle writes it back where
it belongs. Try the same thing through a let and you’ll get a polite compile error pointing
you at var.
Making your own values printable: show
Interpolation renders a number, a string, or a bool on its own. To make a value of your own type
interpolate, give it a method fn show(self) -> string — that is the entire opt-in:
struct Temp {
celsius: float
fn show(self) -> string { return "{self.celsius}°C" }
}
fn main() -> int {
let t = Temp { celsius: 21.5 }
println("it is {t}")
return 0
}
it is 21.5°C
=> 0
The presence of show is the whole contract — there’s no implements to write (it’s structural,
like Go’s Stringer), and "{t}" quietly becomes "{t.show()}". A value whose type has no show
gets a clear compile error naming the missing method rather than a mystery rendering. (Writing
implements Show as well is allowed, and lets the type stand in wherever a Show value or a
T: Show bound is wanted — but it isn’t needed just to interpolate.)
Interfaces and implements
An interface is a list of method signatures — a promise about what a type can do. A struct
declares it keeps that promise with implements, and the compiler checks that it actually
does:
interface Ord {
fn compare(self, other: Self) -> int // negative, zero, or positive
}
struct Version implements Ord {
number: int
fn compare(self, other: Version) -> int { // 'Self' is Version here
return self.number - other.number
}
}
fn main() -> int {
let a = Version { number: 5 }
return a.compare(Version { number: 4 }) // => 1
}
Conformance is nominal: you have to say implements Ord, it isn’t inferred from the
shape of your methods. Name an interface you don’t satisfy — miss a method, get a signature
wrong, misspell the interface — and it’s a compile error, named clearly. Note the capital-S
Self: inside an interface it stands for “whatever type ends up implementing me,” and it
resolves to Version in the struct above. (Lower-case self is the receiver value;
capital Self is the type. One letter, two jobs — keep them straight.)
Interfaces earn their keep three ways. They check that a type provides the right methods (just
now); they bound generics so a <T: Ord> function may call compare on its parameter
(Chapter 10); and — this one’s new, and worth a section of
its own — they work as value types, which is how you get runtime polymorphism without
inheritance.
Interfaces as values: dynamic dispatch
Everything above is resolved at compile time: the receiver’s exact type is known, so a method call goes straight to the right code. But sometimes you genuinely don’t know the concrete type until the program runs — you want a list of different shapes, each computing its own area, all behind one type. For that, use the interface itself as a type:
interface Shape {
fn area(self) -> float
}
struct Circle implements Shape {
radius: float
fn area(self) -> float { return 3.14159 * self.radius * self.radius }
}
struct Rect implements Shape {
w: float
h: float
fn area(self) -> float { return self.w * self.h }
}
fn main() -> int {
let shapes: [Shape] = [Circle { radius: 2.0 }, Rect { w: 3.0, h: 4.0 }]
var total = 0.0
for s in shapes { total = total + s.area() }
println("total {total}")
return 0
}
total 24.5664
=> 0
A [Shape] holds a Circle and a Rect side by side, and s.area() calls the right one for
each. The moment you put a Circle where a Shape is expected, Ingle upcasts it: the value
becomes a small box pairing the receiver with a table of its methods. A call then looks the
method up in that table at run time. One fn report(s: Shape) serves every implementer you’ll
ever write, with no shared base class in sight. The shipped examples/13_interfaces.ig does
exactly this with three shapes and a report function.
There’s one rule on which interfaces can be used this way, and it’s a sensible one: the
interface must be object-safe, meaning no method may mention Self anywhere but the receiver.
The reason is concrete — once a value is hidden behind Shape, its real type is erased, so a method
like compare(self, other: Self) has nowhere to get a second value “of the same type.” Ingle
tells you so plainly if you try:
interface Ord {
fn compare(self, other: Self) -> int // 'other: Self' — not object-safe
}
let xs: [Ord] = [ ... ]
error: this interface can't be used as a value type: one of its methods uses 'Self' beyond the
receiver, which dynamic dispatch can't honor. Use it as a generic bound instead (e.g. fn f<T: Name>(x: T)).
That’s not a limitation so much as a signpost: an interface like Ord is perfectly usable — just
as a generic bound, where the concrete type is still known, which is the very next chapter. Use
Shape-style interfaces (methods that only ever take self) as values, and Ord-style interfaces
(methods comparing two of a kind) as bounds.
Fireside trivia. Type systems split into two camps on this. Structural typing says “if a type has the methods, it qualifies.” Nominal typing — Ingle’s choice — says “it qualifies only if it declares that it does,” which is what
implementsis for. The case for nominal is the accidental match: two types with acomparemethod that mean entirely different things by it. “It saysimplements Ordright there” is a hard line for a confused reader, human or machine, to misread.
Newtypes: a name the compiler keeps straight
That nominal instinct — a type is what it says it is — has a lighter use than a whole interface. A newtype gives an existing type a new name that the compiler then treats as genuinely distinct:
type UserId = int
type Email = string
fn main() -> int {
let id: UserId = UserId(42)
let mail: Email = Email("ada@ember.dev")
println("user {id} <{mail}>")
return 0
}
user 42 <ada@ember.dev>
=> 0
A UserId is an int at run time — the wrapper erases completely, on both the VM and the
native backend, so it costs nothing. What you get for it is a name the compiler refuses to confuse
with any other int. Pass a UserId where a different id is expected and the program simply
doesn’t build:
type UserId = int
type ProductId = int
fn main() -> int {
let u: UserId = UserId(1)
let p: ProductId = u // a UserId is not a ProductId
println("{p}")
return 0
}
error: binding annotation does not match the value's type
That’s the swapped-argument and unit-confusion bug class — transfer(to, from, amount) called
with to and from reversed, or cents handed to code counting dollars — turned into a compile
error. A newtype still inherits its base’s ==, ordering, hashing, and rendering, so it compares,
sorts, serves as a Map key, and interpolates in "{...}" directly. The one thing it won’t do
behind your back is arithmetic: a UserId is an identity, not a quantity, so adding to one is a
question Ingle makes you ask out loud —
error: arithmetic on a newtype requires unwrapping to its base first (e.g. `int(x)`), then re-wrapping the result
— unwrap with the base conversion (int(m)), compute, then re-wrap.
Refinements: a promise the type carries
A newtype over a numeric or bool base may add a where predicate over self — a refinement
type. The predicate is an ordinary bool expression, checked once, at construction:
type Percent = int where 0 <= self && self <= 100
fn main() -> int {
let p: Percent = Percent(80)
println("{p}%")
return 0
}
80%
=> 0
Construct one from a value the predicate rejects and it doesn’t quietly carry on — it traps with a structured fault that names the type and the line:
error[refinement_violation]: refinement violated constructing 'Percent' (line 4)
Once a Percent exists, every reader downstream knows it’s in range without re-checking — the
type is the proof. This is the contract machinery from Chapter 17 aimed
at a value’s construction instead of a function’s entry, and it plays by the same rules: checked in
debug builds, elided in --release. (For now the check is always a runtime one; discharging it
statically through the prover is a later addition.)
Fireside trivia. “Make illegal states unrepresentable” is an old slogan that usually costs a hand-written wrapper type with a private field and a validating constructor. A refinement is that wrapper in a single line — and because the validity lives in the type, the proof travels with the value instead of in a comment that asks the next reader to trust it.