The best way(s) to encode binary type parameter choices

Say I have a parametric type Foo{B} and I want to restrict B to two possible values. I can think of a couple of ways to do this:

  1. Have B take on a Boolean value and use an assertion in the inner constructor to verify this. This is what I usually see recommended when invariants have to be enforced on type parameters which are values.
struct Foo{B}
    function Foo{B}() where {B}
        @assert B isa Bool "B must be Boolean (got $B)"
        return new()
    end
end

However, it has one major disadvantage: no error is produced if an invalid type is constructed (as opposed to an instance of the type).

julia> Foo{Int}()
ERROR: AssertionError: B must be Boolean (got Int64)
Stacktrace:
 [1] Foo{Int64}()
   @ Main ./REPL[2]:3
 [2] top-level scope
   @ REPL[5]:1

julia> Foo{Int}
Foo{Int64}
  1. Restrict B being either Val{true} or Val{false}. This does cause an error if an invalid type is constructed.
struct Foo{B<:Union{Val{true},Val{false}}
end
julia> Foo{Val{true}}
Foo{Val{true}}

julia> Foo{Val{false}}
Foo{Val{false}}

julia> Foo{Int}
ERROR: TypeError: in Foo, in B, expected B<:Union{Val{true}, Val{false}}, got Type{Int64}
Stacktrace:
 [1] top-level scope
   @ REPL[5]:1

julia> Foo{Val{2}}
ERROR: TypeError: in Foo, in B, expected B<:Union{Val{true}, Val{false}}, got Type{Val{2}}
Stacktrace:
 [1] top-level scope
   @ REPL[6]:1

However, it feels clunky (and possibly an abuse) to introduce Val here, and I’ve never seen a design pattern like this recommended anywhere.

  1. Create types that only act as type parameters for Foo{B}, and restrict B to a supertype containing them. I’m not sure if Bar and Baz could be considered traits, but that’s what they feel like. This is the most explicit approach, and the one I’d personally favor (certainly if the parameter may take on more values). One could argue that something like this would clutter the namespace.
abstract type BarOrBaz
end

struct Bar <: BarOrBaz
end

struct Baz <: BarOrBaz
end 

struct Foo{B<:BarOrBaz}
end

Are there reasons I should favor one approach over the other?

2 Likes

How about this instead?

struct Foo{B} 
    function Foo{B}() where {B}
        if B isa Bool
            return new()
        else
            error("Type parameter $B is not a Bool")
        end
    end
end

Are you suggesting this so that @assert does not get disabled in certain circumstances?

Unlike your Case 1., my code disallows anything but Bool as the type parameter:

julia> Foo{true}()
Foo{true}()

julia> Foo{false}()
Foo{false}()

julia> Foo{-1}()
ERROR: Type parameter -1 is not a Bool
Stacktrace:
 [1] error(s::String)
   @ Base .\error.jl:35
 [2] Foo{-1}()
   @ Main .\REPL[1]:6
 [3] top-level scope
   @ REPL[4]:1

julia> Foo{1.0}()
ERROR: Type parameter 1.0 is not a Bool
Stacktrace:
 [1] error(s::String)
   @ Base .\error.jl:35
 [2] Foo{1.0}()
   @ Main .\REPL[1]:6
 [3] top-level scope
   @ REPL[5]:1

Yes, the reason I use error rather than @assert is because of the warning about @assert in the Julia manual:

The else clause takes care of the case where the type parameter is other than Bool which your code in your Case 1. did not address.

Well, I just realized I’ve been using @assert incorrectly in a lot of my code. Oops. Aside from @assert being disabled, our code appears to work identically, though?

julia> struct Foo{B}
           function Foo{B}() where {B}
               @assert B isa Bool "B must be Boolean (got $B)"
               return new()
           end
       end

julia> Foo{1}()
ERROR: AssertionError: B must be Boolean (got 1)
Stacktrace:
 [1] Foo{1}()
   @ Main ./REPL[1]:3
 [2] top-level scope
   @ REPL[2]:1

julia> Foo{Int}()
ERROR: AssertionError: B must be Boolean (got Int64)
Stacktrace:
 [1] Foo{Int64}()
   @ Main ./REPL[1]:3
 [2] top-level scope
   @ REPL[3]:1

julia> Foo{1.0}()
ERROR: AssertionError: B must be Boolean (got 1.0)
Stacktrace:
 [1] Foo{1.0}()
   @ Main ./REPL[1]:3
 [2] top-level scope
   @ REPL[4]:1

Why not constrain the type parameter to be Bool?

struct Foo{B<:Bool}
   function Foo{B}() where B<:Bool
      new{B}()
   end
end

Then Foo{Bool}() and Foo{Bool} work fine, while Foo{Int64} and the like is an error.

What OP wants to do is Foo{true}() or Foo{false}(), not Foo{Bool}().

1 Like

This one is maybe the best. Except I’d make the supertype a Union instead of an abstract type (that way both you and the compiler’s subtyping know there will not be more subtypes added later):

struct Bar end
struct Baz end
const BarOrBaz = Union{Bar, Baz}
struct Foo{B <: BarOrBaz} end

However keep in mind this design still allows invalid types such as Foo{BarOrBaz} or Foo{Union{}}.

One thing you could perhaps do is restrict the type parameter to be a subtype of a concrete type, so the only valid types would be Foo{Union{}} and Foo{Concrete}:

struct Concrete end
struct Foo{P <: Concrete} end
3 Likes

You’re right, I missed your distinction between constructing a type and an instance of a type in your original post.

This is a really interesting option I hadn’t considered. It would certainly lock the possible types down to two.

One reason I think it’s useful to include true and false specifically as type parameters is because these values might be useful in methods using these types. The concrete example I’ll give is CliffordNumbers.Z2CliffordNumber{P,Q,T,L}, which has two aliases:

const EvenCliffordNumber{Q,T<:BaseNumber,L} = Z2CliffordNumber{false,Q,T,L}
const OddCliffordNumber{Q,T<:BaseNumber,L} = Z2CliffordNumber{true,Q,T,L}

Usually, we deal with even or odd grade elements of Clifford algebras (and not mixed grade elements). The type parameter P encodes the parity of the grade, which allows a decent amount of simplification when writing algorithms.