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 implements is for. The case for nominal is the accidental match: two types with a compare method that mean entirely different things by it. “It says implements Ord right 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.