Skip to main content
v0.12.0

Tutorial: Structs, Enums, and Methods

This tutorial shows how to define your own types in Metel. Structs hold named data; enums represent a fixed set of alternatives; extend blocks attach methods to either.

Structs​

A struct is a named collection of typed fields:

struct Point {
x: f64,
y: f64,
}

Create an instance by naming every field:

fun main() {
let origin = Point { x = 0.0, y = 0.0 };
let p = Point { x = 3.0, y = 4.0 };

println("x = ${p.x}, y = ${p.y}");
}

Fields are accessed with .field_name. They are read-only through immutable bindings.

note

Struct Literals Are Explicit

When you construct a struct, you name every field unless shorthand field init applies. There is no positional constructor syntax here.

Adding methods with extend​

Methods live in an extend block for the type. The first parameter self is the receiver:

struct Point {
x: f64,
y: f64,
}

extend Point {
fun distance_from_origin(self) -> f64 {
let x2 = self.x * self.x;
let y2 = self.y * self.y;
// Metel has no sqrt builtin yet — return the squared distance
return x2 + y2;
}

fun to_string(self) -> String {
return "(${self.x}, ${self.y})";
}
}

fun main() {
let p = Point { x = 3.0, y = 4.0 };
println(p.to_string()); // (3, 4)
println(p.distance_from_origin()); // 25
}

Methods are called with .method_name(args). self is passed implicitly.

tip

Think “Function Namespaced Under The Type”

An extend method is still just a function. Dot syntax is the ergonomic call form, but the main extra idea is the implicit self.

The Self type​

Inside an extend block, Self is an alias for the type being extended. This is useful for methods that return a modified copy:

struct Point {
x: f64,
y: f64,
}

extend Point {
fun translate(self, dx: f64, dy: f64) -> Self {
return Point { x = self.x + dx, y = self.y + dy };
}

fun scale(self, factor: f64) -> Self {
return Point { x = self.x * factor, y = self.y * factor };
}
}

fun main() {
let p = Point { x = 1.0, y = 2.0 };
let moved = p.translate(3.0, 4.0);
println("${moved.x}, ${moved.y}"); // 4, 6

let big = moved.scale(2.0);
println("${big.x}, ${big.y}"); // 8, 12
}

Self is equivalent to writing the type name explicitly, but stays correct if the type is renamed.

Enums​

An enum defines a type with a fixed set of named variants. Variants can carry data:

enum Direction {
North,
South,
East,
West,
}

enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}

Unit variants (like North) carry no data. Struct variants (like Circle) carry named fields.

Create enum values with :::

fun main() {
let dir = Direction::North;
let s = Shape::Circle { radius = 5.0 };
let r = Shape::Rectangle { width = 3.0, height = 4.0 };
}

Pattern matching with match​

match is how you inspect an enum value. Every variant must be handled:

enum Direction {
North,
South,
East,
West,
}

fun describe(d: Direction) -> String {
match d {
Direction::North => "heading north",
Direction::South => "heading south",
Direction::East => "heading east",
Direction::West => "heading west",
}
}

fun main() {
println(describe(Direction::North)); // heading north
println(describe(Direction::West)); // heading west
}

For variants with fields, destructure them in the pattern:

enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}

fun area(s: Shape) -> f64 {
match s {
Shape::Circle { radius } => 3.14159 * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}

fun main() {
let c = Shape::Circle { radius = 5.0 };
let r = Shape::Rectangle { width = 3.0, height = 4.0 };

println("circle area: ${area(c)}"); // circle area: 78.53975
println("rectangle area: ${area(r)}"); // rectangle area: 12
}

The field names in a pattern bind to local variables — radius, width, and height are available in the arm body.

caution

match Must Stay Exhaustive

When you add a new enum variant later, every match over that enum must be updated unless you already use a wildcard arm.

Methods on enums​

extend works the same way on enums:

enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}

extend Shape {
fun area(self) -> f64 {
match self {
Shape::Circle { radius } => 3.14159 * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}

fun describe(self) -> String {
match self {
Shape::Circle { radius } => "circle with radius ${radius}",
Shape::Rectangle { width, height } => "rectangle ${width}×${height}",
}
}
}

fun main() {
let shapes = [
Shape::Circle { radius = 2.0 },
Shape::Rectangle { width = 5.0, height = 3.0 },
Shape::Circle { radius = 1.0 },
];

var total_area = 0.0;
for (let s in shapes) {
println("${s.describe()} — area ${s.area()}");
total_area += s.area();
}
println("total area: ${total_area}");
}

Output:

circle with radius 2 — area 12.56636
rectangle 5×3 — area 15
circle with radius 1 — area 3.14159
total area: 30.70795

A complete example: a card game hand​

Here is a longer program that combines structs and enums to represent a simple card game:

enum Suit {
Clubs,
Diamonds,
Hearts,
Spades,
}

enum Rank {
Number { value: i64 },
Jack,
Queen,
King,
Ace,
}

struct Card {
suit: Suit,
rank: Rank,
}

extend Suit {
fun to_string(self) -> String {
match self {
Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds",
Suit::Hearts => "Hearts",
Suit::Spades => "Spades",
}
}
}

extend Rank {
fun to_string(self) -> String {
match self {
Rank::Number { value } => value.to_string(),
Rank::Jack => "Jack",
Rank::Queen => "Queen",
Rank::King => "King",
Rank::Ace => "Ace",
}
}

fun point_value(self) -> i64 {
match self {
Rank::Number { value } => value,
Rank::Jack => 10,
Rank::Queen => 10,
Rank::King => 10,
Rank::Ace => 11,
}
}
}

extend Card {
fun to_string(self) -> String {
return "${self.rank.to_string()} of ${self.suit.to_string()}";
}

fun value(self) -> i64 {
return self.rank.point_value();
}
}

fun hand_value(hand: Card[]) -> i64 {
var total = 0;
for (let card in hand) {
total += card.value();
}
return total;
}

fun main() {
let hand = [
Card { suit = Suit::Hearts, rank = Rank::Ace },
Card { suit = Suit::Spades, rank = Rank::King },
Card { suit = Suit::Diamonds, rank = Rank::Number { value = 7 } },
];

for (let card in hand) {
println(" ${card.to_string()} (${card.value()} pts)");
}
println("Hand total: ${hand_value(hand)}");
}

Output:

Ace of Hearts (11 pts)
King of Spades (10 pts)
7 of Diamonds (7 pts)
Hand total: 28
note

This Is The Main Payoff Of ADTs

Structs and enums become more useful together: structs model stable records, enums model alternatives, and match ties them into explicit control flow.

Copying and moving values​

By default, assigning or passing a struct or enum value moves it: the source binding is considered spent, and the new binding becomes the only valid way to reach the value. This is a rule the type checker tracks, not something that changes what actually runs — the interpreter always copies a value's representation on assignment regardless of move status, so p and q below hold independent storage either way, and mutating one never affects the other:

struct Point { x: f64, y: f64 }

fun main() {
let p = Point { x = 1.0, y = 2.0 };
let q = p; // p is considered moved into q
println("${q.x}, ${q.y}");
}
caution

Move Checking Is Off By Default In This Release

Move status only matters if something actually enforces it. Pass --move-check on the command line to reject use of a moved-from binding — the example above would then reject reading p after moving it into q. Without that flag, using p afterward compiles and runs exactly as if the value had been copied, silently, since nothing is checking.

A type can opt out of moving entirely by declaring Copy. This changes what the type checker considers valid, not the runtime behavior — the value was already being copied either way:

struct Point { x: f64, y: f64 }
extend Point: Copy;

fun main() {
let p = Point { x = 1.0, y = 2.0 };
let q = p; // Copy: reusing p afterward is always valid, checked or not
println("${p.x}, ${q.x}");
}

A type can also declare Drop, marking it as needing cleanup when it goes out of scope. In this release, Drop only has type-level effects — it excludes the type from Copy and participates in move-checking rules — the drop method's body must still be empty; actually running cleanup code on drop is not implemented yet (issue #261):

struct Resource { name: String }
extend Resource: Drop {
fun drop(self) {}
}

What you learned​

  • struct defines a named record with typed fields; extend attaches methods to it.
  • self is the receiver; Self is an alias for the implementing type.
  • enum defines a type with named variants, each optionally carrying data.
  • match exhaustively handles every variant and destructures field data into local variables.
  • Both structs and enums support extend blocks in the same way.
  • Assignment moves by default — a rule the type checker tracks, not a change in what runs; the interpreter always copies a value's representation regardless. Copy opts a type out of the move rule entirely. Drop marks a type as needing cleanup, though running that cleanup isn't implemented yet. --move-check is what actually enforces the move rule by rejecting use of moved-from bindings; it is off by default.

Next: Generics — parameterizing your own structs, enums, and functions over a type.