Why don't `Any` constraints check that the parameter is a type

Why is a <:Any constraint equivalent to there being no constraint?

For abstract types:

julia> abstract type A{T <: Any} end

julia> A{2}  # Succeeds even though 2 doesn't subtype Any
A{2}

For concrete types:

julia> struct S{T <: Any} end

julia> S{2}  # Succeeds even though 2 doesn't subtype Any
S{2}

For methods:

julia> f(::Val{T}) where {T <: Any} = nothing
f (generic function with 1 method)

julia> f(Val{2}())  # Succeeds even though 2 doesn't subtype Any

The above behavior seems to be inconsistent with how subtyping constraints work for all other types except for Any, a constraint A <: B is only valid if A subtypes B, except if B is Any. For example:

julia> struct R{T <: Number} end

julia> R{2}
ERROR: TypeError: in R, in T, expected T<:Number, got a value of type Int64

The operator <:, in expressions, doesn’t exhibit this inconsistency: 2 <: Any fails just the same as 2 <: Number:

julia> 2 <: Any
ERROR: TypeError: in <:, expected Type, got a value of type Int64
julia> 2 <: Number
ERROR: TypeError: in <:, expected Type, got a value of type Int64

EDIT: fixed source code formatting

4 Likes

It’s actually the other way around - only specifiying abstract type A{T} end is equivalent to T <: Any, and it is the 2 and Val(2) that are special cased to be allowed here. The <: Any “check” is never done at all, if the effective result is “allow anything”. See here:

  • Both abstract and concrete types can be parameterized by other types. They can also be parameterized by symbols, by values of any type for which isbits returns true (essentially, things like numbers and bools that are stored like C types or structs with no pointers to other objects), and also by tuples thereof. Type parameters may be omitted when they do not need to be referenced or restricted.
1 Like

That’s the question though: why is the check not done, since the effective result if it was done would not be to allow anything…

The doc says that <: checks for a subtype-supertype relationship, so it would make sense for {T} to accept any isbits value and for {T <: Any} to accept only type values.

2 Likes

That’s the answer, though: we don’t have a separate syntax or internal representation for T{X::Any}.

The only way value parameters work right now is because the <: Any constraint is not enforced. I could’ve sworn there was an issue tracking the isa constraint for type parameters, but I cannot find it.

1 Like

Put another way - since Any is the supertype of all types (even itself), there’s nothing to check - anything in the type system will fulfill the requirement of being a subtype of Any, by definition. Additionally, we also allow objects that are isbits to match such a clause. That doesn’t mean that the objects themselves are a subtype of Any though and that’s not what the <: is doing here. It’s not really a check for conformance, but more communication about the requirements of the type - in that interpretation, <: Any means “I don’t have any particular requirements”.

Alas, this shows a hole in our type system - we can’t write A{T isa Int} after all (though we probably could make it so, the utility is a bit questionable).

I wouldn’t work so hard to justify the status quo; it’s just a necessary practicality do make Julia act the way we want.

5 Likes

But this time I’m not asking for constraints on non-type as type parameters (I can already enforce such constraints by wrapping the non-type in a singleton type). What I’m instead asking for in this post is for the <:Any constraint to have a different effect than no constraint. It would be an improvement for the safety of Julia code if the <:Any constraint was actually effectful, i.e., if it checked that the parameter is an actual type. For example, if I define two types like:

# the parameter can be a non-type
struct S{s} end

# the parameter must be a type
struct R{T <: Any} end

I would expect and like it if R{3} was caught as an error because of that <:Any constraint. Instead it seems that the constraint is just ignored as a consequence of the current implementation.

EDIT: I guess that an alternative proposal would be to introduce a new abstract type that subtypes Any, with all other types being subtypes of the new type. Then I could use the constraint with the new type, and it would work as-is.

I’m not sure I follow - what is the tangible increase in safety (from what)? Or is it that you’d like to get an error when calling the constructor, rather than inside of it?

While looking for a workaround, I found out that even constraints using UnionAll, like <:(AnyType where {AnyType}), have no effect. For example:

# Sadly behaves as if there's no constraint
struct S{T <: (AnyType where {AnyType})} end

# Likewise
struct R{T <: (Union{AnyType} where {AnyType})} end

Sorry, but what does “inside of it” mean?

I’d like to constrain my type parameters to be types, i.e., disallow non-type values for certain (most) parameters. The error should happen independently of constructing the object, i.e., something like S{3} should fail.

Currently, if your type expects to be passed a type in its constructor (but only has <: Any or untyped for that parameter), but is passed something else instead, the error happens inside of the constructor, i.e. when it is called. What I was referring to is that if you could restrict T to be an instance of an isbitstype (or conversely, require T to be a type and disallow instances of types), you’d get a MethodError instead, i.e. an error outside of the constructor, since there is no matching method/constructor to be called.

Yes, that cannot be expressed right now. What S{3} is doing is constructing an instance of DataType, by filling the type parameters:

julia> struct S{T} end

julia> S{3} |> dump
S{3} <: Any

julia> S{3} |> typeof
DataType

julia> Meta.@lower S{3}
:($(Expr(:thunk, CodeInfo(
    @ none within `top-level scope`
1 ─ %1 = Core.apply_type(S, 3)
└──      return %1
))))

If I understand correctly, you want to introduce a distinction between instances of isbitstypes and instances of DataType, to be matched when constructing the S{3} instance of DataType?

1 Like

Yeah, but you’re making it sound like I’m asking for an exception. It’s the opposite, I’m just asking for consistency, for the root of the type system, Any, to behave like other types. Like I’ve shown in the original post above, any other type except Any will behave as expected.

1 Like