steep icon indicating copy to clipboard operation
steep copied to clipboard

Generics Don't Respond Properly to Common Methods or Nil Checks

Open retroandchill opened this issue 1 year ago • 7 comments

Hi all. I'm currently working on trying to implement a generic class that has a nilable instance variable of the generic type, however Steep is having issues with handling the generic type.

There a two issues at hand here as far as I can tell. The first is that generic variables despite all types in Ruby being children of the Object type, do not recognize that they do have access to these methods. The same goes for interfaces as well. This means you can't use methods like nil? or is_a? to verify the type in question.

The second issue here is that if you add a constraint to the parameter to have it derive from Object it refuses to update the type from being optional to not optional, which means a method that say performs a nil check and then returns the non-nil value refuses to work correctly.

Below is a minimal example of my code:

class Optional
  def initialize(value = nil)
    @value = value
  end

  def or_else(default)
    return @value.nil? ? default : @value
  end
end

And this is the RBS

class Optional[T]
  @value: T?

  def initialize: (?T? value) -> void

  def or_else: (T default) -> T
end

These are some of the errors I get:

[error] Type `(T | nil)` does not have method `nil?`
│ Diagnostic ID: Ruby::NoMethod
│
└     return yield @value unless @value.nil?
                                        ~~~~
[error] The method cannot return a value of type `(T | nil)` because declared as type `T`
│   (T | nil) <: T
│     nil <: T
│
│ Diagnostic ID: Ruby::ReturnTypeMismatch
│
└     return @value unless @value.nil?
      ~~~~~~~~~~~~~

[error] Cannot pass a value of type `(T | nil)` as an argument of type `T`
│   (T | nil) <: T
│     nil <: T
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└     return [email protected]? && predicate.call(@value) ? self : Optional.empty

retroandchill avatar Jan 14 '24 15:01 retroandchill

You need to add constraints over type variables to call some method with them.

class Optional[T < _NilP]
  interface _NilP
    def nil?: () -> bool
  end
end

Note that the nil? definition won't work for type narrowing because of implementation limitation. You can use nil or unless syntax to let type narrowing work.

def or_else(default)
  if value = @value
    value
  else
    default
  end
end

soutaro avatar Jan 15 '24 01:01 soutaro

I think the problem is that BasicObject does not have #nil? method. Besides @soutaro 's example, I think this is another useful usage.

class Optional[T < Object] # or [T < Kernel] 
  @value: T?

  def initialize: (?T? value) -> void

  def or_else: (T default) -> T?
end

ksss avatar Jan 15 '24 05:01 ksss

@soutaro's solution works like a charm, I think the only limitation is that it is unable to handle an Optional that is passed a boolean value. (Basically, because the check can't meaningfully differentiate between nil and false because both are falsey)

retroandchill avatar Jan 15 '24 15:01 retroandchill

Basically, because the check can't meaningfully differentiate between nil and false because both are falsey

Got it...

Hmm, possible but weird workaround would be using case-when:

def or_else(default)
  case value
  when NilClass
    default
  else
    value
  end
end

But I don't think I want to recommend it...

soutaro avatar Jan 16 '24 00:01 soutaro

The case-when solution doesn't seem to work as that check doesn't seem to properly cast the nil off in the else block. It was a nice idea though

retroandchill avatar Jan 20 '24 16:01 retroandchill

It does appear that .nil? does work when the value is set to a local variable however, just not when it's an instance variable

retroandchill avatar Jan 20 '24 16:01 retroandchill

Yeah, it's complicated. Type narrowing works for local variables and some of the method calls, but not for instance variables.

No unwrap in else-block in case-when example might be a bug....

soutaro avatar Jan 23 '24 00:01 soutaro