Something like this should work. It is very close to your original code, where the only missing piece was to handle the “conversion” from Symbol values (that the obj.prop syntax produces) to Val types (that you want to use for the dispatch in order to avoid a conditional tree).
struct MyStruct
a::Int
end
# Forward `o.propname` to `getproperty(o, Val(:propname))`
#
# Rely on constant propagation to statically know about the type of `Val(prop)`
# when the value of `prop` is statically known
Base.getproperty(o::MyStruct, prop::Symbol) = getproperty(o, Val(prop))
# Default implementation: assume prop refers to a "real" field and call `getfield`
Base.getproperty(o::MyStruct, prop::Val{T}) where {T} = getfield(o, T)
# Specific methods defining new properties
# This is where dispatch avoids having a conditional tree
Base.getproperty(o::MyStruct, prop::Val{:b}) = o.a * 2
Base.getproperty(o::MyStruct, prop::Val{:c}) = o.a * 3.0
julia> ms = MyStruct(3)
MyStruct(3)
julia> ms.a
3
julia> ms.b
6
julia> ms.c
9.0
Overall, this should be optimized away by the compiler, provided that constant propagation (of the property names) can happen:
julia> b(o::MyStruct) = o.b
b (generic function with 1 method)
julia> @code_native b(ms)
.text
movq (%rdi), %rax
addq %rax, %rax
retq
nopw (%rax,%rax)