Skip to main content

Packed structs

A packed struct gives names and widths to the bits of a single integer. Unlike a GC struct it allocates nothing: a packed value is an i32, an i64, or an i31 reference, passed and stored by value.

packed struct Rgb : i32 {
  red: u8,
  green: u8,
  blue: u8,
  opaque: bool,
}

func luma(c: Rgb) -> i32 {
  return (c->red + c->green + c->blue) / 3;
}

func check() -> i32 {
  let c: Rgb = new Rgb { red: 30, green: 60, blue: 90, opaque: true };
  if (luma(c) != 60) { return 1; }
  if (c->opaque != 1) { return 2; }
  return 0;
}

export func check as "check";

Reach for this when a value has a wire format, a flag set, or a tagged index -- anywhere the alternative is writing the same shifts and masks at every use site, with the layout described only in a comment.

Layout

Fields occupy bit ranges in declaration order, least-significant first, with no padding. Above, red is bits 0..8, green is 8..16, blue is 16..24, and opaque is bit 24.

A field's type is bool (one bit), an explicitly signed iN / unsigned uN of N bits, or the name of a sized enum, which contributes its declared width and signedness. The signedness is part of the declaration either way, which is why a read takes no .s/.u suffix -- unlike a packed i8/i16 storage field, where the same storage genuinely admits both readings and the suffix chooses.

An enum field is exactly a field of the enum's width, so the two spellings below are the same layout:

enum Channel : u8 { Off = 0, Full = 255 }

packed struct ViaEnum : i32 { red: Channel, green: Channel, blue: Channel, opaque: bool }

packed struct Direct : i32 { red: u8, green: u8, blue: u8, opaque: bool }

The enum version says what the bits mean, and Channel.Full is usable as a value for the field. An identifier here that is not an enum is an error: it is not a way to nest one packed struct inside another.

The widths must fit the representation, and the compiler says which field overflows:

packed struct TooWide : i31 {
  low: u16,
  high: u16,   // error: needs bits 16..32, but 'i31' holds only 31
}

Leaving capacity unused is fine. So is declaring no fields at all.

Picking a representation

The : i31 / : i32 / : i64 clause is mandatory, because it decides two observable things: how many bits you get, and whether the value is a reference.

RepresentationCapacityRuntime form
i3131 bits(ref i31) -- a reference
i3232 bitsi32
i6464 bitsi64

i31 is the interesting one. An i31-represented packed value is a real WebAssembly reference, so it fits in an ?any field, an &eq array element, or a table entry without allocating anything:

packed struct Tag : i31 {
  kind: u4,
  index: u20,
}

struct Slot { payload: mut ?any }

func check() -> i32 {
  let t: Tag = new Tag { kind: 3, index: 900 };

  // No allocation: the packed value goes straight into the `?any` field.
  let s: &Slot = new Slot { payload: t };

  let stored: ?any = s->payload;
  if (stored is &i31) {
    let bits: i32 = stored as i32.u;
    let back: Tag = (bits as &i31) as Tag;
    if (back->kind != 3) { return 1; }
    if (back->index != 900) { return 2; }
    return 0;
  }
  return 3;
}

export func check as "check";

Giving up the 32nd bit is what buys that. If you do not need it, use i32.

Testing for it

That round trip is the long way. Because an i31-represented packed value is an i31 reference, you can test for the packed struct directly, and the value narrows to it:

packed struct Tag : i31 {
  kind: u4,
  index: u20,
}

struct Slot { payload: mut ?any }

func check() -> i32 {
  let t: Tag = new Tag { kind: 3, index: 900 };
  let s: &Slot = new Slot { payload: t };
  let stored: ?any = s->payload;

  // Compiles as the i31 test it really is, but narrows to `Tag`, so the fields read
  // directly -- no `as i32.u`, no `as &i31`, no `as Tag`.
  if (stored is &Tag) {
    if (stored->kind != 3) { return 1; }
    if (stored->index != 900) { return 2; }
    return 0;
  }
  return 3;
}

export func check as "check";

This works in all three test positions: the narrowing if above, the plain expression form ((stored is &Tag) ? a : b), and a switch type pattern (case &Tag:).

It is a narrowing convenience, not a discriminator. Two i31-represented packed structs are indistinguishable at runtime, so the test really is asking "is this an i31?" — a value of a different i31-represented packed struct will match, and its bits will then be read through this struct's layout. If several such types share one slot, carry your own tag; the language cannot tell them apart for you.

?Tag is rejected in these positions (a packed value is never null), and so is a test for an i32/i64-represented packed struct — those are plain integers with no runtime identity, so there is nothing to test.

It is nominal, and the conversions are explicit

Two packed structs with identical layouts are still different types, and neither is interchangeable with a raw integer:

packed struct Weight : i32 { grams: u16 }
packed struct Length : i32 { millis: u16 }

func mix(w: Weight) -> i32 {
  let l: Length = w;   // error: value of type Weight is not assignable to Length
  return l->millis;
}

That is the point of the construct. A raw i32 reaching ->millis would read whatever bits happened to be there as a length.

Going between a packed value and its bits is written out, in both directions:

packed struct Flags : i32 { level: u4, urgent: bool }

func round_trip(f: Flags) -> Flags {
  let bits: i32 = f as i32;
  return bits as Flags;
}

as accepts only the struct's own representation: f as i64 on an i32-represented struct is an error, since widening a bit pattern is not a reinterpretation.

Writing a field

place->field = value replaces that field's bits and leaves the rest alone. Because a packed value is a value rather than a reference, there has to be somewhere to write it back to -- a var local or a mut global:

packed struct Flags : i32 {
  level: u4,
  urgent: bool,
  code: i8,
}

global Current: mut Flags = 0 as Flags;

func check() -> i32 {
  var f: Flags = new Flags { level: 1, urgent: false, code: 0 };
  f->level = 9;
  f->code = -3;

  if (f->level != 9) { return 1; }
  if (f->code != -3) { return 2; }
  if (f->urgent != 0) { return 3; }

  Current->urgent = true;
  if (Current->urgent != 1) { return 4; }
  if (Current->level != 0) { return 5; }
  return 0;
}

export func check as "check";

Through a struct field, an array element, or a parameter there is no place to write back to, so rebuild the value with new instead. The compiler says so rather than silently computing a new value and discarding it.

A global's initializer is the one place new is unavailable: building a packed value needs shifts and masks, which a WebAssembly constant expression cannot contain. Write the bits directly (0 as Flags), as above.

Truncation keeps the low bits, and says so when it can

An initializer or an assignment keeps the field's low N bits:

packed struct Small : i32 { nibble: u4 }

func check() -> i32 {
  var n: i32 = 300;                           // 300 = 0b100101100, low 4 bits are 1100
  let s: Small = new Small { nibble: n };
  if (s->nibble != 12) { return 1; }
  return 0;
}

export func check as "check";

This matches how a packed i8/i16 struct field assignment already behaves, and how WebAssembly's own narrow stores behave. Masking is what makes a packed write three instructions instead of a branch, so it is the behaviour, not a fallback.

But when the value is one the compiler can evaluate — a literal, a param, a const, an enum member — it already knows the value does not fit, and saying nothing about that is a choice rather than a limitation. Writing nibble: 300 directly reports:

type warning: 300 does not fit 'Small.nibble' (u4, 0..=15); it will be stored as 12
[packed-truncation]

The value is still stored the same way; only the silence changed. The lint is deliberately narrow — a runtime value that overflows is not knowable here, which is why the example above routes 300 through a var to keep it quiet. Suppress it with --allow packed-truncation if masking a known constant is what you meant.

Methods work

A packed struct is a value type with a name, so it takes methods exactly as i32 does:

packed struct Point : i32 {
  x: i16,
  y: i16,
}

func Point.manhattan(self: Point) -> i32 {
  let dx: i32 = self->x;
  let dy: i32 = self->y;
  return (dx < 0 ? 0 - dx : dx) + (dy < 0 ? 0 - dy : dy);
}

func check() -> i32 {
  let p: Point = new Point { x: -3, y: 4 };
  if (p.manhattan() != 7) { return 1; }
  return 0;
}

export func check as "check";

See also