Enforcing higher-order type parameter constraints

One design pattern I keep stumbling upon is that I want to generate parametric types based on existing parametric types, but with certain constraints among parameters, such that inappropriate combinations are prevented from ever being defined by the user (and causing further trouble).

For example, let’s say I have types Type1{S} and Type2{S,T} and I want to define a new type Type3{U,V} such that U<:Type1{S} and V<:Type2{S,T}, but in addition I want to enforce that the parameter S in both parameters is the same. I.e. trying to call or define a Type3{U,V} for some U<:Type1{S} and V<:Type2{S2,T} will produce an error whenever S and S2 are different.

The problem I have is that I do not really know how to create such types in a Julia-paradigmatic way, or even whether this is the best way to solve the problem I have (i.e. excluding types with inconsistent parameters to exist).

When I want Type3 to be an abstract type, I can do

# correct syntax, but not the desired result
abstract type Type3{U<:Type1{S} where S, V<:Type2{S,T} where {S, T}} end

but this does not force the S in both U and V to be the same.
The following does not work, but should give a flavor of what I’m trying to do:

# throws error `syntax: invalid type signature`
abstract type Type3{U<:Type1{S}, V<:Type2{S,T}} where {S,T} end

The following works for structs, but is probably a bit too convoluted (especially if the pattern keeps coming up), and cannot be done for abstract types because they lack constructors:

# correct syntax and outcome
struct Type3{S, T, U, V}
    x::U
    y::V
    Type3(x::Type1{S}, y::Type2{S,T}) where {S, T} = new{S, T, typeof(x), typeof(y)}(x, y)
end

Slightly simpler would be the following (which again does not work):

# throws error `syntax: invalid type signature`
struct Type3{U<:Type1{S}, V<:Type2{S, T} where {S, T}
    x::U
    y::V
end

Use an inner constructor to validate.

Thanks. Yes that’s what I’m doing in my third code example. But this cannot be replicated for abstract types.

The type system is not a programming language. Its primary use is for dispatch. You cannot enforce arbitrary constraints for abstract types, but since every object eventually has concrete type, this should not matter.

If you are computing on types for some reason, you can wrap them in a type and use the constructor of that. But generally, this may not be an idiomatic solution.

If you described your actual problem with an MWE, it would be possible to give more specific help.

Right, dispatch is the primary use, but I’m also looking to set up a hierarchy of abstract types that guides the user in reasoning about the problem and prevents from doing thinks that do not make sense in the given domain.

My use case is the following. I start with arbitrary type parameters {S,T} for the state spaces of models. based on that, I have abstract types for the models AbstractModel1{S} and AbstractModel2{S,T}. There are concrete types of models that will be defined as structs, e.g.

struct ConcreteModel1{S} <: AbstractModel1{S} end
struct ConcreteModel2{S,T} <: AbstractModel2{S,T} end

which will contain some fields that describe the model, e.g. distributions or functions etc.

Then I want to define problems that consist of a pair of models AbstractModel1{S} and AbstractModel2{S,T}. But the domain requires the S to be consistent in order for the problem to make sense. I could define

abstract type AbstractProblem{S,T} end

and then when there is a concrete type of problem I can use an inner constructor as you suggest

struct ConcreteProblem{S,T,U,V}  <: AbstractProblem{S,T}
    model1::U 
    model2::V
    function ConcreteProblem(model1::AbstractModel1{S}, model2::AbstractModel2{S,T}) where {S,T}
        new{S,T,typeof(model1),typeof(model2)}(model1,model2)
    end
end

preventing invalid combinations of models to be defined.
However, this does not allow me to dispatch on model types for abstract problem types. Let’s say I want to define a function

Foo(problem::AbstractProblem)

but I want to dispatch Foo based on the types of models that define problem. With the solution above, I can only dispatch on S and T. Thus I cannot do

Foo(problem::AbstractProblem{ConcreteModel1{S},ConcreteModel2{S,T}}) where {S,T}

because models were not parameters of AbstractProblem.
On the other hand, if I define

abstract type NewAbstractProblem{U<:AbstractModel1, V<:AbstractModel2} end

I can do the above dispatch, but this allows NewAbstractProblem to be defined even if the parameters U and V are inconsistent. It also complicates the definition of concrete types.

Yes, I think this is the right solution. Note that using type parameters which are more or less ignorable by the user is a common practice, eg

julia> typeof(@view ones(1,2)[1, :])
SubArray{Float64,1,Array{Float64,2},Tuple{Int64,Base.Slice{Base.OneTo{Int64}}},true}

I am not sure that’s a good use of abstract types in Julia. IMO ideally, your interface should work via functions that do things, and the user should not care much about types (except when extending your code, of course).

Thanks. BTW, I added some more paragraphs after you replied. I hit the Reply button prematurely.

Thanks, I have to think about this.
My thinking behind having a good hierarchy of abstract types was that it will lead to more generic code. In the example above, by having model types as parameters to problem types, I do not have to define a new type of problem for each combination of model types. The user interface (through functions) should also reflect this.

Maybe I should define AbstractProblem{S,T,U<:AbstractModel1,V<:AbstractModel2} with all parameters explicit and then have all functions that act on this type to be defined only for valid combinations of {S,T,U,V}. So the type hierarchy is not safe, but a MethodError will be thrown whenever the user tries to use something unreasonable.

1 Like

Why not something like this:

struct Type3{S, T}
  x::Type1{S}
  y::Type2{S, T}
end

With a design like this, you don’t have more parameters than necessary and constraints between them: you have just as many parameters as needed, and you build the necessary derived types from them.

1 Like

Translating this to my domain would mean either this:

struct ConcreteProblem{S, T}
  x::AbstractModel1{S}
  y::AbstractModel2{S, T}
end

The problem of this solution is that it will create type instabilities because the fields of ConcreteProblem are abstract types. A better solution would be to define a concrete type for each combination of concrete models, like this:

struct ConcreteModel1{S} <: AbstractModel1{S} end
struct ConcreteModel2{S,T} <: AbstractModel2{S,T} end

struct ConcreteProblem{S, T}
  model1::ConcreteModel1{S}
  model2::ConcreteModel2{S, T}
end

This will be slightly cumbersome if I have a lot of different concrete models.
In addition, since the types of the fields of ConcreteProblems are not parameters, I will not be able to dispatch on them, i.e. the following will not work:

function DoSomething(problem::AbstractProblem{ConcreteModel1{S}, M2}) where {S,M<:AbstractModel2} 
# do something that only works for problems that involve ConcreteModel1.
end

To my current knowledge the only way to combine these different requirements is to have four parameters as mentioned in my last post, i.e. define

abstract type AbstractProblem{S,T,U<:AbstractModel1,V<:AbstractModel2} end

and then concrete problems with inner constructors that enforce the type constraints, i.e.

struct ConcreteProblem{S,T,U<:AbstractModel1,V<:AbstractModel2} <: AbstractProblem{S,T,U,V}
     model1::U 
     model2::V 
     function ConcreteProblem(x::AbstractModel1{S}, y::AbstractModel2{S,T}) where {S,T}
        new{S,T,typeof(x),typeof(y)}(x,y)
     end
end

Then this becomes possible

function DoSomething(problem::AbstractProblem{S,T,ConcreteModel1{S},M2}) where {S,T,M<:AbstractModel2} 
# do something that only works for problems that involve ConcreteModel1.
end

Is there an easier way?

Have you tried ComputedFieldTypes.jl? I upgraded it to 1.0 half a year ago and used it in Grassmann.jl to make types which error with the wrong array sizes. The array size is parametrically computed based on the type parameters.

1 Like

That does make sense, thanks! Then I do not see any way to achieve what you want other than what you posted in the end.

I found another way of enforcing type constraints in abstract types using type aliases.
After having defined

abstract type AbstractProblem{S,T,U<:AbstractModel1,V<:AbstractModel2} end

I can define

const ConsistentProblem = AbstractProblem{S,T,<:AbstractModel1{S},<:AbstractModel2{S,T}} where {S,T}

The structs that are subtypes of ConsistentProblem still have to be protected by custom constructors. But this abstract type allows to define functions that can safely assume that the types in models are consistent:

function DoSomething(prob::ConsistentProblem)
    # do something that relies on consistent models in prob
end

Thanks for pointing me to these packages. They are interesting!