Handling typing for optional dependencies

So I have been exploring the idea of de-coupling some of the Athena components from the framework. In other words, make some of them optional and not installed by default. E.g. one of the framework services has a constructor like:

def initialize(
  @serializer : ASR::SerializerInterface,
  @validator : AVD::Validator::ValidatorInterface,
  @annotation_resolver : ATH::AnnotationResolver,
); end

The approach I was thinking of was, say the validator component was optional, that parameter could be typed as AVD::Validator::ValidatorInterface? = nil and would be able to detect that that component is installed to wire it up so it can be provided to the constructor.

However, the problem with this is of course is if that component isn’t installed then the AVD::Validator::ValidatorInterface is not going to exist and the compiler would error because of the unknown type. My original plan to handle this was to have the framework just stub out the type so it exists but doesn’t have any implementation, nor be used as a marker that the component is installed so the @validator would be nil. The main con of that is I’d need to keep track of what types need to be stubbed, might get complicated when they’re not modules due to needing to also stub superclass, etc.

Another idea I thought of, not entirely sold on it as of yet either, is the idea of adding an annotation that would suppress the unknown type error. E.g. @[SomeAnnotationName] @validator : AVD::Validator::ValidatorInterface? = nil would not error if that type is missing at compile time, and just resolve to nil. This approach would be a bit more straightforward and obvious that the type is optional.

EDIT: Or maybe instead of an annotation, could be its own syntax? @validator : ?AVD::Validator::ValidatorInterface? = nil? :person_shrugging:

Yet another approach would be to of course leverage {% if ... %} macro to define a constructor that just entirely excludes that parameter if the component isn’t installed, but this just feels less than idea due to how verbose it would have to be, esp if more than one parameter is optional.

Curious what others thoughts are on how to best handle this!

Did you consider defining a type if it was not defined by that time? That would work as long as the require are in a specific order. But it would be less convoluted usage of if macros I believe.

Rather than doing something special at the type restriction level, I think it would be more comfortable to do something at the top level to declare optional types that would default to something like alias <optional type> = Nil if <optional type>.resolve (or whatever the macro method is) doesn’t, well, resolve.

I’m not sure how the rest of the compilation would work out, though, given <optional type that turned into Nil>.not_nil!.some_methodcould be written and some_method obviously doesn’t exist on Nil. I guess that would be a forcing function to not use the .not_nil! method for these kinds of types :grin:

Or from another angle, this could be an argument for needing some way to declare at the type restriction level “I don’t care what the type is, so long as method some_metho exists on it”, and then the type of the instance becomes moot so long as it has a specific shape.

The compiler already ignores overloads with type restrictions that don’t exist because they can never match.

This is totally fine:

def foo(x : Int32)
end

def foo(x : NotExist)
end

foo 1

When merging both overloads into one, the missing type is a problem though:

def foo(x : Int32 | NotExist)
end
 
foo 1 # Error: undefined constant NotExist

Perhaps this should be valid code?
We could enhance the compiler to ignore undefined members in a union type restrictions. Semantically, this makes sense: NotExist is undefined, thus we can never encounter an instance of it. We can ignore it and produce an entirely valid program.

I’m not sure how well this plays with type annotations in other places than method parameters, though.

I don’t think so. It would be indistinguishable from typos

3 Likes

Would this be any different than defining them regardless and just let monkeypatching handle merging in the implementation when the component is required?

Oo I never considered making the type an alias to another type :eyes:. I tried this out with:

alias Athena::Validator::Validator::ValidatorInterface = Nil

# ...

def initialize(
  @serializer : ASR::SerializerInterface,
  @annotation_resolver : ATH::AnnotationResolver,
  @validator : Athena::Validator::Validator::ValidatorInterface? = nil,
); end

Unfortunately, compiler doesn’t like this:

 213 | @validator : Athena::Validator::Validator::ValidatorInterface? = nil,
       ^
Error: instance variable @validator of SomeType was inferred to be Nil, but Nil alone provides no information

Reminds me a bit of some of the implicit interface stuff talked about in [RFC] Standardized Abstractions.

This would work quite well for this use case yea! Tho as I brought up earlier maybe there would be a world in which it’s more opt-in via new syntax/annotation/type or something as to @bcardiff’s point to silently ignoring errors when you might actually want them.

  • Annotation: @[Optional] @validator : ValidatorInterface? = nil
  • Syntax: @validator : ?ValidatorInterface? = nil (prefix with ? or something along those lines)
  • New Type: @validator : Optional(ValidatorInterface) = nil (Tho might run into the same problem as aliasing it to Nil)

Yea, looking at the code for my use case again, this wouldn’t be a 100% solution as there is still code later on doing like:

if object.is_a?(Array) && constraints && !constraints.is_a?(Athena::Validator::Constraints::All)
  constraints = Athena::Validator::Constraints::All.new constraints
end

Which I would expect to error when not used as well. Granted guarding some block of code with macro if logic would be a lot more straightforward than the constructor. So may still be useful to have.


Another idea would be to do some refactoring to extract the logic into another type that could be provided/called but that just would no-op when the validator component isn’t installed. Tho I suppose this is really just moving the macro if logic to somewhere else and not really eliminating the problem.

Either way it’s def seeming like macro if logic is going to be the best option, as even if i were to stub out the types, will also have to handle constructors of those types and such as well and that’s just not worth the effort.

I think that class vs. struct and inheritance can get in the way of partial type declarations.

In crystal-env/src/crystal-env/core.cr at master · crystal-lang/crystal-env · GitHub I added some logic to implement a Default value. Not the same problem put I would use a similar solution.

In a broader sense I think that your case falls into how to support plugins, optional or peer dependencies. It’s a use case that is not first class in crystal or shards and hence you are getting some friction on how to encode that with the available tools. Maybe focusing on that problem, and to make it first class would lead to nicer solutions.

1 Like

My first example could also hide a typo, though.
Expanded a bit for demonstration.

class Foo; end
class Bar < Foo; end

def foo(x : Foo)
  1
end

def foo(x : Bat) # <- This could be mistyped `Bar`
  2
end

foo Bar.new # => 1

This has always been okay, even though it could easily hide a potential mistake.
That doesn’t automatically legitimize introducing something similar, but I think it could be an argument to not entirely discount the idea.

If you make interfaces then you can have swappable objects, and use fake (or null) types by default:

module Foo
  abstract class Interface
    abstract def something
  end

  class Default < Interface
    def something; end
  end

  # use singleton so it's initialized once per program
  class_getter(default : self) { Default.new }
end

def foo(foo : Foo::Interface = Foo.default)
end

When the actual interface is required, it can replace the default constructor:

require "foo/interface"

module Foo
  class Real < Interface
    def something
      puts "I'm doing something"
    end
  end

  # no singleton, we want distinct objects
  def self.default : self
    Real.new
  end
end

It’s kinda verbose, requires boilerplate and ceremony, but then you don’t have to deal with nils at runtime + the optimizer will drop the empty calls by default.

Bonus: the interface is swappable (can implement alternative, can use mock in specs, …).

1 Like