Constant propagation magic

I carried out a little study to better understand type-stable overloading of getproperty and getindex with NamedTuple by using Val. Please see the (more or less) MWE below.

I think I understand almost everything in that script, except why the second to last example is type stable:

function test_symbols(wrapper, keys)
    for k in keys
        @inferred wrapper[Val(k)]
    end
end

test_symbols(wr, (:t, :x))

Why has passing several keys in a Tuple and wrapping them into Val in a loop an advantage over passing just a single key as Symbol and wrapping it into Val as in the last example (where the wrapping happens in getindex)?
I see why the last example is not type stable, but I do not see why the second to last example is type stable.

using Test

# The type `Wrapper` has only one field that is a `NamedTuple`
struct Wrapper{wrapperType<:NamedTuple}
    data::wrapperType
end

function Wrapper(data::NamedTuple)
    Wrapper{typeof(data)}(data)
end

# `hasproperty` checks if `s` is either a key of the `NamedTuple` or a field in `Wrapper`
function Base.hasproperty(::Wrapper{WT}, s::Symbol) where {WT}
    hasfield(WT, s) || hasfield(Wrapper, s)
end

# `getproperty` checks if `s` is a key of the `NamedTuple` and if so returns the corresponding value,
# if not it returns the corresponding field of `Wrapper`
function Base.getproperty(w::Wrapper{WT}, s::Symbol) where {WT}
    if hasfield(WT, s)
        return getfield(getfield(w, :data), s)
    else
        return getfield(w, s)
    end
end

# Return wrapped NamedTuple
Base.parent(w::Wrapper) = w.data

# This getindex method recieves the key symbol wrapped in Val,
# which implies that the value is known at compile time
Base.getindex(w::Wrapper, ::Val{s}) where {s} = getindex(parent(w), s)

# This getindex method wraps the key symbol into a Val, and passes it on to the above method
Base.getindex(w::Wrapper, s::Symbol) = getindex(w, Val(s))


### Test for type stability ###

# Create a `Wrapper` instance based on some arbitrary data (with different types)

data = (
    t=0.0,
    x=rand(3),
)

wr = Wrapper(data)

# Accessing fields of NamedTuple via forwarding in getproperty is type-stable
# as the value of the key symbol is known at compile time:

test_t(wrapper) = wrapper.t
test_x(wrapper) = wrapper.x

@inferred test_t(wr)
@inferred test_x(wr)


# Accessing fields of NamedTuple via getindex of Val{<:Symbol} is type stable

test_symbol(wrapper, s) = wrapper[s]

@inferred test_symbol(wr, Val(:t))
@inferred test_symbol(wr, Val(:x))

@inferred wr[Val(:t)]
@inferred wr[Val(:x)]


# Accessing fields of NamedTuple via getindex of Val{<:Symbol} with
# Val wrapping inside a loop is also type stable

function test_symbols(wrapper, keys)
    for k in keys
        @inferred wrapper[Val(k)]
    end
end

test_symbols(wr, (:t, :x))


# Accessing fields of NamedTuple via getindex of Symbol that wraps the key
# symbol into a Val is not type stable

@inferred test_symbol(wr, :t)
@inferred test_symbol(wr, :x)

This was tested on

Julia Version 1.13.0-beta2
Commit f36fbbfd951 (2026-02-04 11:07 UTC)
Build Info:
  Official https://julialang.org release
Platform Info:
  OS: macOS (arm64-apple-darwin24.0.0)
  CPU: 16 × Apple M4 Max
  WORD_SIZE: 64
  LLVM: libLLVM-20.1.8 (ORCJIT, apple-m4)
  GC: Built with stock GC
Threads: 1 default, 1 interactive, 1 GC (on 12 virtual cores)

Quick answer: I suspect it’s not, not really.

The @inferred macro dynamically looks at the types of the arguments of the outermost function, and just ensures that the function called with those types is type stable. In this case, that’s the getindex call (from the [] syntax). If you look at @code_warntype test_symbols(wr, (:t, :x)), I’ll wager there’s a whole slew of red.

Now, it might also still be type stable through some constant propagation magic in some compiled contexts, but I don’t think that’s what’s happening here.