I’ve been using the Real type in some of my functions that take direct user-input, to avoid spurious errors around the use of Int or Float64; however, I encountered an issue when trying to do this with an argument to a function that was NamedTuple{(:a, :b), Tuple{Real, Real}}.

The function below works as expected, and accepts Tuple arguments with any combination of Float64 and Int.

function testA(x::Tuple{Real,Real})
print(x)
end

However, the function testB returns error for every combination of Int and Float64 within the NamedTuple.

function testB(x::NamedTuple{(:a, :b),Tuple{Real,Real}})
print(x)
end

I have a workaround:

function testC(x::NamedTuple{(:a, :b), Tuple{T, U}} where {T <: Real , U <: Real})
print(x)
end

This works as expected for all inputs of Int and Float64.

To summarise my question: why don’t Tuple and NamedTuple work the same way?

function testC(x::NamedTuple{(:a, :b), Tuple{<:Real,<:Real}})
print(x)
end

or even with <:Tuple{Real,Real} instead.

(Meta-comment: Perhaps you don’t even need to bother declaring your method with such specific types. There really is no harm in just defining testC(x) or testC(x::NamedTuple) unless you are needing specific behaviour from multiple dispatch.)

This is just how the Julia type system is designed. Tuple types are contravariant, and they’re, in fact, the only non-invariant concrete types (hope I’m not misusing terminology):

For more, see the Julia Manual, and perhaps even Wikipedia.

BTW, another, more general, approach for your “workaround” would be something like this:

julia> testG(x::NamedTuple{syms, <:NTuple{n, Real}}) where {syms, n} = print(x)
testG (generic function with 1 method)
julia> testG((a = 1, c = 3))
(a = 1, c = 3)
julia> testG((z = 4.3, r = big"5"))
(z = 4.3, r = 5)

This should work for any NamedTuple, as long as all values are subtypes of Real.

Hopefully the following is a bit easier to digest than the various “__-variance.”

The general rule is that an abstract type parameter only serves as a restriction of field/element types (often implemented as pointers to boxes) and does not make the type itself abstract. So a Vector{Real} is a concrete type you can instantiate; it isn’t a supertype (which are abstract) of Vector{Int} or Vector{Float64}. Vector{T} where T<:Real, or as an older shorthand Vector{<:Real}, is an abstract supertype of all the aforementioned types.

Tuple is the notable exception to that rule. Tuples are a special type in the language to package many concrete instances into one instance. There’s no way for a concrete Tuple to specify a particular element as being restricted by an abstract type, the way the concrete Vector{Real} restricts elements. Confusingly, abstract type parameters are allowed for Tuple, but since it can’t mean an element type restriction, it instead does make the type abstract. You can consider Tuple{Real} to be equivalent to Tuple{T} where T<:Real.

NamedTuple contains a Tuple (which is why the Tuple’s type is a type parameter of NamedTuple), but unlike the Tuple, it still follows the general rule.

I’m not really clear on the terminology myself, but Vararg is also weird like Tuple e.g Vararg{Int,3} <: Vararg{Real,3}. Vararg are not concrete types, but they do sometimes annotate varargs methods so it’s worth looking out for.