Solargraph

A Ruby Language Server

Type Checking

NOTE: Type checking is a work in progress. Users are encouraged to suggest improvements and report bugs in the GitHub repo.

Solargraph provides a type checker that can idenfity problems like invalid tags, unrecognized methods, and mismatched types.

The type checker performs its diagnosis through a combination of YARD tag analysis and type inference. YARD tags help Solargraph determine the developer’s intention. Type inference helps determine what the code actually does. Varying degrees of type safety can be enforced, depending on the check level.

Usage

There are two ways to use the type checker: run it from the command line, or integrate it into your editor’s diagnostics via the language server.

The Command Line

From your workspace’s root directory, run the following:

$ solargraph typecheck

For problems in a particular file:

$ solargraph ./path/to/file.rb

Use the --level option to select a check level, e.g., solargraph typecheck --level strict.

Diagnostics

Editors that support diagnostics can use the typecheck reporter. Add typecheck to your workspace’s reporters in .solargraph.yml:

reporters:
- typecheck

You can also specify a level:

reporters:
- typecheck:strict

Ignoring Errors

The typechecker can be told to ignore errors with the @sg-ignore directive:

# @sg-ignore
UnknownConstant1 # not reported
UnknownConstant2 # reported

You can also use @sg-ignore to suppress type inference discrepancies in method tags. Example in typed mode:

# Ignore the discrepancy between tagged type `String` and inferred type `Integer`
# @sg-ignore
# @return [String]
def foo
  100
end

Type Checking Rules

Solargraph supports four levels of type checking: normal, typed, strict, and strong. Each level enforces increasingly stringent type safety.

The check levels are designed to facilitate gradual typing. The more you make use of YARD documentation, the easier it becomes to increase the check level you use to verify your project’s code.

Common Rules

A few rules are standard across all check levels, including tag type validation and method call arity.

Tag Type Validation

The checker verifies that all tag types, e.g., @return [String], can be resolved. Classes, modules, duck types (e.g., [#to_s]), and special YARD types like [Boolean] and [void] are all valid.

Method Call Arity

Method calls get checked to ensure that their arguments match the method’s defined parameters.

# Arity check example
def example(arg1, arg2 = 0); end

example(1)       # OK
example(1, 2)    # OK
example(1, 2, 3) # Error: Too many arguments

Check Levels

The following sections describe the rules that apply to each check level.

The Normal Level

Normal checks validate YARD type tags, but do not perform type inference on the code. Untagged types are ignored.

class NormalExample
  # OK: Return type is a recognized class
  #
  # @return [String]
  def method1; end

  # Error: NotARealClass could not be resolved
  #
  # @return [NotARealClass]
  def method2; end

  # OK: Method without `@return` tag is ignored
  def method3; end
end

The Typed Level

Typed checks compare type tags to inferred types.

class TypedExample
  # OK: Return type matches inferred type
  #
  # @return [String]
  def method1
    'a string'
  end

  # Error: Tagged type Integer does not match inferred type String
  #
  # @return [Integer] 
  def method2
    'a string'
  end

  # OK: Method without `@return` tag is ignored
  #
  def method3
    'a string'
  end

  # OK: Tagged type is valid when inferred type is unknown
  #
  # @return [String]
  def method4
    unknown_method
  end
end

The Strict Level

Strict checks require all methods to have either a tagged type or an inferred type. If an untagged method’s type cannot be inferred, the type checker issues a warning. @param tags are optional, but checked if they exist.

class StrictExample
  # OK: Type checker knows this method returns a String
  #
  def method1
    'a string'
  end

  # OK: Tagged type matches inferred type
  #
  # @return [String]
  def method2 
    'a string'
  end

  # Error: Method's type could not be inferred
  #
  def method3
    unknown_method
  end
end

Attributes are required to have @return tags because their types cannot be inferred from code.

class StrictExample
  # OK: Attribute has a `@return` tag
  #
  # @return [String]
  attr_reader :attr1

  # Error: Untagged attribute cannot be inferred
  #
  attr_reader :attr2
end

If a method has @param tags, the type checker validates it arguments.

class StrictExample
  # @param arg1 [Integer]
  # @return [String]
  def method1(arg1)
    arg1.to_s
  end
end

# OK: Method call has valid argument
StrictExample.new.method1(100)

# Error: arg1 expected Integer, received String
StrictExample.new.method1('100')

The Strong Level

Strong checks require all method types to be tagged and the inferred types to match the tags. @param tags are required.

class StrongExample
  # OK: Parameter and return types are valid.
  #
  # @param arg1 [Integer]
  # @return [String]
  def method1(arg1)
    arg1.to_s
  end

  # Error: Parameter type is untagged.
  #
  # @return [String]
  def method2(arg1)
    "received #{arg1}"
  end
end

# OK: Method call is correct
StrongExample.new.method1(100)

# Error: arg1 expected Integer, received String
StrongExample.new.method1('100')

Undefined Types

At the normal and typed check levels, the type checker allows undefined types. This behavior is similar to the way TypeScript treats Any.

At the strict and strong check levels, a call to an undefined return type results in an “unresolved call” error.

class Example
  def method1; end
end

Example.new.method1.method2
# Normal type checks ignore the call to `method2` because the return type of
# `Example#method1` is untagged.
#
# Strict type checks report an error because `Example#method1` is inferred to
# be `nil` and `NilClass#method2` is not a recognized method.

Overloaded Methods

Overloaded methods inherit tags from superclasses or included modules.

module Mixin
  # @return [Integer]
  def integer
    100
  end
end

class User1
  include Mixin

  # OK: User1#integer returns same type as Mixin#integer
  #
  def integer
    200
  end
end

class User2
  include Mixin

  # Error: Declared return type Integer does not match inferred type String
  #
  def integer
    '100'
  end
end

Abstract Methods

Sometimes you might want to declare “abstract” methods. One example is a class with methods that are declared as part of its interface but need to be implemented in subclasses. Here’s a simple example of a class with an abstract method:

class Greeter
  # Subclasses should implement this method
  #
  # @return [String]
  def greeting
    raise NotImplementedError
  end

  # @return [void]
  def say_greeting
    puts greeting
  end
end

class Person < Greeter
  def greeting
    "Hello!"
  end
end

class Cowboy < Greeter
  def greeting
    "Howdy!"
  end
end

class Stranger < Greeter
end

Person.new.say_greeting    #=> "Hello!"
Cowboy.new.say_greeting    #=> "Howdy!"
Stranger.new.say_greeting  #=> NotImplementedError

In strict mode, type checks would report an error for Greeter#greeting because it doesn’t return a String. You can use the @abstract tag to indicate that its return type should be applied to subclasses that implement it.

class Greeter
  # @abstract
  # @return [String]
  def greeting
    raise NotImplementedError
  end
end

class Right < Greeter
  # OK: The implementation satisfies the abstract method's expectations
  #
  def greeting
    "hello"
  end
end

class Wrong < Greeter
  # Error: Declared return type String does not match inferred type Integer
  #
  def greeting
    100
  end
end