From a03b8b63f3be40664de8749bc4682106c1142373 Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Wed, 18 Mar 2026 15:13:44 -0400 Subject: [PATCH 1/4] Move error message to `raise` line --- lib/type_toolkit/ext/nil_assertions.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/type_toolkit/ext/nil_assertions.rb b/lib/type_toolkit/ext/nil_assertions.rb index c444046..bc987c7 100644 --- a/lib/type_toolkit/ext/nil_assertions.rb +++ b/lib/type_toolkit/ext/nil_assertions.rb @@ -18,7 +18,8 @@ class NilClass # @override #: -> bot def not_nil! - raise TypeToolkit::UnexpectedNilError + # Do not rely on this message content! Its content is subject to change. + raise TypeToolkit::UnexpectedNilError, "Called `not_nil!` on nil." end end @@ -31,8 +32,5 @@ module TypeToolkit # # Note: `rescue Exception` can still catch it, but that's intentionally harder to write accidentally. class UnexpectedNilError < Exception # rubocop:disable Lint/InheritException - def initialize(message = "Called `not_nil!` on nil.") - super - end end end From a5b3694c0e34a36fde8eed4122d8b6a9fd4a244f Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Wed, 18 Mar 2026 15:14:25 -0400 Subject: [PATCH 2/4] Add raising `not_nil!` to Sorbet `void` values --- lib/type_toolkit/ext/nil_assertions.rb | 40 ++++++++++++++++++++++++++ spec/nil_assertions_spec.rb | 22 ++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/lib/type_toolkit/ext/nil_assertions.rb b/lib/type_toolkit/ext/nil_assertions.rb index bc987c7..20f7cc8 100644 --- a/lib/type_toolkit/ext/nil_assertions.rb +++ b/lib/type_toolkit/ext/nil_assertions.rb @@ -1,6 +1,8 @@ # typed: true # frozen_string_literal: true +require "bundler" + # Asserts that the receiver is not nil. # # You should use `not_nil!` in places where you're absolutely sure a `nil` value can't occur. @@ -23,6 +25,44 @@ def not_nil! end end +# FIXME: this is load-order dependent, and will break if it's loaded after the real +# `T::Private::Types::Void::VOID` module, which is frozen: +# https://github.com/sorbet/sorbet/blob/f0cb505/gems/sorbet-runtime/lib/types/private/types/void.rb#L17-L19 +if Bundler.locked_gems.specs.any? { |s| s.name == "sorbet-runtime" } + module T + module Types + class Base; end + end + + module Private + module Types + class Void < T::Types::Base + module VOID + class << self + # An override of `not_nil!` intended to reduce the discrepancy between test and production environments + # when Sorbet Runtime is used in tests but not production. + # + # When Sorbet Runtime is active `void`-returning methods have their return value replaced with the + # `T::Private::Types::Void::VOID` module. If Sorbet Runtime is on in tests but not production, + # this introduces a dangerous difference in behaviour for methods that return `nil`: + # + # * In test code, it'll be replaced with `T::Private::Types::Void::VOID`. + # If `not_nil!` is called on it (and this override didn't exist), it'll just return `self`. + # + # * In production code, that `nil` value will left-as-is, and calling `not_nil!` on it will raise an error. + #: -> bot + def not_nil! + # Do not rely on this message content! Its content is subject to change. + raise TypeToolkit::UnexpectedNilError, "Called `not_nil!` on a void value (T::Private::Types::Void::VOID)" + end + end + end + end + end + end + end +end + module TypeToolkit # An error raised when calling `#not_nil!` on a `nil` value. # diff --git a/spec/nil_assertions_spec.rb b/spec/nil_assertions_spec.rb index 711ae82..83fbfe1 100644 --- a/spec/nil_assertions_spec.rb +++ b/spec/nil_assertions_spec.rb @@ -2,10 +2,13 @@ # frozen_string_literal: true require "spec_helper" +require "sorbet-runtime" module TypeToolkit class NilAssertionsTest < Minitest::Spec describe "#not_nil!" do + extend T::Sig + it "returns self on non-nil values" do x = "Hello, world!" assert_same x, x.not_nil! @@ -14,6 +17,25 @@ class NilAssertionsTest < Minitest::Spec it "raises an error on nil values" do assert_raises(UnexpectedNilError) { nil.not_nil! } end + + it "raises an error on Sorbet runtime's void value" do + # Cast away the void type to prevent the static type checker from blocking this with + # "Cannot call method `not_nil!` on void type" + void_value = example_void_returner #: as Object + + assert_same T::Private::Types::Void::VOID, void_value + e = assert_raises(UnexpectedNilError) { void_value.not_nil! } + + # Do not rely on this message content! Its content is subject to change. + assert_includes e.message, "Called `not_nil!` on a void value (T::Private::Types::Void::VOID)" + end + + private + + # To make sure we're patching the right thing, use a void-returning method to produce a void value, + # rather than directly referencing `T::Private::Types::Void::VOID`. + sig { void } + def example_void_returner = nil end end end From e43c4a79719f5bf31e8742cd08e7416a65c438b1 Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Wed, 18 Mar 2026 15:47:41 -0400 Subject: [PATCH 3/4] Rework to make it load-order independent --- lib/type_toolkit/ext/nil_assertions.rb | 54 +++++++++++++++++--------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/lib/type_toolkit/ext/nil_assertions.rb b/lib/type_toolkit/ext/nil_assertions.rb index 20f7cc8..bc9cfa1 100644 --- a/lib/type_toolkit/ext/nil_assertions.rb +++ b/lib/type_toolkit/ext/nil_assertions.rb @@ -29,6 +29,29 @@ def not_nil! # `T::Private::Types::Void::VOID` module, which is frozen: # https://github.com/sorbet/sorbet/blob/f0cb505/gems/sorbet-runtime/lib/types/private/types/void.rb#L17-L19 if Bundler.locked_gems.specs.any? { |s| s.name == "sorbet-runtime" } + module TypeToolkit + module SorbetRuntimeCompatibility + module VoidPatch + # An override of `not_nil!` intended to reduce the discrepancy between test and production environments + # when Sorbet Runtime is used in tests but not production. + # + # When Sorbet Runtime is active `void`-returning methods have their return value replaced with the + # `T::Private::Types::Void::VOID` module. If Sorbet Runtime is on in tests but not production, + # this introduces a dangerous difference in behaviour for methods that return `nil`: + # + # * In test code, it'll be replaced with `T::Private::Types::Void::VOID`. + # If `not_nil!` is called on it (and this override didn't exist), it'll just return `self`. + # + # * In production code, that `nil` value will left-as-is, and calling `not_nil!` on it will raise an error. + #: -> bot + def not_nil! + # Do not rely on this message content! Its content is subject to change. + raise TypeToolkit::UnexpectedNilError, "Called `not_nil!` on a void value (T::Private::Types::Void::VOID)" + end + end + end + end + module T module Types class Base; end @@ -37,24 +60,19 @@ class Base; end module Private module Types class Void < T::Types::Base - module VOID - class << self - # An override of `not_nil!` intended to reduce the discrepancy between test and production environments - # when Sorbet Runtime is used in tests but not production. - # - # When Sorbet Runtime is active `void`-returning methods have their return value replaced with the - # `T::Private::Types::Void::VOID` module. If Sorbet Runtime is on in tests but not production, - # this introduces a dangerous difference in behaviour for methods that return `nil`: - # - # * In test code, it'll be replaced with `T::Private::Types::Void::VOID`. - # If `not_nil!` is called on it (and this override didn't exist), it'll just return `self`. - # - # * In production code, that `nil` value will left-as-is, and calling `not_nil!` on it will raise an error. - #: -> bot - def not_nil! - # Do not rely on this message content! Its content is subject to change. - raise TypeToolkit::UnexpectedNilError, "Called `not_nil!` on a void value (T::Private::Types::Void::VOID)" - end + if defined?(::T::Private::Types::Void::VOID) + # sorbet-runtime was already loaded, and the `VOID` module is already frozen, so we can't change it. + # Instead, replace it with our own copy. + ::T::Private::Types::Void.const_set(:VOID, ::Module.new do + extend ::TypeToolkit::SorbetRuntimeCompatibility::VoidPatch + + freeze # Freeze it like the original + end) + else + # sorbet-runtime hasn't been loaded yet, so we're defining the `VOID` module for the first time. + module VOID + extend ::TypeToolkit::SorbetRuntimeCompatibility::VoidPatch + # Leave it unfrozen so sorbet-runtime can freeze it later when it defines it. end end end From c2ff234407f6f043d79730fee4c658a4337cc988 Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Wed, 18 Mar 2026 15:52:03 -0400 Subject: [PATCH 4/4] Extract into its own file --- lib/type_toolkit.rb | 1 + lib/type_toolkit/ext/nil_assertions.rb | 56 ------------------ .../ext/sorbet-runtime/nil_assertions.rb | 59 +++++++++++++++++++ 3 files changed, 60 insertions(+), 56 deletions(-) create mode 100644 lib/type_toolkit/ext/sorbet-runtime/nil_assertions.rb diff --git a/lib/type_toolkit.rb b/lib/type_toolkit.rb index 1994c4a..0162352 100644 --- a/lib/type_toolkit.rb +++ b/lib/type_toolkit.rb @@ -6,6 +6,7 @@ require_relative "type_toolkit/ext/method" require_relative "type_toolkit/ext/module" require_relative "type_toolkit/ext/nil_assertions" +require_relative "type_toolkit/ext/sorbet-runtime/nil_assertions" module TypeToolkit end diff --git a/lib/type_toolkit/ext/nil_assertions.rb b/lib/type_toolkit/ext/nil_assertions.rb index bc9cfa1..5543815 100644 --- a/lib/type_toolkit/ext/nil_assertions.rb +++ b/lib/type_toolkit/ext/nil_assertions.rb @@ -25,62 +25,6 @@ def not_nil! end end -# FIXME: this is load-order dependent, and will break if it's loaded after the real -# `T::Private::Types::Void::VOID` module, which is frozen: -# https://github.com/sorbet/sorbet/blob/f0cb505/gems/sorbet-runtime/lib/types/private/types/void.rb#L17-L19 -if Bundler.locked_gems.specs.any? { |s| s.name == "sorbet-runtime" } - module TypeToolkit - module SorbetRuntimeCompatibility - module VoidPatch - # An override of `not_nil!` intended to reduce the discrepancy between test and production environments - # when Sorbet Runtime is used in tests but not production. - # - # When Sorbet Runtime is active `void`-returning methods have their return value replaced with the - # `T::Private::Types::Void::VOID` module. If Sorbet Runtime is on in tests but not production, - # this introduces a dangerous difference in behaviour for methods that return `nil`: - # - # * In test code, it'll be replaced with `T::Private::Types::Void::VOID`. - # If `not_nil!` is called on it (and this override didn't exist), it'll just return `self`. - # - # * In production code, that `nil` value will left-as-is, and calling `not_nil!` on it will raise an error. - #: -> bot - def not_nil! - # Do not rely on this message content! Its content is subject to change. - raise TypeToolkit::UnexpectedNilError, "Called `not_nil!` on a void value (T::Private::Types::Void::VOID)" - end - end - end - end - - module T - module Types - class Base; end - end - - module Private - module Types - class Void < T::Types::Base - if defined?(::T::Private::Types::Void::VOID) - # sorbet-runtime was already loaded, and the `VOID` module is already frozen, so we can't change it. - # Instead, replace it with our own copy. - ::T::Private::Types::Void.const_set(:VOID, ::Module.new do - extend ::TypeToolkit::SorbetRuntimeCompatibility::VoidPatch - - freeze # Freeze it like the original - end) - else - # sorbet-runtime hasn't been loaded yet, so we're defining the `VOID` module for the first time. - module VOID - extend ::TypeToolkit::SorbetRuntimeCompatibility::VoidPatch - # Leave it unfrozen so sorbet-runtime can freeze it later when it defines it. - end - end - end - end - end - end -end - module TypeToolkit # An error raised when calling `#not_nil!` on a `nil` value. # diff --git a/lib/type_toolkit/ext/sorbet-runtime/nil_assertions.rb b/lib/type_toolkit/ext/sorbet-runtime/nil_assertions.rb new file mode 100644 index 0000000..f9f4ccc --- /dev/null +++ b/lib/type_toolkit/ext/sorbet-runtime/nil_assertions.rb @@ -0,0 +1,59 @@ +# typed: ignore +# frozen_string_literal: true + +# This file is `typed: ignore` so we don't have to implement the abstract methods from `T::Types::Base`, +# which `sorbet-runtime` will implement eventually anyway. + +require "bundler" +return unless Bundler.locked_gems.specs.any? { |s| s.name == "sorbet-runtime" } + +module TypeToolkit + module SorbetRuntimeCompatibility + module VoidPatch + # An override of `not_nil!` intended to reduce the discrepancy between test and production environments + # when Sorbet Runtime is used in tests but not production. + # + # When Sorbet Runtime is active `void`-returning methods have their return value replaced with the + # `T::Private::Types::Void::VOID` module. If Sorbet Runtime is on in tests but not production, + # this introduces a dangerous difference in behaviour for methods that return `nil`: + # + # * In test code, it'll be replaced with `T::Private::Types::Void::VOID`. + # If `not_nil!` is called on it (and this override didn't exist), it'll just return `self`. + # + # * In production code, that `nil` value will left-as-is, and calling `not_nil!` on it will raise an error. + #: -> bot + def not_nil! + # Do not rely on this message content! Its content is subject to change. + raise TypeToolkit::UnexpectedNilError, "Called `not_nil!` on a void value (T::Private::Types::Void::VOID)" + end + end + end +end + +module T + module Types + class Base; end + end + + module Private + module Types + class Void < T::Types::Base + if defined?(::T::Private::Types::Void::VOID) + # sorbet-runtime was already loaded, and the `VOID` module is already frozen, so we can't change it. + # Instead, replace it with our own copy. + ::T::Private::Types::Void.const_set(:VOID, ::Module.new do + extend ::TypeToolkit::SorbetRuntimeCompatibility::VoidPatch + + freeze # Freeze it like the original + end) + else + # sorbet-runtime hasn't been loaded yet, so we're defining the `VOID` module for the first time. + module VOID + extend ::TypeToolkit::SorbetRuntimeCompatibility::VoidPatch + # Leave it unfrozen so sorbet-runtime can freeze it later when it defines it. + end + end + end + end + end +end