December 5, 2021

Live Ruby Type Checking with TypeProf-IDE


In his RubyConf 2021 keynote, the creator of Ruby, Yukihiro Matsumoto, announced TypeProf-IDE, a Visual Studio Code integration for Ruby’s TypeProf tool to allow real-time type analysis and developer feedback. In another session, the creator of TypeProf-IDE, Yusuke Endoh, demoed the extension in more detail. This functionality is available for us to try today in Ruby 3.1.0 preview 1, which was released during RubyConf. So let’s give it a try!

Setup

First, install Ruby 3.1.0 preview 1. If you’re using rbenv on macOS, you can install the preview by executing the following commands in order:

  • brew update
  • brew upgrade ruby-build
  • rbenv install 3.1.0-preview1

Next, create a project folder:

  • mkdir typeprof_sandbox
  • cd typeprof_sandbox

If you’re using rbenv, you can configure the preview to be the version of Ruby used in that directory:

  • rbenv local 3.1.0-preview1

Next, initialize the Gemfile:

Next, let’s set up Visual Studio Code. Install the latest version, then add the TypeProf VS Code extension and the RBS Syntax Highlighting extension.

Open your typeprof_sandbox folder in VS Code. Next, open the Gemfile and add the typeprof gem:

 git_source(:github)  "https://github.com/#repo_name" 

 #gem "rails"
+gem 'typeprof', '0.20.3'

Now install it:

Getting Type Feedback

To see TypeProf in action, let’s create a class for keeping track of members of a meetup group. Create a file meetup.rb and add the following:

class Meetup
  def initialize
    @members = []
  end

  def add_member(member)
    @members.push(member)
  end

  def first_member
    @members.first
  end
end

It’s possible you will already see TypeProf add type signatures to the file, but more likely you won’t see anything yet. If not, to find out what’s going on, click the “View” menu, then choose “Output”. From the dropdown at the right, choose “Ruby TypeProf”. You’ll see the output of the TypeProf extension, which will likely include a Ruby-related error. What I see is:

[vscode] Try to start TypeProf for IDE
[vscode] stderr: --- ERROR REPORT TEMPLATE -------------------------------------------------------
[vscode] stderr:
[vscode] stderr: ```
[vscode] stderr: Gem::GemNotFoundException: can't find gem bundler (= 2.2.31) with executable bundle
[vscode] stderr:   /Library/Ruby/Site/2.6.0/rubygems.rb:278:in `find_spec_for_exe'

In my case, the command is running in my macOS system Ruby (/Library/Ruby/Site/2.6.0) instead of my rbenv-specified version. I haven’t been able to figure out how to get it to use the rbenv version. As a workaround, I switched to the system Ruby and updated Bundler:

  • rbenv local system
  • sudo gem update bundler
  • rbenv local 3.1.0-preview1

For more help getting the extension running, check the TypeProf-IDE Troubleshooting docs. Of note is the different places that the extension tries to invoke typeprof from. Ensure that your default shell is loading Ruby 3.1.0-preview1 and that a typeprof binary is available wherever the extension is looking.

After addressing whatever error you see in the output, quit and reopen VS Code to get the extension to reload. When it succeeds, you should see output like the following:

[vscode] Try to start TypeProf for IDE
[vscode] typeprof version: typeprof 0.20.2
[vscode] Starting Ruby TypeProf (typeprof 0.20.2)...
[vscode] Ruby TypeProf is running
[Info  - 9:03:49 AM] TypeProf for IDE is started successfully

You should also see some type information added above the methods of the class:

screenshot of a code editor showing the Meetup class with type signatures added above each method

Well, that’s not a lot of information. We see that #add_member takes in an argument named member, but its type is listed as untyped (which means, the type information is unknown). It returns an Array[untyped], meaning an array containing elements whose type is unknown. Then #first_member says it returns nil, which is incorrect.

Improving the Types and Code

For our first change, let’s look at the return value of #add_member. It’s returning an Array, but I didn’t intend to return a value; this is just a result of Ruby automatically returning the value of the last expression in a method. Let’s update our code to remove this unintentional behavior. Add a nil as the last expression of the method:

 def add_member(member)
   @members.push(member)
+  nil
 end


Now the return type is updated to be NilClass, which is better:

screenshot of an editor showing an add_member method definition, with a type signature showing it returns NilClass

Next, how can we fix the untyped? Endoh recommends a pattern of adding some example code to the file showing the use of the class. Add the following at the bottom of meetup.rb:

if $PROGRAM_NAME == __FILE__
  meetup = Meetup.new
end

Next, type meetup.ad below the line where meetup is assigned. (We’ll explain the $PROGRAM_NAME line in a bit.) An autocomplete dropdown will appear, with add_member selected:

screenshot of an editor showing a variable meetup with the letters "ad" typed as the beginning of a method call. an autocomplete dropdown is shown with the method add_member highlighted

Because TypeProf can see that meetup is an instance of class Meetup, it can provide autocomplete suggestions for methods.

Click add_member in the list, then type an opening parenthesis (. VS Code will add the closing parenthesis ) after the cursor, and another popup will appear with information about the method’s arguments:

screenshot of an editor showing an add_member method call, with parenthesis but no arguments passed. a tooltip shows the method signature

It indicates that the method takes one argument, member, and returns nil. Also note that the type of member is still listed as untyped; we’re still working toward fixing that.

Pass a string containing your name as the argument, then add the rest of the code below:

if $PROGRAM_NAME == __FILE__
  meetup = Meetup.new
  meetup.add_member('Josh')
  first_member = meetup.first_member
  puts first_member
end

What’s the significance of the if $PROGRAM_NAME == __FILE__ conditional? $PROGRAM_NAME is the name of the currently running program, and __FILE__ is the name of the current source file. If they are equal, that means that this Ruby file is being executed directly, which includes when TypeProf runs the file. So this is a way to provide supplemental information to TypeProf.

When you added this code, the type information should have updated to:

screenshot of an editor showing two method definitions, add_member and first_member, along with type signatures above them. both have been updated to show the type String instead of untyped

Why does this added code affect the type of information? TypeProf executes the code to see the types that are actually used by the program. By supplying an example of using the class, TypeProf has more type information to work with. Future TypeProf development may allow it to be more intelligent about inferring type information from RSpec tests and usages elsewhere in the project.

Note that TypeProf now indicates that the member argument is a String, and that #first_member may return either a NilClass or a String. (The reason it might return a NilClass is if the array is empty.)

Making the Code Generic with Type Variables

Let’s put our object-oriented design hats on and think about these types. Is it specific to Strings? No, the code doesn’t make any assumptions about what the members are. But TypeProf has coupled our class to one specific other class to use!

To prevent this, we can manually edit the RBS type signatures generated for our class to indicate just how generic we want Meetup to be.

Create an empty typeprof.rbs file in your project folder. Next, command-click on the type signature above #add_member. The typeprof.rbs file will open, and the RBS type signature for that method will be automatically added to it:

screenshot of an editor showing an RBS file with a type definition for the add_member method of the Meetup class

Next, go back to meetup.rb and right-click the type signature above #first_member. This adds the signature for that method to the RBS file too, but as a separate class declaration:

screenshot of an editor showing an RBS file with two separate Meetup class definitions. each one has a type signature for a different method: first_member and add_member

To keep things simpler, edit the RBS file so there’s a single class with two methods in the same order as in the Ruby file, and save the file:

screenshot of an editor showing an RBS file with a single Meetup class definition containing signatures for two methods: add_member and first_member

Now, let’s edit the signature to use type variables. A type variable is a place where, instead of referencing a specific type, you use a variable that can represent any type. Everywhere the same type variable appears, the type must be the same.

First, add a [MemberT] after the Meetup class name:

screenshot of an editor showing an RBS file with a Meetup class definition. an arrow points to a type variable MemberT that has been added to the class

Next, replace the two occurrences of String with MemberT:

screenshot of an editor showing an RBS file with arrows pointing to the type variable MemberT in two places: as an argument to method add_member, and as part of the return type of method first_member, along with NilClass

What this means is, a given Meetup instance applies to a certain type, called MemberT. That’s the type of the member you pass in to #add_member. That is the same type as what the return value of #first_member should be. So if you pass in a String you should get a String back. If you pass in a Hash, you should get a Hash.

Switch back to meetup.rb. If you don’t see the type signatures updated, you may need to close and reopen meetup.rb. Then, you should see updated type signatures:

screenshot of an editor showing a Ruby class Meetup. type signatures appear over the methods, including the MemberT type variable as an argument to method add_member, and as part of the return type of method first_member

Note that our MemberT types appear in the signatures of #add_member and #first_member. Also note that the signatures have a # in front of them: this indicates that they’re manually specified in the RBS file.

Now, let’s see what help this gives us. In the statement puts first_member, start typing .up after it. Note that an autocomplete dropdown appears and #upcase is selected:

screenshot of an editor showing a variable first_member with the letters "up" typed as the beginning of a method call. an autocomplete dropdown is shown with the method upcase highlighted

TypeProf knows that member is a Meetup object. Because you passed a String into the #add_member method of the meetup object, TypeProf can tell that meetup’s type variable MemberT is equal to the type String. As a result, it can see that its #first_member method will also return a String. So it knows first_member is a string, and therefore it can suggest String’s methods for the autocomplete.

Click upcase to autocomplete it. Now note that first_member.upcase has a red squiggly underlining it. Hover over it to see the error:

screenshot of an editor showing an error indicator under the method call upcase on variable first_member. the error message says "undefined method: nil#upcase"

It says [error] undefined method: nil#upcase. But wait, isn’t first_member a String? The answer is maybe. But it could also be a nil if the meetup hasn’t had any members added to it. And if it is nil, this call to #upcase will throw a NoMethodError. Now, in this trivial program we know there will be a member present. But for a larger program, TypeProf will have alerted us to an unhandled edge case!

To fix this, we need to change the way the type signature is written slightly. In the RBS file, replace (NilClass | MemberT) with MemberT? (don’t miss the question mark):

screenshot of an editor showing an RBS file, with an arrow pointing to the return type of a first_member method, which is MemberT followed by a question mark

? indicates an optional type, a case where a value could be a certain type or it could be nil.

Now, in the Ruby file, wrap the puts call in a conditional:

 first_member = meetup.first_member
-puts first_member.upcase
+if first_member
+  puts first_member.upcase
+else
+  puts 'first_member is nil'
+end

If the red squiggly under the call to #upcase doesn’t disappear, close and reopen meetup.rb to get TypeProf to rerun. After that, if you made the changes correctly, the underline should disappear:

screenshot of an editor showing Ruby code with a call to the first_member method of object meetup. the result is assigned to variable first_member. a conditional checks if first_member is truthy. if so, upcase is called on it and the result is outputted. if first_member is not truthy, the string "first_member is nil" is outputted

TypeProf has guided us to write more robust code! Note that currently TypeProf requires the check to be written as if variable; other idioms like unless variable.nil? and if variable.present? will not yet work.

Next Steps

If you’d like to learn more about TypeProf-IDE, Endoh’s RubyConf 2021 talk should be uploaded to YouTube within a few months. In the meantime, check out the TypeProf-IDE documentation and the RBS syntax docs. And you can help with the continued development of TypeProf-IDE by opening a GitHub Issue on the typeprof repo.

Thank you to Yusuke Endoh for his hard work building the TypeProf-IDE integration, for his presentation, and for helping me work through issues using it during RubyConf!

If you’d like to work at a place that explores the cutting edge of Ruby and other languages, join us at Big Nerd Ranch!

Josh Justice

Author
Big Nerd Ranch

Josh Justice has worked as a developer since 2004 across backend, frontend, and native mobile platforms. Josh values creating maintainable systems via testing, refactoring, and evolutionary design, and mentoring others to do the same. He currently serves as the Web Platform Lead at Big Nerd Ranch.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *