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 c444046..5543815 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. @@ -18,7 +20,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 +34,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 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 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