W# 0.2.0

Structs and closures

Nominal records, implicit widening to a supertype, and functions as values.

// Structs and closures.
//
// Struct instances and closures are both heap objects with the same 16-byte
// header, allocated through the same `ws_alloc` entry point. That uniformity is
// what lets the collector trace a closure's captures exactly as it traces a
// struct's fields.

const Point = struct { x: i64, y: i64 };

const Rect = struct {
    origin: Point,
    width: i64,
    height: i64,
};

fn area(r: Rect) i64 {
    return r.width * r.height;
}

fn right_edge(r: Rect) i64 {
    return r.origin.x + r.width;
}

// A function taking another function. `twice` is generic in neither argument,
// but `apply_to_area` below shows a closure being passed the same way.
fn twice(f: fn(i64) i64, x: i64) i64 {
    return f(f(x));
}

fn main() i64 {
    var r = Rect{
        .origin = Point{ .x = 2, .y = 3 },
        .width = 10,
        .height = 4,
    };

    print(area(r));
    print(right_edge(r));

    // Fields are mutable through a `var` binding.
    r.width += 5;
    print(area(r));

    // A closure capturing a local by value.
    const margin = 100;
    const pad = fn (n) { return n + margin; };
    print(pad(area(r)));

    print(twice(pad, 0));
    return 0;
}
wsharp run examples/shapes.ws
40
12
60
160
200

Declaring and building

A struct type is a const bound to a struct expression, and an instance names the type and its fields:

const Point = struct { x: i64, y: i64 };
const p = Point{ .x = 2, .y = 3 };
print(p.x);

Fields are mutable through a var binding, and r.width += 5 writes through the field. A const binding does not allow it.

Field types can be inferred from the literal, so Box{ .value = 42 } fixes a generic Box[T] to Box[i64] without you saying so. That is generics.

Subtyping is one pointer

A struct may declare a supertype:

const Event    = struct { at: i64 };
const Finished = struct : Event { count: i64 };

A Finished widens implicitly wherever an Event is wanted. No conversion is emitted, because a subtype’s fields are its supertype’s, first: Finished is laid out as a byte-identical copy of Event followed by count. A field read compiled against Event runs unchanged on any subtype, with no adjustment and no vtable.

This is also what makes dispatch cheap, and the two features are the same feature: the lattice that widening walks is the lattice type ids are assigned over.

A supertype is an ordinary type expression, so struct : pkg.Base and struct : inner.Base work as well as a bare name. Aliasing runs first, which means a facade’s name for a type reaches the same type as the original and the two produce siblings rather than two separate lattices.

A struct with no fields is also a value: its sole instance. That is how the HTTP status types work, and it is why http.NotFound404 can be passed as an argument as well as written as a type.

Closures

fn (a, b) { ... } is an expression. It captures by value, generalises like a declaration, and can be passed anywhere a fn(A) B is wanted:

const margin = 100;
const pad = fn (n) { return n + margin; };
print(twice(pad, 0));

twice takes f: fn(i64) i64 and calls it. A plain top-level fn can be passed the same way, because every function takes an environment pointer and top-level ones ignore it and are called with null. That uniformity means passing a fn as a value needs no wrapper to be generated.

A closure may also name itself, which is how a recursive local function is written.

Struct instances and closures are both heap objects with the same 16-byte header, allocated through the same entry point. That is what lets the collector trace a closure’s captures exactly as it traces a struct’s fields.

Comparing two structs

== compares a struct field by field, each field by its own type’s rule, so a struct field recurses and an f64 field makes a NaN unequal to itself.

Two values of different concrete types are never equal. That answers the awkward half of “identity or structure” without the surprising part: x == x is true whatever x holds, because the same object is tested for first, and two subtypes held at their supertype compare the fields they actually have rather than only the part the supertype declares.

const a = Finished{ .at = 1, .count = 5 };
const b = Finished{ .at = 1, .count = 5 };
print(a == b);              // true

An optional field compares, so an absent one differs from a present one and two absent ones agree. An array, a function or an error union field does not, and makes the whole struct uncomparable at compile time, with the compiler saying which field. That reaches down the lattice too: a subtype with such a field makes its supertype uncomparable, because a comparison written at the supertype is what will be handed the subtype.

A value that reaches itself recurses for ever, as derived equality does everywhere it exists, and that is on the list.

Under the hood this is a function per concrete type rather than an inline sequence, because a type that reaches itself would otherwise expand for ever during compilation: a dispatching entry point that answers identity, nulls and unequal type ids and then picks within the declared type’s subtree, and an exact comparison per type. The field reads go through the same load barrier and the same rooting as every other field read, because they are built as W# would build them.

Last changed 17 September 2026. Improve this page

On this page