Skip to content

Instantly share code, notes, and snippets.

@tauoverpi
Created April 26, 2025 16:34
Show Gist options
  • Save tauoverpi/999f478251ebb1c79c15ccca4ffc5268 to your computer and use it in GitHub Desktop.
Save tauoverpi/999f478251ebb1c79c15ccca4ffc5268 to your computer and use it in GitHub Desktop.

note: This is not a syntax proposal, the colour of the shed can be decided later if this is actually accepted.

Where I want to use this my game stuff/ecs.

The gist of it

Given two separate types,

const Player = struct { x: u16, y: u16, z: u16, hp:  u16 };
const Mob = struct { x: u16, y: u16, z: u16, hp:  u16, type: enum { a, b, whatever }};

To work with them structurally we use anytype,

fn lenFromOrigin(v: anytype) u16 {
    comptime assert(
        // this is only here to show what the types are
        @TypeOf(v) == Player or 
        @TypeOf(v) == Mob
     );

     return @sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}

Which doesn't communicate anything about the expected shape.

Under this proposal the function would become:

fn lenFromOrigin(v: shape struct { x: u16, y: u16, z: u16 }) u16 {
    comptime assert(
        // this didn't change much
        @TypeOf(@fromShape(v)) == Player or 
        @TypeOf(@fromShape(v)) == Mob
    );

    return @sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}

Where shape narrows the view we have of a type passed but doesn't change the underlying type thus:

test {
    var player: Player = dontCareInitPlayer(); // implementation is whatever, don't care
    var mob: Mob = dontCareInitMob(); // ditto
    const plen: u16 = lenFromOrigin(player);
    const mlen: u16 = lenFromOrigin(Mob);
    _ = plen;
    _ = mlen;
}

Works as you expect with anytype.

Shapes are "views" not "types"

You cannot construct a shape thus the following is nonsense:

const v: shape struct { x: u32 } = .{ .x = 5 };
const u: shape union { x: u32 } = .{ .x = 5 };
const e: shape enum { a, b, c } = .a;

as shapes only reppresent the structure of an expected type and thus do not themselves have a runtime representation.

Shapes work with existing builtins

fn offset(v: shape struct { x: u32 }) usize {
    return @offsetOf(@TypeOf(@fromShape(v)), "x");
}

Gives the offset of the field x in whatever type was passed to offset the same way as anytype allows as shape is just a narrowed view of the original type passed.

Comptime generics work the same for shapes

Since zig generics are not bijective shapes do not try to be:

fn ArrayListShape(comptime T: type) type { // or viewtype
    return shape struct {
        items: []T,
        capacity: usize,

        pub fn append(@This(), Allocator, T) error{OutOfMemory}!void;
    }; 
}

Specifies what an ArrayList looks like in this future where that Managed types have been removed. Thus std.io could use:

pub inline fn readUntilDelimiterArrayList(
    self: Self,
    array_list: ArrayListShape(u8),
    delimiter: u8,
    max_size: usize,
} (NoEofError || Allocator.Error || error{StreamTooLong})!void {
  // ...
  // implementation remains the same
}

If it didn't care that the type is specifically ArrayList(u8). Since it only ever uses a subset of ArrayList(u8) it could even use a subset of the definition of ArrayListShape such that a user could pass their own ArrayList implementation. (note: I just picked the first example I remembered that uses ArrayList as a parameter, not suggestiong this interface should be made anytype generic).

Shapes compare equal

Shapes use structural equivalence thus compare equal even if order differs.

const SA = shape sturct { a: u32, b: bool };
const SB = shape struct { b: bool, a: u32 };
comptime assert(SA == SB);

as a consequence @typeInfo(SA).@"shape".fields would need to be sorted by field name along with .decls so there's no observable difference wrt to field order observable at comptime.

std.builtin.Type.Shape

I assume it would be something like this:

/// Type of the child which this shape represents.
/// This is only set when another type has coerced
/// to this shape as to not lose track of what the
/// original type is. Otherwise if a shape is inspected
/// directly via `@typeInfo(shape struct {})` then
/// this field is `null`.
child: ?type,
/// Definition of a shape where entries are sorted
/// by name of `mem.order(..)`.
shape: union(enum) {
    @"opaque": []const Declaration,
    @"struct": struct {
        decls: []const Declaration,
        fields: []const StructField,
    },
    @"union": struct {
        decls: []const Declaration,
        fields: []const UnionField,
    },
    @"enum": struct {
        decls: []const Declaration,
        fields: []const [:0]const u8,
    },
}

Shapes don't have member functions

Shapes are views and cannot have their own functions thus none of the functions declared within a shape have a function body as it doesn't make any sense (shaped only describe what other types look like).

Someone may argue that shapes should allow methods too to be just like haskell typeclasses or rust traits but that's rabbit hole I don't want to explore as things would explode in complexity and shapes would no longer compare equal (something something HOTT is never making it into zig anyway).

Coercion

Any type which satisfies the shape will coerce to a shape type thus ArrayList(T) coerces to what is effectively shape ArrayList(T) (and any subtype of that) implicitly however going in the other direction requires @fromShape(value) which recovers the underlying type ArrayList(T). That is to say, shapes only ever wrap types, they don't represent distinct types themselves. This is also why @fromShape(..) operates on values and not types and in the following case:

fn foo(x: shape struct {}) void {
    _ = @fromShape(x).stuff;
}

is equivalent to:

fn foo(x: anytype) void {
    comptime assert(@typeInfo(@TypeOf(x)) == .@"struct");
    _ = x.stuff; 
}

as we've discarded the shape/view and instead operated directly on the concrete type which the user passed to foo(..). That is to say, shape .. is anytype with a check equivalent to hand-rolled @typeInfo(x) with comptime assert(..) while moving this to the type signature to better communicate expectations.

Consequences

One can now write shape agnostic code which is the point of the proposal. It makes anytype generics less of a pain which might as a consequence result in more of it (or less, who knows).

Prior art

Bikeshed colours

Whatever, even things not in this set, it doesn't matter if this proposal is rejected.

  • anytype sturct { .. }
  • shape struct { .. }
  • view struct { .. }
  • viewtype struct { .. }
  • interface struct { .. }
  • generic struct { .. }
  • ducktype struct { .. }
  • whatever struct { .. }
  • hiandrewimentionedthisoverircbackduringzig_0_5_0daysrelatedtoelmtype struct { .. }
@AndrewKraevskii
Copy link

What do you think about user level implementation?

const std = @import("std");

pub fn Shape(comptime T: type) type {
    return struct {
        value: T,

        pub fn from(full: anytype) @This() {
            var result: T = undefined;
            inline for (@typeInfo(T).@"struct".fields) |field| {
                @field(result, field.name) = @field(full, field.name);
            }
            return .{ .value = result };
        }
    };
}

const Player = struct { x: u16, y: u16, z: u16, hp: u16 };
const Mob = struct { x: u16, y: u16, z: u16, hp: u16, type: enum { a, b, whatever } };

fn lenFromOrigin(s: Shape(struct { x: u16, y: u16, z: u16 })) u16 {
    const v = s.value;
    return std.math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}

test {
    const player: Player = undefined; // implementation is whatever, don't care
    const mob: Mob = undefined; // ditto
    const plen: u16 = lenFromOrigin(.from(player));
    const mlen: u16 = lenFromOrigin(.from(mob));
    _ = plen;
    _ = mlen;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment