rbs icon indicating copy to clipboard operation
rbs copied to clipboard

Add type arguments support to singleton types

Open allcre opened this issue 7 months ago • 9 comments

Add support for parameterized singleton types

This PR adds support for type parameters on singleton types to match the functionality available in Sorbet's T.class_of(X)[Y] syntax. With this change, RBS now supports the equivalent syntax: singleton(X)[Y].

Changes

  • Updated the RBS grammar to allow type arguments for singleton types
  • Modified the C parser implementation to accept type parameters for singleton types

Examples

Previously, only this was supported:

singleton(Array)   # Class singleton type with no type parameters

Now this is also supported:

singleton(Array)[String]   # Class singleton type with String type parameter

Questions

  1. Should the Application module be included in the ClassSingleton class, similar to how it's included in ClassInstance and Interface? Currently, I've implemented the necessary methods directly.

  2. Are there additional methods such as map_type and each_type that should be added to the ClassSingleton class?

allcre avatar May 20 '25 18:05 allcre

@soutaro Did singleton(Array)[String] (or singleton(Array[String]), whatever) make sense in the first place?

ParadoxV5 avatar May 20 '25 22:05 ParadoxV5

@allcre Can you give me some examples why we need that type? I'm assuming singleton(Array) is a singleton type -- ::Array is the only one value of the type -- and we don't need generic type for it.

soutaro avatar May 27 '25 06:05 soutaro

@soutaro we use it in Sorbet to represent the type of the attached class to a singleton.

It's useful around factories, here's a simple example:

class Box
  extend T::Sig
  extend T::Generic
  
  E = type_member

  sig { params(e: E).void }
  def initialize(e)
    @e = e
  end

  sig { returns(E) }
  def e
    @e
  end
end

extend T::Sig

sig { returns(T.class_of(Box)[Box[Integer]]) }
def example
  Box
end

x = example
T.reveal_type(x) # => T.class_of(Box)[Box[Integer]]
x.new("str") # error: Expected `Integer` but found `String` for argument `e`

More involved examples can be found in the documentation: https://sorbet.org/docs/class-of#tclass_of-applying-type-arguments-to-a-singleton-class-type.

Morriar avatar Jun 03 '25 20:06 Morriar

RBS expects us to genericize class methods because it only recognizes type parameters at the instance level.

class Set[E]
  def self.[]: [E] (*E elements) -> Set[E] # `E` is totally not duplicated
end

Honestly, that might be a flawed design:

class Set[E]
  def self.[]: (*untyped elements) -> instance # `instance` is `Set[untyped]`!
end

ParadoxV5 avatar Jun 04 '25 05:06 ParadoxV5

Thanks @Morriar,

I got the use case and agree that it cannot be written in RBS now. Using an interface would be a workaround, but not sure if it can cover the existing use cases.

Let me confirm the semantics: the type singleton(T)[S, ...] means that a class object of T but the instance created through the value of the type is T[S, ...]. Looks like it makes some sense, except if it needs a big overhaul in RBS (and Steep).

What should we do for the other singleton methods?

class Box[T]
  def initialize: (T) -> void

  def self.new_array: [T] (T) -> Box[Array[T]]
end

b = Box #: singleton(Box)[String]
b.new("")           # => Box[String]
b.new(1)            # => type error
b.new_array(1)      #=> ???

Looks like it only works for .new method in Sorbet?

soutaro avatar Jun 04 '25 05:06 soutaro

What should we do for the other singleton methods?

How about changing the semantics so that type parameters apply on the class level as well? It would resolve my #1521.

class Box[T]
  def self.new_array: (T) -> Box[Array[T]] # No need to genericize the method separately
  def self.[]: (T) -> instance # `instance` was `Box[untyped]`, but now `Box[T]`
end

ParadoxV5 avatar Jun 05 '25 19:06 ParadoxV5

@ParadoxV5 Yeah, it would make sense. But, how can we associate the type parameter T in self.[] and the type parameter given to the singleton class. singleton(Box)[T]? Can we do that by adding another type instance[T]?

soutaro avatar Jun 06 '25 02:06 soutaro

singleton(Box)[T] should probably be singleton(Box[T]) rather, so that singleton(Box[Integer])#[]Box[Integer].[]T in self.[] is Integer.

Allowing instance[T] is an option to, as I requested in #1521 long ago. Its flexibility would enable def self.new_array: (T) -> instance[Array[T]].

ParadoxV5 avatar Jun 06 '25 05:06 ParadoxV5

Let me confirm the semantics: the type singleton(T)[S, ...] means that a class object of T but the instance created through the value of the type is T[S, ...].

Yes, when limited to the type of the attached class (instance in RBS?). Here's an example on sorbet.run.

But it can also be used to specify the type of the generic parameters to the singleton class:

class BoxFactory
  class << self
    extend T::Sig
    extend T::Generic

    Kind = type_member

    sig { returns(Box[Kind]) }
    def create
      Box[Kind].new
    end
  end
end

class Box
  extend T::Generic

  Kind = type_member
end

extend T::Sig

sig do
  type_parameters(:T)
    .params(factory: T.class_of(BoxFactory)[BoxFactory, T.type_parameter(:T)])
    .returns(Box[T.type_parameter(:T)])
end
def create_box(factory)
  factory.create
end

Here's the equivalent RBS inline syntax we want with Sorbet:

class BoxFactory
  #: [E]
  class << self
    #: -> Box[E]
    def create
      Box.new #: Box[E]
    end
  end
end

#: [E]
class Box
end

#: [T] (singleton(BoxFactory)[BoxFactory, T]) -> Box[T]
def create_box(factory)
  factory.create
end

Morriar avatar Jun 17 '25 16:06 Morriar