About #32099: remove `@TypeOf` and `anytype`; introduce `|T|` syntax

Since the release of Proposal #32099 - remove @TypeOf and anytype; introduce |T| syntax - ziglang/zig - Codeberg.org, I have been following it closely and I am looking forward to its acceptance. It is an important proposal to improve the user experience of Zig type system. I have some ideas about this proposal, but we need to review Zig Zen first. All of my arguments are based on it.

 * Communicate intent precisely.
 * Edge cases matter.
 * Favor reading code over writing code.
 * Only one obvious way to do things.
 * Runtime crashes are better than bugs.
 * Compile errors are better than runtime crashes.
 * Incremental improvements.
 * Avoid local maximums.
 * Reduce the amount one must remember.
 * Focus on code rather than style.
 * Resource allocation may fail; resource deallocation must succeed.
 * Memory is a resource.
 * Together we serve the users.

Introduce |T| syntax

Firstly, starting from the most fundamental aspect, which is the core content of #32099, it introduces the syntax of |T| to replace @TypeOf and anytype. Its meaning is as follows:
Zig itself has variable capture syntax:

if(optional) |opt| {...}
if(error_union) |v| {...} else |err| {...}
for(list) |capture| {...}

The syntax of |T| is similar, but it is placed in the expected type position:

const a: u32 = b;
const c: |T| = a; // T is inferred to be u32

Peer Type Resolution

When using |T| instead of @TypeOf, it introduces a problem that @TypeOf itself can perform peer type resolution(as mentioned in the issue #32099), but |T| cannot do similar things.
The solution I proposed is to introduce a new built-in function specifically for peer type resolution, called @PeerType (...) (signed as fn @PeerType(type, ...) type, which allows accepting several types as parameters).

For example, in issue #32099:

pub fn clamp(val: anytype, lower: anytype, upper: anytype) @TypeOf(val, lower, upper) {
    const T = @TypeOf(val, lower, upper);
    // ...
}

:backhand_index_pointing_down:

pub fn clamp(val: |V|, lower: |L|, upper: |U|) @PeerType(val, lower, upper) {
    const T = @PeerType(V, L, U);
    // ...
}

It is based on zig zen’s Communicate intent precisely. @TypeOf itself does not contain any semantic information about peer type parsing, which is confusing for those who are not familiar with @TypeOf.

The expressions are evaluated, however they are guaranteed to have no runtime side-effects:

This feature is the second issue of replacing @TypeOf with |T|, which is a feature of @TypeOf and only it has this feature. In my opinion, it can be expressed through _: |T|=expr;To achieve. Assigning the value of an expression to _ means that the value of the expression is not important, so there is no need to generate runtime code for it. But if developers expect the side effects of expressions, they should use const result: |T|=expr; _ = result;.This method explicitly tells the compiler that I need to evaluate it.

_: |T| = expr;
// equivalent to
const T = @TypeOf(expr);
//----
const result: |T| = expr;
_ = result;
// equivalent to
const T = @TypeOf(result);
const result = expr;
_ = result;

This pattern is designed for the form of _: |T|, which means that even if it is in the function parameters:

fn TypeOf(_: |T|) type {
    return T;
}

Because _ has already told you that the runtime value of the expression here is not important.

Type Deconstruction

This is what I most expect, because for type systems, pattern matching is more readable than the @typeInfo code. I first argue why pattern matching is more readable compared to reflective code:

For example, if you expect a pointer type and use the current code to write:

pub fn expectPointerType(expr: anytype) !void {
    const info = @typeInfo(expr);
    if (info != .Pointer) {
        @compileError("Expected pointer type");
    }
}

The caller usually only sees the function signature pub fn expectPointerType(expr: anytype) !void, Unless he is interested in jumping to the definition. But the function parameters tell me that you can accept any type, but this is obviously unreasonable! If you could directly tell me the pattern of the type you expect in the parameters, that would be great:

pub fn expectPointerType(expr: *|T|) !void {
    ...
}

The caller can see pub fn expectPointerType(expr: *|T|) !void, It expects a pointer type, which is obvious. Not only pointers, but also arrays and slices should be possible. For example, the API of the allocator, The parameters of the create and destroy functions expect pointers, while alloc and free expect slices. If written on the parameters, it will not confuse people.
(zig zen Favor reading code over writing code.)

Secondly, there are not many built-in compound types in the language (such as []T,?T, etc.), and using pattern matching syntax can actually reduce mental burden.

Deconstruction of Generic Functions (Maybe)

This is also the feature I am looking forward to, but there is an obvious problem. Zig’s generic functions have a many to one relationship, which means that you can only determine a specific type based on function parameters, but cannot determine the function and parameters that generate it based on a single type.

My idea for this issue is that since Zig already has a caching mechanism for evaluating generic functions, for the mapping table of (function, parameter list)=>generic type, if it is only a one-to-one mapping, then it has successfully inferred. If it is a many to one mapping, then it should report an error. In most cases, we use generic functions to adapt them to different types. For Rust or C++, if the generic parameters are different, they must not be the same type. And for many to one mapping relationships, the power to handle them specifically lies with the developers.

const a: std.ArrayList(u8) = undefined;
_: std.ArrayList(|T|) = a; // T is inferred as u8

pub fn generic(comptime N: usize) type {
    return struct {
        pub const T = if (N > 10) i32 else u8;
    };
}

const T = generic(5);


const a: std.ArrayList(T) = undefined;
_: std.ArrayList(|T|) = a; // OK, there is only one possibility, T.

const T2 = generic(6);

const b: std.ArrayList(T) = undefined;
_: std.ArrayList(|T|) = b; // Error, there are two possibilities: T and T2

In addition, to ensure code readability, generic function deconstruction should not allow nesting.

const a: std.ArrayList(std.ArrayList(u8)) = undefined;

// Error
_: std.ArrayList(std.ArrayList(|T|)) = a;

// OK
_: std.ArrayList(|U|) = a;
_: std.ArrayList(|T|) = @as(U, undefined);

There is also |_| used for discarding values, which is similar to anytype.

I AM REALLY LOOKING FORWARD TO IT!

4 Likes

Note that type deconstruction is definitively not part of this proposal,
and none of the core team member commented in that direction.

I think memcpy(dest: []|T|, src: []const T) would definitively help the language by removing some boilerplate for the frequent use case of manipulating the pointee type, but I really don’t think Zig should go further and try to support generic type deconstruction because first “generic types” don’t exist in Zig. ArrayList is a function that return a type that’s all. I can do whatever I want in this function, good luck predicting what it would return from one call to another.

Type golfing is a well known hobby of Rust, Typescript, and Scala programmer but it’s a distraction compared to solving user problems and making computer go brrrr. I don’t want to engage to much in that non sense even if I sometimes indulge myself to it, and would prefer Zig to not favor this.

10 Likes

Did you mean @PeerType(V, L, U)? Otherwise, it just looks like a rename of @TypeOf()

1 Like

This part is my idea.

If this feature is introduced, it will be helpful for the structural abstraction of static dispatch methods. But in reality, I am not sure if the approach I proposed is suitable for Zig implementation, or if Zig is possible to implement the function of inferring function parameters based on the returned result.

At present, Zig’s dynamic dispatch method has a minor performance bottleneck where virtual functions cannot be inlined to call points. Static distribution can solve this problem, but it also brings about the problem of code template inflation. I remember there was a proposal for restricted function types, one of which was to address the overhead of calling virtual functions. I still trust the Zig team on how to make a specific decision.

1 Like

This part is my idea.

yes, I wanted to clarify because a lot of people are bike-shedding on the issue to push for this. So reading the issue may give the impression that’s where Zig is going.
Thanks for bringing that discussion on the forum.

I don’t see the relationship between your proposal and dynamic dispatch

1 Like

Previously, I came up with a programming pattern that combines static and dynamic dispatch in Zig. You can see it below.

Here is a simple interface example.

// A simple interface example
pub const Trait = struct {...}; // static
pub const Interface = struct{ // dynamic
    vptr: *anyopaque,
    vtable: struct{...},
};
pub fn Warpper(comptime Impl: type) type { // unified abstract
    ...
    return struct{
        ...
    };
}

For the use of interfaces, accept parameters through anytype:

pub fn callInterface(warpper_impl: anytype) void {...}

If it is possible to deconstruct generic functions, the parameters can be replaced with more descriptive Warpper(|Impl|):

pub fn callInterface(warpper: Warpper(|Impl|)) void {...}

In this mode, Warpper(Interface) serves as a dynamic dispatch interface, while other interfaces implemented directly through generic parameter passing can serve as static dispatch interfaces.

I disagree with the “no runtime effects” part.

Yes it was convenient and I’m not too happy to see this semantic go away.

But bring it back within a regular expression is waaaay worse than it was with a dedicated builtin. It’s major inconsistency in the syntax imo.

For example:

// I don't see why this shouldn't modify `old`. 
_: |T| = getAndSet(&old, new);

// But I can see why this shouldn't.
const T = @TypeOf(getAndSet(&old, new);

If you didn’t know about it, you could search a bug for days without realising. While you can just hover over @TypeOf and get the info. Debug your program, not your programming language knowledge.

4 Likes

it does modify old . i think the OP misread the issue. in my understanding an argument in favor of getting rid of TypeOf is that it purposely removes the “this has no runtime side effects” aspect of the builtin

3 Likes

never got this argument. The right way for this is std.meta.FnReturnType(getAndSet)

1 Like

in general, the return type of a function depends on its comptime captures.

2 Likes

Like @alanza said, a generic return type depends on the function’s arguments. The getAndSet in my example would be something like this in op’s syntax:

fn getAndSet(old: *|T|, new: T) T

And there’s no function in userland that could retrieve the return type in general.

3 Likes

I know I’m getting old [and grumpy], but my brain just cant parse this |T| syntax :frowning: Makes the language start to look like C++ imo.

I much prefer TypeOf and anytype, even if it does complicate the language spec’ a bit. Much better that, than complicate developers lives, and imo making code much harder to read.

3 Likes

Shouldn’t a natural extension of using the capture syntax be that it allows an expression that can use the captured value afterwards.

For example you could use a function that validates the type and then returns it.

foo(x: |T| assertInterface(T)) void

The only problem I can see is that, if the value x can’t easily be casted to the result of the expression, it doesn’t really make sense to return anything other than the original captured type.

If we do allow the a type capture expression to modify x then we have now have runtime code leaking into function signatures which doesn’t feel right.

On the other hand if we double down and let type capture expressions inspect the value described by the captured type we could even double down and put validation code for the return value and end up with contracts.

This does seem like a massive overcomplication though and I suggest taking this with a huge side of salt.

P.s I accidentally replied to someone else

1 Like

this would be trivially caught by the compiler in the same way that it currently catches an author mixing up comptime and runtime scopes.

i don’t think what you propose is worth the headache it adds though

ETA: oh i misunderstood, modifying the parameter itself sounds awful

The modification was just a side thought, the main idea was the type capture expression.

Since captured types would be immutable, you could move the type capture expression into the body of the function without changing anything.

Assuming the function hasShape checks all fields and declarations in the first arg are present in the second and on fail throws a compile error.

fn foo(a: |T| hasShape(struct {x: u8},T), b: u8) void {
return a.x + b;
}

fn foo(a: |T|, b: u8) void {
hasShape(struct {x: u8},T);
return a.x + b;
}

I’m not sure this change is worth it though.
If you just want to document and check an inference type then the second piece of code is enough.

This does not seem like a good idea to me. With your proposal, either _ = expr; (without any type capture) is now a no-op in which case it’s pointless syntax; or _ = expr; and _: |T| = expr; have opposite meaning for similar syntax which is much more confusing.

1 Like

It’s worth noting that you can use switches and blocks to “sneak” comptime assertions into the function signature where they’re visible:

pub fn do_something(expr: anytype) !switch(@typeInfo(@TypeOf(expr))){
	.pointer => void,
	else => @compileError("Expected pointer type, found " ++ @typeName(@TypeOf(expr))),
} {
	
}

It does make the function look a bit ugly (especially in generated documentation), though.

2 Likes

I also thought this was a good practice before:

So I wrote a library for this purpose:

But when I actually used it for my code, I found that ZLS does not execute the comptime code at this location, which means I cannot directly trigger code completion after it. Meanwhile, Zig also does not allow shadows, which makes it very inconvenient to use.

If that’s the case, why not just write @TypeOf(new) directly?

After careful consideration, I think this is more likely to be my misunderstanding. Because I really didn’t think of any scenarios that only require expression types and don’t want to have runtime side effects.

4 Likes