Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
retroandchill opened this issue Jan 14, 2024 · 7 comments
Open

Comments

@retroandchill
Copy link

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
@soutaro
Copy link
Owner

soutaro commented Jan 15, 2024

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

@ksss
Copy link
Contributor

ksss commented Jan 15, 2024

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

@retroandchill
Copy link
Author

@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)

@soutaro
Copy link
Owner

soutaro commented Jan 16, 2024

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...

@retroandchill
Copy link
Author

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
Copy link
Author

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

@soutaro
Copy link
Owner

soutaro commented Jan 23, 2024

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....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants