I am getting unexpected behaviour with the DataType Dict when trying to specify subtypes. Specifically, I want a function to be able to accept a Dict where the keys are strings and the entries are tuples containing real numbers. I would expect the following to return true:

dictionary = Dict("k6" => (1.0,3.0))
dictionary isa Dict{String, Tuple{Real, Real}}

However, it returns false. I found in the docs that this is due to type invariance:

“This last point is very important: even though Float64 <: Real we DO NOT have Point{Float64} <: Point{Real} .”

I do not fully understand though what this means and how I can specify the type declaration in the function to accept the tuple containing any real number;

Almost all parametric types are invariant in their parameters, but Tuples get to be covariant because concrete Tuples are a composite of any number of concrete types, hence the unique variation in the number of parameters. This doesn’t really help remembering, it’s easier to recognize:

concrete types can’t subtype each other

iterated unions of types with respect to type constraints

abstract type parameters alone don’t make parametric types abstract, except for Tuples acting like iterated unions

Invariance means that while Float64<:Real, Vector{Float64} is not a subtype of Vector{Real}. In fact, Vector{Real} isn’t even abstract, it is a concrete type, its instances hold pointers to elements of varying types that subtype Real. Vector{Float64} and Vector{Real} both subtype the abstract type Vector{<:Real}, which is like a shorthand for Vector{T} where T<:Real except it generates an implicit variable instead of T. T is called a type variable, and it is used to designate type parameter constraints. This shorthand for upper-bound-constraints is often used to mimick covariance, but strictly speaking it makes an iterated union of types.

Covariance is pretty intuitive, Float64<:Real, so Tuple{Float64}<:Tuple{Real}. Tuple{Float64} is a concrete type, a direct type of instances like (1.2,). Tuple{Real} is NOT a concrete type; like I said before, a concrete Tuple is a composite of concrete types, and Real is not concrete. Tuple{Real} is thus exceptionally designated an abstract type that can supertype many concrete types, being equal to (==) but not technically the same (===) as Tuple{<:Real}.

So let’s bring that back to this example. The concrete type here is Dict{String, Tuple{Float64, Float64}}.

The first comment suggested Dict{String, Tuple{<:Real, <:Real}}; all the parameters are types, not constraints, so we are looking at a concrete type, which is not equal to our concrete type. Implementation-wise, our type stores float tuples directly in a vector, but this type’s corresponding vector stores pointers to tuples that instantiate various subtypes of Tuple{Real, Real}.

The second comment first suggests a counterexample Dict{<:String, Tuple{<:Real, <:Real}}. There’s a constraint there, so it’s an iterated union. But the constraint is <:String, so the only instantiable subtype is Dict{String, Tuple{<:Real, <:Real}} from before. Our concrete type is just not included.

The second comment then suggests the example Dict{String, <:Tuple{Real, Real}}. Another constraint, another iterated union. The constraint is <:Tuple{Real, Real}, and our corresponding parameter Tuple{Float, Float} is indeed a subtype per tuples’ covariance. That means Dict{String, Tuple{Float64, Float64}} is included in that iterated union.

In practice, I don’t see people writing the shorthand Tuple{<:Real}, the equivalent and shorter Tuple{Real} is written instead. I wouldn’t recommend going the other way to aesthetically match other parametric types because Julia can also force the latter tuple type: