Functions in Type Declarations

Is there anyway to call functions in type declarations?

For example, something like

struct ChildParent{T}
    child::Type{T}
    parent::Type{supertype{T}}
end

This throws MethodError: no method matching supertype(::TypeVar).

I know I could do a workaround like

struct ChildParent{T, S}
    child::Type{T}
    parent::Type{S}
    function ChildParent(a::Type{T}, b::Type{S})
        if S != supertype(T)
           error("S should be of type ", supertype(T))
        end
        new{T, S}(a, b)
    end
end

However, this workaround is a lot more complicated and there’s an unnecessary type parameter now.

Obviously, these examples are not particularly useful, but in general is it possible to use functions in type declarations like this? If not is there a more elegant way to accomplish something like this?

I don’t believe this works, but it seems like it might create a lot of problems by making type information much less static. How would a type checker run without running the whole codebase? Can you use functions that haven’t been defined yet? If so, does the type definition not run at all when it’s encountered?

How about

julia> struct ChildParent{C, P}
           child::C
           parent::P
       end

julia> ChildParent(T) = ChildParent(T, supertype(T))
ChildParent

julia> ChildParent(Int)
ChildParent{DataType, DataType}(Int64, Signed)

EDIT: I guess this is a bit different from what the OP is asking.
What is the context in which you would use this?

1 Like

No, generally you cannot do computation with type parameters in this context.

You can write a macro for this if you do it frequently. Also note packages like ArgCheck.jl, which would allow you to write

@argcheck S === supertype(T) 

instead of the if ... end block above.

Personally, I have grown to like this property of Julia, as it provides a clear separation between type definitions and other things like validation. This triangular pattern of validation is quite common, but far from being general. Also, if the type calculations used generic functions, it is unclear what would happen when they are redefined.

Finally, it is common practice to put “nuisance” type parameters like S above last, where they can be omitted in some contexts.

1 Like

You are representing T twice: once in the type domain as a parameter to your struct, and a second redundant time as the child field. The same applies for S in your “workaround”.
If you do it only in the type domain, it’s much simpler, since you can call functions inside the type parameters to new (or any function call):

julia> struct ChildParent{T,S}
           ChildParent(T) = new{T,supertype(T)}()
       end

julia> ChildParent(Int)
ChildParent{Int64,Signed}()

You can then access the T and S “fields” inside functions like this:

function f(cp::ChildParent{T,S}) where {T,S}
    # T and S available here
end
4 Likes

I’m not very familiar with the implementation of the type system, so I’m not sure how this would be implemented, or if it would cause problems.

The reason why I thought this might be possible is because the following works:

f(t, s) = rand() < 0.5 ? t : s
g(t, s) = Dict{t, s}

struct Test{T, S}
    a::f(T, S)
    b::g(T, S)
end

Then either of these works (it’s a 50-50 chance on which one)

Test{Int, String}(75, Dict(5 => "test"))
Test{Int, String}("abc", Dict(5 => "test"))

So you can use functions in the type declaration, but without knowing what type T or S is representing, I can’t think of a single usecase for this.