Skip to content
Draft
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
1 change: 1 addition & 0 deletions lib/type_toolkit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions lib/type_toolkit/ext/nil_assertions.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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

Expand All @@ -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
59 changes: 59 additions & 0 deletions lib/type_toolkit/ext/sorbet-runtime/nil_assertions.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions spec/nil_assertions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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
Loading