Anonymous structavaganza in zig

Thu Sep 25 2025

When statements disappear, what remains of good semantics?

Let’s see what side effects have been introduced!

To start, observe this truly primordial ‘C code;


struct A {};
struct B {};

void example(struct A e);

int main(){
	example((struct B){});
}

clang output:

error: passing 'struct B' to parameter of incompatible type 'struct A'
     example((struct B){});
             ^~~~~~~~~~~~

THE TYPES ARE UNIQUE. THEY HAVE DIFFERENT NAMES! THE ARE NOMINALLY DIFFERENT.

And such it is for all c structs…


But let’s move forward to a distant future far away… or rather close actually.

The anonymous, privacy-preserving world of Zig the statementless!

Look at this beauty:

const A = struct {};

We have bound our capital name to this lost struct!

But has the struct recognized our interaction? Or is it like a beautiful immutable atom? Has it changed itself?

Let’s take a look:

pub fn main() !void {
    const std = @import("std");
    std.debug.print("Who are you? {any}\n", .{A});
}

outputs:

Who are you? test.A

Not only has it given itself the name ‘A’, it even looked outward and saw the file ‘test.zig’. Zig structs have certain nationalistic, or fileistic, tendencies.



From this you SHOULD be able to guess how the following behaves:

const A = struct {};
const B = A;

pub fn main() !void {
    const std = @import("std");
    std.debug.print("Who are you? {any}\n", .{B});
}

Yes. The struct does not care for a second name or ‘renaming’. It stays intact:

Who are you? test.A



The fun part: Struct equality

Zig borrows quite a lot from ‘C, like my first example:

struct {} == struct {}

outputs false. They are different! (There are two LITERAL structs in the source code! They must be different)


But yet, oh this can't be! What about the typesystem, who will save it?

Everything can’t be different! A great tower of Babel has been built! And soon all variables won’t dare communicate anymore. They all have different types now and all programming is henceforth dead. A looming danger is over us, our pristine generic programming! Poor ArrayList(i32).

But in swoops zig, saving us from a slow death:

fn type_constructor() type {
    return struct {};
}

const THE_TRUTH = type_constructor() == type_constructor();

It’s true !!! THE_TRUTH is true.


  • you might say: of course! because they both were born from the same ‘struct{}’! thus they are the same!

and what you say is entirely correct:
fn type_constructor(a: i64) type {
    return if (a==0) struct {} else struct {};
}

const THE_TRUTH = type_constructor(0) == type_constructor(1);

THE_TRUTH has turned false. Just look at the code!! Both structs occupy 9 bytes of this source file, let them have their own types!




Now to the ultimate test:


fn type_constructor(a: i64) type {
    return struct {b: i32 = a};
}

const THE_TRUTH = type_constructor(0) == type_constructor(1);

It’s still false! ‘b’ has a different default value in the two types — therefore THEY CANNOT be the same. (If we replace 1 with 0 though, they become the same)



yay yay yay! Genericly we continue to program one day further! Type construction through functions, through the same literal literal — the same struct — has given us our structural equivalence, to be found nowhere else.


We can pass a ArrayList(i32) to a function parameter with type Arraylist(i32).

(for reference, here is the Arraylist type-constructor)

pub fn ArrayList(comptime T: type) type {
    return ArrayListAligned(T, null);
}

Yet, do you notice it too? A rumbling of evil machinations lowers onto you… A dark voice whispers from a deep subconcious spot:

  • Zig is totally zigged. It’s pure argumentation! It’s total parametrizational memoization!

But: you have the perfect response:

fn type_constructor(a: i64) type {
    _ = a;
    return struct {};
}

const A = type_constructor(0);
const B = type_constructor(1);
const THE_TRUTH = A == B;

And thankfully, THE_TRUTH is true. You let out a long sigh, yet the voice continues:

  • So? They are the same type eh? Print out their names:
pub fn main() !void {
    const std = @import("std");

    std.debug.print("{any} {any}\n", .{A, B});
}

and out comes:

test.type_constructor(0) test.type_constructor(0)

OH NO!
And switching the order of their usages you get:

fn type_constructor(a: i64) type {
    _ = a;
    return struct {};
}

const A = type_constructor(0);
const B = type_constructor(1);

pub fn main() !void {
    const std = @import("std");

    //                                    SWAPPED ORDER:
    std.debug.print("Who are you? {any} {any}\n", .{B, A});
}

out:

Who are you? test.type_constructor(1) test.type_constructor(1)
  • AHA, we’ve been zigged alright. Zig was simply caching the result, it didn’t even care to call type_constructor(0), because it knew it would return the same thing.

The final stab in the heart

And so we get to the final boss of type constructors: ‘a*0’

fn type_constructor(a: i64) type {
    return struct {b: i64 = a*0};
}

const A = type_constructor(0);
const B = type_constructor(1);
const THE_TRUTH = A == B;

pub fn main() !void {
    const std = @import("std");

    std.debug.print("{any} {any} {any}\n", .{THE_TRUTH, B, A});
}

output:

false test.type_constructor(1) test.type_constructor(0)

Zig is too stupid to realize we haven’t actually used ‘a’, so it creates two DIFFERENT types…


Why is this?


Is it just so the printability remains top-notch?


Yet, in the previous example, both of them GOT the same type, even though they called the type_constructor differently?


Maybe this is just a side-effect of another zig possible feature: removal of unnecessary arguments? I’m not a zigxpert, so I can’t answer.


STRUCTURAL TYPING IS YET FAR AWAY!

GENERICS REMAIN AT RISK YET. BUT CALL THEM THE SAME, AND YOU MAY GET THE SAME THING BACK… BUT DON’T TRUST IN ZIG TO FIGURE THINGS OUT FOR YOU.

‘a type-constructor with side-effects’ - SCARE A ZIG USER WITH THAT LINE.


‘OH but who cares’ - you say. ’ zig is simply unzigeresting, it’s comptime is just raw fakery, it’s just another C++ mistake of a language modestly evaluating a small preordained subset of semantics.’ ’ ITS NOT usable;;bring out some real type magic(jai)!’

The rest will speak for itself, like a code novel:

A :: struct {};
B :: struct {};

main :: () {
	#import "Basic";
	print(": %\n", A == B);
}

: false



f :: () -> Type {return struct {};}

A :: #run f();
B :: #run f();

main :: () {
	#import "Basic";
	print(": %\n", A == B);
}

: true



f :: (a: s64) -> Type {return struct {};}

A :: #run f(0);
B :: #run f(1);

main :: () {
	#import "Basic";
	print(": %\n", A == B);

}

: true



f :: ($a: s64) -> Type {return struct {b: s64 = a;};}

A :: #run f(0);
B :: #run f(1);

main :: () {
	#import "Basic";
	print(": %\n", A == B);

}

: false (giving them both ‘0’ -> true)



f :: ($a: s64) -> Type {return struct {b: s64 = a*0;};}

A :: #run f(0);
B :: #run f(1);

main :: () {
	#import "Basic";
	print(": % ", A == B);
	print(": % %\n", A, B);

}

: false : (anonymous struct) (anonymous struct)


OK JAI IS LITERALLY EXACTLY THE SAME.


The programming languages are converging?

Super fun odin ender:

package odin

import "core:fmt"

f :: struct($a: i64) {
	b: i64,
}

A :: f(0)
B :: f(1)

main :: proc() {
	fmt.println(A == B);
}

gives false. (Just like jai does, if you do it with jai’s ‘struct’ instead of creating the struct through a function like I did)