Understanding how subtypes work within NamedTuple

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?

What you see here is maybe similar to the behaviour explained here
https://m3g.github.io/JuliaNotes.jl/stable/typevariance/

I actually also have to read up why Tuple{Float64} <: Tuple{Real} works :smiley: but as the post shows, NamedTuple and also Vector behave similarly here.

1 Like

Thanks.

I can see why this would be required with vectors, since the function could try to alter them and an error would occur if the type weren’t correct.

However, since Tuples are immutable, I think that allowing subtypes to be substituted makes sense.

By the way, your workaround may be written as:

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

2 Likes

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):

julia> NamedTuple{(:a, :b),Tuple{Int, Int}} <: NamedTuple{(:a, :b),Tuple{Real, Real}}
false

julia> Tuple{Int} <: Tuple{Real}
true

Relevant FAQ entry (especially the second paragraph):
Frequently Asked Questions · The Julia Language?

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.

3 Likes

Thanks for the suggestions. I should be able to tidy my code up using the in-line <:.

(I’m using explicit types so that errors can be caught early.)

1 Like

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.

2 Likes

I think this may be because Vararg is syntax sugar for NTuple, or something like that. They’re related for sure.

2 Likes

The Julia developer documentation has a good explanation relevant to this discussion for the Tuple part of the NamedTuple type here:
https://docs.julialang.org/en/v1/devdocs/types/#Tuple-types

1 Like