Sorbet Cheatsheet Part 7

Published on

Note: This was an incomplete draft. You probably want the finished version!

Changes from last time:

  • Added T.type_alias
  • Added T.self_type
  • Added T.attached_class
  • Should the examples be in named methods or inlined into the module bodies? I think the method boilerplate might be more noise than signal, but maybe it's just the sig { void } that's the problem. I guess I don't need those because I'm using typed: true.
# Every file should have a "typed sigil" that tells Sorbet how strict to be
# during static type checking.
#
# Strictness levels (lax to strict):
#
# ignore: Sorbet won't even read the file.  This means its contents are not
# visible during type checking.  Avoid this.
#
# false: Sorbet will only report errors related to constant resolution.  This
# is the default if no sigil is included.
#
# true: Sorbet will report all static type errors.  This is the sweet spot of
# safety for effort.
#
# strict: Sorbet will require that all methods, constants, and instance
# variables have static types.
#
# strong: Sorbet will no longer allow anything to be T.untyped, even
# explicitly.  Almost nothing satisfies this.

# typed: true

# Include the runtime type-checking library.  This lets you write inline sigs
# and have them checked at runtime (instead of running Sorbet as RBI-only).
# These runtime checks happen even for files with `ignore` or `false` sigils.
require 'sorbet-runtime'

module BasicSigs
  # Bring in the type definition helpers.  You'll almost always need this.
  extend T::Sig

  # Sigs are defined with `sig` and a block.  Define the return value type with
  # `returns`.
  #
  # This method returns a value whose class is `String`.  These are the most
  # common types, and Sorbet calls them "class types".
  sig { returns(String) }
  def greet
    'Hello, World!'
  end

  # Define parameter value types with `params`.
  sig { params(n: Integer).returns(String) }
  def greet_repeat(n)
    (1..n).map { greet }.join("\n")
  end

  # Define keyword parameters the same way.
  sig { params(n: Integer, sep: String).returns(String) }
  def greet_repeat(n, sep: "\n")
    (1..n).map { greet }.join(sep)
  end

  # Notice that positional/keyword and required/optional make no difference
  # here.  They're all defined the same way in `params`.

  # For lots of parameters, it's nicer to use do..end and a multiline block
  # instead of curly braces.
  sig do
    params(
      str: String,
      num: Integer,
      sym: Symbol,
    ).returns(String)
  end
  def uhh(str:, num:, sym:)
    'What would you even do with these?'
  end

  # For a method whose return value is useless, use `void`.
  sig { params(name: String).void }
  def say_hello(name)
    puts "Hello, #{name}!"
  end

  # Splats! Also known as "rest parameters", "*args", "**kwargs", and others.
  #
  # Type the value that a _member_ of `args` or `kwargs` will have, not `args`
  # or `kwargs` itself.
  sig { params(args: Integer, kwargs: String).void }
  def no_op(*args, **kwargs)
    if kwargs[:op] == 'minus'
      args.each { |i| puts(i - 1) }
    else
      args.each { |i| puts(i + 1) }
    end
  end

  # Most initializers should be `void`.
  sig { params(name: String).void }
  def initialize(name:)
    # Instance variables must have annotated types to participate in static
    # type checking.

    # The value in `T.let` is checked statically and at runtime.
    @upname = T.let(name.upcase, String)

    # Sorbet can infer this one!
    @name = name
  end

  # Constants also need annotated types.
  SORBET = T.let('A delicious frozen treat', String)

  # Class variables too.
  @@the_answer = T.let(42, Integer)

  # Sorbet knows about the `attr_*` family.
  sig { returns(String) }
  attr_reader :upname

  sig { params(Integer).returns(Integer) }
  attr_writer :write_only

  # You say the reader part and Sorbet will say the writer part.
  sig { returns(String) }
  attr_accessor :name
end

module StandardHelpers
  extend T::Sig
  # Sorbet provides some helpers for typing the Ruby standard library.

  # Use T::Boolean to catch both `true` and `false`.
  #
  # For the curious, this is equivalent to
  #     T.type_alias { T.any(TrueClass, FalseClass) }
  sig { params(str: String).returns(T::Boolean) }
  def confirmed?(str)
    str == 'yes'
  end

  # Reminder that the value `nil` is an instance of NilClass.
  sig { params(val: NilClass).void }
  def only_nil(val:); end

  # To avoid modifying standard library classes, Sorbet provides wrappers to
  # support common generics.
  #
  # Here's the full list:
  #   * T::Array
  #   * T::Enumerable
  #   * T::Enumerator
  #   * T::Hash
  #   * T::Range
  #   * T::Set
  sig { params(config: T::Hash[Symbol, String]).returns(T::Array[String]) }
  def merge_values(config)
    keyset = [:old_key, :new_key]
    config.each_pair.flat_map do |key, value|
      keyset.include?(key) ? value : nil
    end
  end

  # Sometimes (usually dependency injection), a method will accept a reference
  # to a class rather than an instance of the class.  Use `T.class_of(Dep)` to
  # accept the `Dep` class itself (or something that inherits from it).
  class Dep; end

  sig { params(dep: T.class_of(Dep)).returns(Dep) }
  def dependency_injection(dep:)
    dep.new
  end

  # Blocks, procs, and lambdas, oh my!  All of these are typed with `T.proc`.
  #
  # Limitations:
  # 1. All parameters are assumed to be required positional parameters.
  # 2. The only runtime check is that the value is a `Proc`.  The argument
  #    types are only checked statically.
  sig do
    params(
      data: T::Array[String],
      blk: T.proc.params(val: String).returns(Integer),
    ).returns(Integer)
  end
  def count(data, &blk)
    data.sum(&blk)
  end

  sig { returns(Integer) }
  def count_usage
    count(["one", "two", "three"]) { |word| word.length + 1 }
  end

  # If the method takes an implicit block, Sorbet will infer `T.untyped` for
  # it.  Use the explicit block syntax if the types are important.
  sig { params(str: String).returns(T.untyped) }
  def implicit_block(str)
    yield(str)
  end

  # If you're writing a DSL and will execute the block in a different context,
  # use `bind`.
  sig { params(num: Integer, blk: T.proc.bind(Integer).void).void }
  def number_fun(num, &blk)
    num.instance_eval(&blk)
  end

  sig { void }
  def number_fun_usage(num)
    number_fun(10) { puts digits.join }
  end

  # If the block doesn't take any parameters, don't include `params`.
  sig { params(blk: T.proc.returns(Integer)).returns(Integer) }
  def doubled_block(&blk)
    2 * blk.call
  end
end

module Combinators
  extend T::Sig
  # These methods let you define new types from existing types.

  # Use `T.any` when you have a value that can be one of many types.  These are
  # sometimes known as "union types" or "sum types".
  sig { params(num: T.any(Integer, Float)).returns(Integer) }
  def hundreds(num)
    num.round(-2)
  end

  # `T.nilable(Type)` is a convenient alias for `T.any(Type, NilClass)`.
  sig { params(val: T.nilable(String)).returns(Integer) }
  def strlen(val)
    val.nil? ? -1 : val.length
  end

  # Use `T.all` when you have a value that must be satisfy multiple types.
  # These are sometimes known as "intersection types".  They're most useful for
  # interfaces (described later), but can also be useful for helper modules.

  module Reversible
    extend T::Sig
    sig { void }
    def reverse
      # Pretend this is actually implemented
    end
  end

  module Sortable
    extend T::Sig
    sig { void }
    def sort
      # Pretend this is actually implemented
    end
  end

  class List
    include Reversible
    include Sortable
  end

  sig { params(list: T.all(Reversible, Sortable)).void }
  def rev_sort(list)
    # reverse from Reversible
    list.reverse
    # sort from Sortable
    list.sort
  end

  def rev_sort_usage
    rev_sort(List.new)
  end

  # Sometimes, actually spelling out the type every time becomes more confusing
  # than helpful.  Use type aliases to make them easier to work with.
  JSONLiteral = T.type_alias { T.any(Float, String, T::Boolean, NilClass) }

  sig { params(val: JSONLiteral).returns(String) }
  def stringify(val)
    val.to_s
  end
end

module DataClasses
  # Use `T::Struct` to create a new class with type-checked fields.  It
  # combines the best parts of the standard Struct and OpenStruct, and then
  # adds static typing on top.
  #
  # Types constructed this way are sometimes known as "product types".

  class Matcher < T::Struct
    # Use `prop` to define a field with both a reader and writer.
    prop :count, Integer
    # Use `const` to only define the reader and skip the writer.
    const :pattern, Regexp
    # You can still set a default value with `default`.
    const :message, String, default: 'Found one!'

    # This is otherwise a normal class, so you can still define methods.

    # You'll still need to bring `sig` in if you want to use it though.
    extend T::Sig

    sig { void }
    def reset
      self.count = 0
    end
  end

  sig { params(text: String, matchers: T::Array[Matcher]).void }
  def awk(text, matchers)
    matchers.each(&:reset)
    text.lines.each do |line|
      matchers.each do |matcher|
        if matcher.pattern =~ line
          puts matcher.message
          matcher.count += 1
        end
      end
    end
  end

  # Gotchas and limitations

  # 1. `const` fields are not truly immutable.  They don't have a writer
  #    method, but may be changed in other ways.
  class ChangeMe < T::Struct
    const :list, T::Array[Integer]
  end

  def whoops!(change_me)
    change_me = ChangeMe.new(list: [1, 2, 3, 4])
    change_me.list.reverse!
    change_me.list == [4, 3, 2, 1]
  end

  # 2. `T::Struct` inherits its equality method from `BasicObject`, which uses
  #    identity equality (also known as "reference equality").
  class Position < T::Struct
    const :x, Integer
    const :y, Integer
  end

  def never_equal!
    p1 = Position.new(x: 1, y: 2)
    p2 = Position.new(x: 1, y: 2)
    p1 != p2
  end

  # Define your own `#==` method to check the fields, if that's what you want.
  class Position < T::Struct
    # Note: reopened class
    def ==(other)
      self.class == other.class && self.x == other.x && self.y == other.y
    end
  end

  # Use `T::Enum` to define a fixed set of values that are easy to reference.
  # This is especially useful when you don't care what the values _are_ as much
  # as you care that the set of possibilities is closed and static.
  class Crayon < T::Enum
    extend T::Sig

    # Start initialization with `enum`.
    enums do
      # Define each member with `new`.  Each of these is an instance of the
      # `Crayon` class.
      Red = new
      Orange = new
      Yellow = new
      Green = new
      Blue = new
      Violet = new
      Brown = new
      Black = new
      # The default value of the enum is its name in all-lowercase.  To change
      # that, pass a value to `new`.
      Gray90 = new('light-gray')
    end

    # Define any aliases outside the initialization block.
    Purple = Violet

    sig { returns(String) }
    def to_hex
      case self
      when Red   then '#ff0000'
      when Green then '#00ff00'
      # ...
      else            '#ffffff'
      end
    end
  end

  sig { params(crayon: Crayon, path: T::Array[Point]).void }
  def draw(crayon:, path:)
    path.each do |point|
      puts "(#{point.x}, #{point.y}) = " + crayon.to_hex
    end
  end
end

module FlowSensitivity
  extend T::Sig
  # Sorbet understands Ruby's control flow constructs and uses that information
  # to get more accurate types when your code branches.

  # You'll see this most often when doing nil checks.
  sig { params(name: T.nilable(String)).returns(String) }
  def greet_loudly(name)
    if name.nil?
      'HELLO, YOU!'
    else
      # Sorbet knows that `name` must be a String here, so it's safe to call
      # `#upcase`.
      "HELLO, #{name.upcase}!"
    end
  end

  # The nils are a special case of refining `T.any`.
  sig { params(id: T.any(Integer, T::Array[Integer])).returns(T::Array[String]) }
  def database_lookup(id)
    if id.is_a?(Integer)
      # `ids` must be an Integer here.
      [id.to_s]
    else
      # `ids` must be a T::Array[Integer] here.
      id.map(&:to_s)
    end
  end

  # Sorbet recognizes these methods that narrow type definitions:
  # * is_a?
  # * kind_of?
  # * nil?
  # * Class#===
  # * Class#<
  # * block_given?
  #
  # Because there so common, it also recognizes these Rails extensions:
  # * blank?
  # * present?
  #
  # Be careful to maintain Sorbet assumptions if you redefine these methods!

  # Have you've ever written this line of code?
  #
  #     raise StandardError, "Can't happen"
  #
  # Sorbet can help you prove that statically (this is known as
  # "exhaustiveness") with `T.absurd`.  It's extra cool when combined with
  # `T::Enum`!

  class Size < T::Enum
    enums do
      Byte = new('B')
      Kibibyte = new('KiB')
      Mebibyte = new('MiB')
      # "640K ought to be enough for anybody"
    end

    sig { returns(Integer) }
    def bytes
      case self
        when Byte     then 1 <<  0
        when Kibibyte then 1 << 10
        when Mebibyte then 1 << 20
        else
          # Sorbet knows you've checked all the cases, so there's no possible
          # value that `self` could have here.
          #
          # But if you _do_ get here somehow, this will raise at runtime.
          T.absurd(self)

          # If you're missing a case, Sorbet can even tell you which one it is!
      end
    end
  end

  # Sorbet knows that no code can execute after a `raise` statement because it
  # "never returns".
  sig { params(num: T.nilable(Integer)).returns(Integer) }
  def decrement(num)
    raise ArgumentError, '¯\_(ツ)_/¯' unless num

    num - 1
  end

  # You can annotate your own error-raising methods with `T.noreturn`.
  class CustomError < StandardError; end
  sig { params(message: String).returns(T.noreturn) }
  def oh_no(message = 'A bad thing happened')
    puts message
    raise CustomError, message
  end

  # It also applies to infinite loops.
  sig { returns(T.noreturn) }
  def loading
    loop do
      %q(-\|/).each_char do |c|
        print "\r#{c} reticulating splines..."
        sleep 1
      end
    end
  end

  # You may run into a situation where Sorbet "loses" your type refinement.
  # Remember that almost everything you do in Ruby is a method call that could
  # return a different value next time you call it.  Sorbet doesn't assume that
  # any methods are pure (even those from `attr_reader` and `attr_accessor`).
  sig { returns(T.nilable(Integer)) }
  def answer
    rand > 0.5 ? 42 : nil
  end

  sig { void }
  def bad_typecheck
    if answer.nil?
      0
    else
      # But answer might return `nil` if we call it again!
      answer + 1
    end
  end

  sig { void }
  def good_typecheck
    ans = answer
    if ans.nil?
      0
    else
      # Now Sorbet knows that `ans` is non-nil.
      ans + 1
    end
  end
end

module ReceiverTypes
  #

  # TODO: T.self_type
  # TODO: T.attached_class
end

module Inheritance
  extend T::Sig

  # If you have a method that always returns the type of its receiver, use
  # `T.self_type`.  This is common in fluent interfaces and DSLs.
  #
  # Warning: This feature is still experimental!
  class Logging
    extend T::Sig

    sig { returns(T.self_type) }
    def log
      pp self
      self
    end
  end

  class Data < Logging
    extend T::Sig

    sig { params(x: Integer, y: String).void }
    def initialize(x: 0, y: '')
      @x = x
      @y = y
    end

    # You don't _have_ to use `T.self_type` if there's only one relevant class.
    sig { params(x: Integer).returns(Data) }
    def setX(x)
      @x = x
      self
    end

    sig { params(y: String).returns(Data) }
    def setY(y)
      @y = y
      self
    end
  end

  # Tada!
  sig { void }
  def chaining(data: Data)
    data.setX(1).log.setY('a')
  end

  # If it's a class method (a.k.a. singleton method), use `T.attached_class`.
  # No warning here.  This one is stable!
  class Box
    extend T::Sig

    sig { params(contents: String, weight: Integer).void }
    def initialize(contents, weight)
      @contents = contents
      @weight = weight
    end

    sig { params(contents: String).returns(T.attached_class) }
    def self.pack(contents)
      new(contents, contents.chars.uniq.length)
    end
  end

  class CompanionCube < Box
    extend T::Sig

    sig { returns(String) }
    def pick_up
      "#{@contents}🤍"
    end
  end

  sig { returns(String) }
  def befriend
    CompanionCube.pack('').pick_up
  end

  # TODO: abstract!
  # TODO: interface!
  # TODO: abstract. / override.
  # TODO: mixes_in_class_methods
  # TODO: final!
  # TODO: sealed!
end

module Debugging
  # TODO: T.reveal_type
end

module EscapeHatches
  # TODO: T.untyped
  # TODO: T.cast
  # TODO: T.unsafe
  # TODO: T.must
  # TODO: T.assert_type!
end

# The following types are not officially documented but are still useful.

module ValueSet
  # TODO: T.enum
end

module Generics
  # TODO: type_parameters / T.type_parameter
  # TODO: T::Generic
  # TODO: type_member
end