Static typing description is achieved via Ruby core RBS.
Static type checking is achieved via Steep.
bundle exec steep check [sources]
The sources
arguments are optional and used to scope type checking to a smaller set of files or directories.
bundle exec rbs prototype rb [source files]
Outputs .rbs
content on stdout. The source files
arguments lists the files from which the skeleton will be statically built from parsing. No evaluation occurs, therefore this has limited typing analysis capability, typically resulting in a lot of untyped
.
Note: Comments are reproduced as is which is useful for a visual check but should be manually removed when moving the skeleton to a .rbs
file, because of the duplication and risk of comments getting desynced.
Ruby code in .rb
files are, as is customary for a gem, stored in lib
.
While RBS type annotations could be put inline, this creates a lot of noise, hampering "pure Ruby" readability. While the closeness with the code itself may look like an advantage, such annotations live in comments, which are harder to read and mix with other comments.
RBS types can be described in any number of .rbs
files, stored in sig
. These files can be generated, syntax highlighted, checked, linted, and more. This is therefore the chosen approach.
While the presence of .rb
and .rbs
files is entirely decoupled, here we choose to have one .rbs
file per .rb
file, mirroring the lib
structure in sig
. This has a number of advantages such as tracking typing progress, noticing stale files, generating new files without messing with existing type information, configuring IDEs and editors to jump from source to signature and back...
Tools such as rbs prototype
output comments. These should be removed, and only comments relevant to typing should end up in .rbs
files.
Similar to many other Ruby tools, Steep reads project configuration from a DSL in Steepfile
. We will use that to allow progressive typing.
Steep distinguishes between loading signatures and actually checking code for signatures. This is extremely useful to progressively type code, limiting check scope while still being able to provide signatures to code that can't be fully checked yet.
target :default do
signature "sig" # ALL signatures from this directory will be loaded
check "lib/foo/bar" # ONLY this source code folder will be checked against, using ALL signatures above
ignore "lib/foo/bar/baz" # EXCEPT this subfolder
end
Steep starts with a minimal core loaded type signatures. Adding more types from the Ruby stdlib should be done progressively as required:
library "set" # adds typing for Ruby stdlib's Set
Note: These signatures are part of rbs
, which is included in Ruby releases since Ruby 3.0.
Gems can embed a sig
directory, which can be used directly:
library "some_gem_with_a_sig_dir"
Some gems don't have typing information.
In addition, a vast collection of gems have been typed. These can be fetched via a Rubygems/Bundler-like feature of RBS called collections
collection_config "rbs_collection.steep.yaml"
This yaml file is akin to a Gemfile, describes the sources and gem signatures to fetch, and also has a lockfile mechanism. It can also integrate with bundler
to match the signatures with the gem versions in use.
Otherwise signatures can be vendored:
repo_path "vendor/rbs"
library "subdir"
Typically these are be written as needed for gems entirely missing signatures, and ideally contributed back either upstream to the gem project itself or to the gem rbs collection project.
With the described layout and 1:1 match, it becomes easy to track coarse-grained coverage, additions, removals, changes through refactorings, in a similar way as is usually done with unit tests or specs.
In addition, to output typing detailed coverage statistics:
bundle exec steep stats
To type a .rb
file without a matching .rbs
file, start with the skeleton:
mkdir -p sig/foo
bundle exec rbs prototype rb lib/foo/bar.rb > sig/foo/bar.rbs
One can then proceed to adjusting the signatures (by example), removing as much untyped
as possible.
To discover types, one can leverage typeprof
. Contrary to rbs prototype rb
which relies solely on static parsing, typeprof
is a Ruby interpreter, except it doesn't execute Ruby code, merely evaluates it to track types. Entry point calls to explore the various codepaths are required.
With this file:
# test.rb
def foo(x)
p x # reveal type of x
if x > 10
x.to_s
else
nil
end
end
foo(42) # this call is needed otherwise there's nothing evaluated!
foo(3) # make sure to explore as many codepaths as possible to get best coverage
The following is evaluated:
$ typeprof test.rb
# TypeProf 0.21.2
# Revealed types
# foo.rb:3 #=> Integer
# Classes
class Object
private
def foo: (Integer x) -> String?
end
One quick hackish way to type a class is to add a bunch of calls all the way down the file defining that class and run typeprof
on it exploring the most interesting codepaths. This can also be achieved with a separate file requiring the one we want to type and performing calls there. In theory typeprof
could be run on unit test files having 100% coverage and output precise type information for the tested code.
See the demo doc for more examples and features.
# check everything
bundle exec rake rbs:stale
# check one file
bundle exec rake rbs:stale[sig/foo/bar.rbs]
# check a directory
bundle exec rake rbs:stale[sig/foo]
# clean stale files and empty directories
bundle exec rake rbs:clean
# check everything
bundle exec rake rbs:missing
# check one file
bundle exec rake rbs:missing[lib/foo/bar.rb]
# check a directory
bundle exec rake rbs:missing[lib/foo]
# prototype one file if missing
bundle exec rake rbs:prototype[lib/foo/bar.rb]
# prototype one file unconditionally
bundle exec rake rbs:prototype[force,lib/foo/bar.rb]
# prototype missing signatures in a directory
bundle exec rake rbs:prototype[lib/foo]
# prototype all files in a directory
bundle exec rake rbs:prototype[force, lib/foo]
# prototype every missing file
bundle exec rake rbs:prototype
# prototype every file
bundle exec rake rbs:prototype[force]