Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ Naming/FileName:
# This file matches RuboCop naming conventions, like `rubocop-rails`, `rubocop-sorbet`, etc.
- lib/rubocop-type_toolkit.rb

Style/EmptyElse:
AllowComments: true

Style/Semicolon:
AllowAsExpressionSeparator: true
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,58 @@ Unimplemented abstract methods cannot be called, and the Type Toolkit runtime wi
```ruby
class EmailNotifier
include Notifier

# Oops, forgot to implement `#send_notification`!
end

EmailNotifier.new.send_notification("Hello, world!") # ❌ TypeToolkit::AbstractMethodNotImplementedError
# => Abstract method #send_notification was never implemented.
```

### Abstract Classes

Abstract classes are partially-implemented classes that leave some abstract methods to be implemented by subclasses.

Example:

```ruby
class Widget
abstract!

#: -> void
abstract def draw; end
end

class Button < Widget
# @override
#: -> void
Comment thread
amomchilov marked this conversation as resolved.
def draw
puts "Drawing a button"
end
end

Button.new.draw # ✅
# => Drawing a button
```

Just like abstract methods on interfaces, the Type Toolkit runtime will raise an error if you try to call an unimplemented abstract method on an abstract class:

```ruby
class TextBox < Widget
# Oops, forgot to implement `#draw`!
end

TextBox.new.draw # ❌ TypeToolkit::AbstractMethodNotImplementedError
# => Abstract method #draw was never implemented.
```

Abstract classes are incomplete, so it wouldn't make sense to instantiate them directly. The Type Toolkit runtime will raise an error if you try to do so:

```ruby
Widget.new # ❌ TypeToolkit::CannotInstantiateAbstractClassError
# => Widget is declared as abstract; it cannot be instantiated
```

## Guiding Principles

### Blazingly fast™
Expand Down
141 changes: 141 additions & 0 deletions benchmark/abstract_class_new.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# typed: ignore
# frozen_string_literal: true

# Benchmark the time it takes to instantiate a subclass of an abstract class

############################################# Results #############################################
#
# ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [arm64-darwin23]
#
# Time to instantiate a subclass of an abstract class
# | | Interpreter | YJIT |
# |-------------------|-------------------------:|------------------------:|
# | sorbet-runtime | 118.95 ns | 97.93 ns |
# | type_toolkit | (3.32x faster) 35.82 ns | (4.63x faster) 21.14 ns |
#
# Time to instantiate a subclass of an abstract class with a custom implementation of `new`
# | | Interpreter | YJIT |
# |-------------------|-------------------------:|------------------------:|
# | sorbet-runtime | 140.45 ns | 119.00 ns |
# | type_toolkit | (1.17x faster) 119.89 ns | (1.40x faster) 84.75 ns |
#
####################################################################################################

require "bundler"
Bundler.setup(:default, :benchmark)
Bundler.require(:benchmark)

# Intentionally not requiring "type_toolkit/ext/class", so we don't monkey-patch in our `Class#abstract!`.
# If we did, Sorbet runtime's `abstract!` would call it (since delegates up the chain), and break things.
require "type_toolkit/abstract_class"

# This benchmark has pretty high variance (it depends on the GC's allocation patterns),
# so we run it for a longer time to get a more stable result.
warmup = 10
time = 30

width = ["type_toolkit", "sorbet-runtime", "manual delegation"].max_by(&:length).length

module TypeKitDemo
class Parent
TypeToolkit.make_abstract!(self)
end

class Child < Parent; end

class Child_OverridesNew < Parent
def self.new(...) = super
end
end

module SorbetRuntimeDemo
class Parent
extend T::Helpers

abstract!
end

class Child < Parent; end

class Child_OverridesNew < Parent
def self.new(...) = super
end
end

# Run GC before each job run.
#
# Inspired by https://www.omniref.com/ruby/2.2.1/symbols/Benchmark/bm?#annotation=4095926&line=182
class GCSuite
def warming(*)
GC.start
end

def running(*)
GC.start
end

def warmup_stats(*)
end

def add_report(*)
end
end

suite = GCSuite.new

[:interpreter, :yjit].each do |mode|
if mode == :yjit
puts <<~MSG


================================================================================
Enabling YJIT...
================================================================================


MSG
RubyVM::YJIT.enable
end

puts "\nBenchmark the time to instantiate a subclass of an abstract class..."
Benchmark.ips do |x|
x.config(warmup:, time:, suite:)

x.report("sorbet-runtime".rjust(width)) do |times|
i = 0
while (i += 1) < times
SorbetRuntimeDemo::Child.new
end
end

x.report("type_toolkit".rjust(width)) do |times|
i = 0
while (i += 1) < times
TypeKitDemo::Child.new
end
end

x.compare!(order: :baseline)
end

puts "\nBenchmark the time to instantiate a subclass of an abstract class with a custom implementation of `new`..."
Benchmark.ips do |x|
x.config(warmup:, time:, suite:)

x.report("sorbet-runtime".rjust(width)) do |times|
i = 0
while (i += 1) < times
SorbetRuntimeDemo::Child_OverridesNew.new
end
end

x.report("type_toolkit".rjust(width)) do |times|
i = 0
while (i += 1) < times
TypeKitDemo::Child_OverridesNew.new
end
end

x.compare!(order: :baseline)
end
end
2 changes: 2 additions & 0 deletions lib/type_toolkit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
# frozen_string_literal: true

require_relative "type_toolkit/version"
require_relative "type_toolkit/abstract_class"
require_relative "type_toolkit/interface"
require_relative "type_toolkit/ext/class"
require_relative "type_toolkit/ext/method"
require_relative "type_toolkit/ext/module"
require_relative "type_toolkit/ext/nil_assertions"
Expand Down
116 changes: 116 additions & 0 deletions lib/type_toolkit/abstract_class.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# typed: true
# frozen_string_literal: true

require "type_toolkit/dsl"
require "type_toolkit/method_def_recorder"
require "type_toolkit/has_abstract_methods"
require "type_toolkit/abstract_method_receiver"

module TypeToolkit
class << self
#: (Module[top]) -> void
def make_abstract!(mod)
case mod
when Class
if mod.singleton_class.method_defined?(:__type_toolkit_private_original_new_impl, false)
raise AlreadyDeclaredAbstractError, "#{mod.inspect} is already declared abstract"
end

if mod.singleton_class?
raise NotImplementedError, "Declaring `abstract!` from a singleton class is not supported yet."
end

if TypeToolkit::AbstractClass > mod.singleton_class # Check if AbstractClass was already extended up in mod's ancestor chain.
raise NotImplementedError, "Declaring a subclass of an abstract class as abstract is not supported yet."
end

# We need to save the original implementation of `new`, so we can restore it on the subclasses later.
mod.singleton_class.alias_method(:__type_toolkit_private_original_new_impl, :new)

mod.extend(TypeToolkit::AbstractClass)
mod.extend(TypeToolkit::DSL)
mod.extend(TypeToolkit::MethodDefRecorder)
mod.extend(TypeToolkit::HasAbstractMethods)

mod.include(TypeToolkit::AbstractInstanceMethodReceiver)
when Module
raise NotImplementedError, "Abstract modules are not implemented yet."
end
end
end

# This module is extended onto every class marked `abstract!`.
# Abstract classes can't be instantiated, only subclassed.
# They should contain abstract methods, which must be implemented by subclasses.
#
# Example:
#
# class Widget
# abstract!
#
# #: -> void
# abstract def draw; end
# end
#
# class Button < Widget
# # @override
# #: -> void
# def draw
# ...
# end
# end
#
# class TextField < Widget
# # @override
# #: -> void
# def draw
# ...
# end
# end
#
module AbstractClass
# An override of `new` which prevents instantiation of the class.
# This needs to be overridden again in subclasses, to restore the real `.new` implementation.
def new(...) # :nodoc:
#: self as Class[top]

if respond_to?(:__type_toolkit_private_original_new_impl) # This is true for the abstract classes themselves, and false for their subclasses.
raise CannotInstantiateAbstractClassError, "#{inspect} is declared as abstract; it cannot be instantiated"
end

# This is hit in the uncommon case where a subclass of an abstract class overrides `.new` and calls `super`.
super
end

# Restores the original `.new` implementation for the direct subclasses of an abstract class.
#: (Class[AbstractClass]) -> void
def inherited(subclass) # :nodoc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relying on this inherited method means we only restore .new when user-defined self.inherited hooks call super. If an abstract class defines an inherited hook without super, concrete subclasses keep the abstract .new and can't be instantiated:

class Base
  abstract!

  def self.inherited(_subclass)
    # no super
  end

  abstract def call; end
end

class Impl < Base
  def call; end
end

Impl.new # raises CannotInstantiateAbstractClassError

I wonder if we should install this as a prepended singleton hook, or otherwise wrap/chain the existing hook, and add a regression test so subclass instantiation doesn't depend on user hooks calling super.

@amomchilov amomchilov Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good find. I'll come back to this, because:

  1. Diagnosing and raising in that case requires more state variables, which an upstack branch will have and make trivial
  2. Fixing it requires a bunch more anonymous modules and prepending stuff which is icky and I want to punt on.

It's doable, but not calling super is a skill issue, as the kids would say. There's even a default cop called Lint/MissingSuper which catches it.

For now I just added a test case that documents the broken behaviour.

Tracked in #45

if subclass.singleton_class.method_defined?(:__type_toolkit_private_original_new_impl)
# We only need to restore the original `.new` implementation for the direct subclasses of the abstract class.
# That's then inherited by the indirect subclasses.

if AbstractClass == subclass.singleton_class.instance_method(:new).owner
# The raising `new` implementation is still in place, so we need to restore the original implementation we stashed away.
subclass.singleton_class.alias_method(:new, :__type_toolkit_private_original_new_impl)
else
# The parent class defined its own `new` after being declared `abstract!`.
# We just inherit that implementation without needing to do anything.
end

# We don't need a reference to the original implementation anymore,
# so let's undef it to limit namespace pollution.
subclass.singleton_class.undef_method(:__type_toolkit_private_original_new_impl)
end

super
end
end

# Raised when an attempt is made to instantiate an abstract class.
class CannotInstantiateAbstractClassError < Exception # rubocop:disable Lint/InheritException
end

# Raised when you attempt to call `abstract!` twice on the exact same class.
class AlreadyDeclaredAbstractError < Exception # rubocop:disable Lint/InheritException
end
end
11 changes: 11 additions & 0 deletions lib/type_toolkit/ext/class.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# typed: strict
# frozen_string_literal: true

require "type_toolkit/abstract_class"

class Class

@Morriar Morriar Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@amomchilov is it meant to be required standalone?

require "type_toolkit/ext/class"

like we do for not_nil!

require "type_toolkit/ext/nil_assertions"

If yes 2 comments:

  1. We should require lib/type_toolkit/abstract_class.rb‎ in there to avoid undefined constant
  2. We should rename it as type_toolkit/ext/abstract.

I'm not a fan of requiring files under ext directly, it would be cleaner to require features and let them require the internal plumbing properly:

require "type_toolkit/abstract"
require "type_toolkit/nil_assertions"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See https://github.com/Shopify/type_toolkit#cherry-picking-features

  1. True.
  2. Conventionally, ext files are named after the class they extend, not what they do. See https://github.com/rails/rails/tree/main/activesupport/lib/active_support/core_ext for example

#: -> void
def abstract!
TypeToolkit.make_abstract!(self)
end
end
Loading
Loading