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 usingtyped: 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.
# 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) }
end
# Define parameter value types with `params`.
sig { params(n: Integer).returns(String) }
(1..n).map { greet }.join()
end
# Define keyword parameters the same way.
sig { params(n: Integer, sep: String).returns(String) }
(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
end
# For a method whose return value is useless, use `void`.
sig { params(name: String).void }
puts
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 }
if kwargs[:op] ==
args.each { puts(i - 1) }
else
args.each { puts(i + 1) }
end
end
# Most initializers should be `void`.
sig { params(name: String).void }
# 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.
= T.let(, 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
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) }
str ==
end
# Reminder that the value `nil` is an instance of NilClass.
sig { params(val: NilClass).void }
; 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]) }
keyset = [:old_key, :new_key]
config.each_pair.flat_map do
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).
; end
sig { params(dep: T.class_of(Dep)).returns(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
data.sum(&blk)
end
sig { returns(Integer) }
count([, , ]) { 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) }
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 }
num.instance_eval(&blk)
end
sig { void }
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) }
2 * blk.call
end
end
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) }
num.round(-2)
end
# `T.nilable(Type)` is a convenient alias for `T.any(Type, NilClass)`.
sig { params(val: T.nilable(String)).returns(Integer) }
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.
extend T::Sig
sig { void }
# Pretend this is actually implemented
end
end
extend T::Sig
sig { void }
# Pretend this is actually implemented
end
end
include Reversible
include Sortable
end
sig { params(list: T.all(Reversible, Sortable)).void }
# reverse from Reversible
list.reverse
# sort from Sortable
list.sort
end
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.
= T.type_alias { T.any(Float, String, T::Boolean, NilClass) }
sig { params(val: JSONLiteral).returns(String) }
val.to_s
end
end
# 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".
# 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:
# 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 }
self.count = 0
end
end
sig { params(text: String, matchers: T::Array[Matcher]).void }
matchers.each(&:reset)
text.lines.each do
matchers.each do
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.
const :list, T::Array[Integer]
end
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").
const :x, Integer
const :y, Integer
end
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.
# Note: reopened class
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.
extend T::Sig
# Start initialization with `enum`.
enums do
# Define each member with `new`. Each of these is an instance of the
# `Crayon` class.
= new
= new
= new
= new
= new
= new
= new
= new
# The default value of the enum is its name in all-lowercase. To change
# that, pass a value to `new`.
= new()
end
# Define any aliases outside the initialization block.
= Violet
sig { returns(String) }
case self
when Red then
when Green then
# ...
else
end
end
end
sig { params(crayon: Crayon, path: T::Array[Point]).void }
path.each do
puts + crayon.to_hex
end
end
end
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) }
if name.nil?
else
# Sorbet knows that `name` must be a String here, so it's safe to call
# `#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]) }
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`!
enums do
= new()
= new()
= new()
# "640K ought to be enough for anybody"
end
sig { returns(Integer) }
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) }
raise ArgumentError, unless num
num - 1
end
# You can annotate your own error-raising methods with `T.noreturn`.
; end
sig { params(message: String).returns(T.noreturn) }
puts message
raise CustomError, message
end
# It also applies to infinite loops.
sig { returns(T.noreturn) }
loop do
.each_char do
print
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)) }
rand > 0.5 ? 42 : nil
end
sig { void }
if answer.nil?
0
else
# But answer might return `nil` if we call it again!
answer + 1
end
end
sig { void }
ans = answer
if ans.nil?
0
else
# Now Sorbet knows that `ans` is non-nil.
ans + 1
end
end
end
#
# TODO: T.self_type
# TODO: T.attached_class
end
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!
extend T::Sig
sig { returns(T.self_type) }
pp self
self
end
end
extend T::Sig
sig { params(x: Integer, y: String).void }
@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) }
@x = x
self
end
sig { params(y: String).returns(Data) }
@y = y
self
end
end
# Tada!
sig { void }
data.setX(1).log.setY()
end
# If it's a class method (a.k.a. singleton method), use `T.attached_class`.
# No warning here. This one is stable!
extend T::Sig
sig { params(contents: String, weight: Integer).void }
@contents = contents
@weight = weight
end
sig { params(contents: String).returns(T.attached_class) }
new(contents, contents.chars.uniq.length)
end
end
extend T::Sig
sig { returns(String) }
end
end
sig { returns(String) }
CompanionCube.pack().pick_up
end
# TODO: abstract!
# TODO: interface!
# TODO: abstract. / override.
# TODO: mixes_in_class_methods
# TODO: final!
# TODO: sealed!
end
# TODO: T.reveal_type
end
# 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.
# TODO: T.enum
end
# TODO: type_parameters / T.type_parameter
# TODO: T::Generic
# TODO: type_member
end