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!

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.

3 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.

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