Skip to content

assert x is T smart-casts to the interface box instead of its payload #28143

Description

@davlgd

Describe the bug

After assert err is MyError, subsequent uses of err in the same block are smart-cast to MyError, but code generation passes the address of the IError box rather than the address of the payload it holds. Every field read through that cast returns garbage — in practice a zeroed struct — with no crash and no diagnostic.

if err is MyError { ... } generates the correct unwrapping, so the two forms of the same smart cast disagree. The value returned by err.msg() therefore changes depending on whether a preceding assert err is MyError is present, which is the opposite of what a type assertion is supposed to guarantee.

This is not specific to IError; it is the assert <interface> is <Type> smart cast that is wrong. It shows up most often with errors because or blocks are where interface values are most commonly narrowed.

Reproduction Steps

Calling the same method before and after the assertion returns two different values:

struct MyError {
	Error
pub:
	reason string
}

pub fn (e &MyError) msg() string {
	return 'my error: ${e.reason}'
}

fn fails() ! {
	return MyError{
		reason: 'boom'
	}
}

fn main() {
	fails() or {
		before := err.msg()
		assert err is MyError
		after := err.msg()
		println('before the type assert : [${before}]')
		println('after  the type assert : [${after}]')
		println('equal                  : ${before == after}')
	}
}

The same defect makes a test assertion fail, and there it also depends on -stats:

struct MyError {
	Error
pub:
	reason string
}

pub fn (e &MyError) msg() string {
	return 'my error: ${e.reason}'
}

fn fails() ! {
	return MyError{
		reason: 'boom'
	}
}

fn test_without_the_type_assert() {
	fails() or { assert err.msg() == 'my error: boom' }
}

fn test_with_the_type_assert() {
	fails() or {
		assert err is MyError
		assert err.msg() == 'my error: boom'
	}
}

v test repro_test.v fails on test_with_the_type_assert, while v -stats test repro_test.v reports both tests as passing. Only the second test is affected; removing the assert err is MyError line makes it pass.

Expected Behavior

err.msg() returns my error: boom whether or not it is preceded by assert err is MyError, and v test and v -stats test agree on the outcome.

Current Behavior

before the type assert : [my error: boom]
after  the type assert : [my error: ]
equal                  : false

The override is dispatched correctly — the my error: prefix comes from MyError.msg — but e.reason reads as an empty string, because the receiver does not point at the MyError value.

For the test file:

V panic: Assertion failed...
repro_test.v:24: fn test_with_the_type_assert
> assert err.msg() == 'my error: boom'

Both reproductions are deterministic: 3 runs each, identical results. They reproduce with -cc clang, -cc gcc and -cc tcc, in debug and with -prod, which rules out the C compiler and its optimiser.

Possible Solution

The generated C shows the two smart casts side by side. With if err is MyError:

if (err._typ == 1654694011) {
	println(MyError__msg((main__MyError*)(err._object)));

With assert err is MyError:

int _t1 = (int)(err._typ);
if (!(_t1 == 1654694011)) { /* panic */ }
println(MyError__msg(&err));

The if form dereferences err._object and casts it to the concrete type; the assert form passes &err, which is an IError*. Since IError begins with _typ and _object, the callee reads the box header as if it were the struct, so reason lands on unrelated bytes.

The fix is to make the smart cast registered by assert <iface> is <Type> emit the same (T*)(x._object) unwrapping that the if form already emits, rather than taking the address of the interface variable.

Additional Information/Context

The -stats discrepancy in the second reproduction is an observation, not a separate claim about its cause: with -stats the assertion goes through the test runner's reporting path and the wrong cast does not appear, so the same source passes. It is worth mentioning because it makes the bug easy to misdiagnose — a failing test suite that turns green as soon as it is run verbosely looks like flakiness rather than miscompilation.

Found while writing vlib/crypto/scram, where the idiomatic way to test a typed error is assert err is SomeError followed by an assertion on its message. The workaround is to assert on the message first and narrow the type afterwards, or to use if err is SomeError { ... }, but both hide a silent miscompilation rather than fixing it. Any code that reads fields through an assert ... is ... cast is currently reading garbage.

V version

V 0.5.2 7895be1.28128d9 (git 0.5.2-278-g5b7fa858)

Environment details (OS name and version, etc.)

|V full version      |V 0.5.2 7895be1be8401122b9c014f774b0ec0ec4bc0073.28128d9
|OS                  |macos, macOS, 26.6, 25G72
|Processor           |12 cpus, 64bit, little endian, Apple M3 Pro
|V executable        |/Users/my-account/v/v
|V home dir          |OK, value: /Users/my-account/v
|VMODULES            |OK, value: /Users/my-account/.vmodules
|VTMP                |OK, value: /tmp/v_501
|Git version         |git version 2.50.1 (Apple Git-155)
|V git status        |0.5.2-278-g5b7fa858
|.git/config present |true
|cc version          |Apple clang version 21.0.0 (clang-2100.1.1.101)
|gcc version         |Apple clang version 21.0.0 (clang-2100.1.1.101)
|clang version       |Apple clang version 21.0.0 (clang-2100.1.1.101)
|tcc version         |tcc version 0.9.28rc 2026-07-24 HEAD@85ba3ae8 (AArch64 Darwin)

Note

You can use the 👍 reaction to increase the issue's priority for developers.

Please note that only the 👍 reaction to the issue itself counts as a vote.
Other reactions and those to comments will not be taken into account.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugThis tag is applied to issues which reports bugs.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions