Back to Scaling Ruby Applications guides

Getting Started with TypeProf

Stanley Ulili
Updated on October 30, 2025

TypeProf is Ruby's official type inference engine that automatically generates RBS type signatures by analyzing your code. Developed by the Ruby core team and shipping with Ruby 3.0+, TypeProf eliminates the tedious work of writing type signatures by hand, letting you bootstrap type coverage for entire codebases in minutes.

What makes TypeProf compelling is its ability to understand Ruby's dynamic nature while producing static type information. Unlike tools that require you to annotate every method, TypeProf examines how values flow through your program and infers types automatically. You write normal Ruby code, run TypeProf, and get RBS signatures that document your interfaces.

This guide shows you how to use TypeProf effectively, understand what it can and can't infer, and leverage it to build comprehensive type coverage for your Ruby projects.

Prerequisites

You'll need Ruby 3.0 or later since TypeProf ships as part of the standard library starting from that version. This guide assumes you're comfortable with Ruby fundamentals and have a basic understanding of types. Familiarity with RBS syntax helps but isn't required - TypeProf generates the signatures for you.

Setting up your first TypeProf project

Let's create a Ruby project to explore how TypeProf analyzes code and generates signatures. Start by setting up a basic structure:

 
mkdir typeprof-demo && cd typeprof-demo
 
bundle init

Open the generated Gemfile and add TypeProf:

Gemfile
source 'https://rubygems.org'

group :development do
gem 'typeprof', require: false
end

Install it:

 
bundle install

Create a directory for the source code:

 
mkdir lib

Then create a simple Ruby file to analyze:

lib/greeter.rb
class Greeter
  def initialize(name)
    @name = name
  end

  def greet
    "Hello, #{@name}!"
  end

  def formal_greeting
    "Good day, #{@name}. How are you?"
  end
end

Run TypeProf on this file:

 
bundle exec typeprof lib/greeter.rb
Output
# TypeProf 0.30.1

# lib/greeter.rb
class Greeter
  def initialize: (untyped) -> untyped
  def greet: -> String
  def formal_greeting: -> String
end

TypeProf analyzed the code and inferred that @name is a String based on how it's used in string interpolation. It also determined that initialize takes a String parameter and returns void, while the greeting methods return String values.

The inference works by tracking how values flow through your program. When TypeProf sees "Hello, #{@name}!", it knows the result is a string, and therefore greet returns a string. When it sees @name = name in initialize, it infers that name must be a string to make the string interpolation valid.

Create a file that uses the Greeter class:

lib/app.rb
require_relative 'greeter'

greeter = Greeter.new("Alice")
puts greeter.greet
puts greeter.formal_greeting

Run TypeProf on both files:

 
bundle exec typeprof lib/greeter.rb lib/app.rb
Output
# TypeProf 0.30.1

# lib/greeter.rb
class Greeter
  def initialize: (String) -> String
  def greet: -> String
  def formal_greeting: -> String
end

# lib/app.rb

The output remains the same because the usage in app.rb confirms TypeProf's inferences. The concrete value "Alice" validates that initialize expects a String.

This is TypeProf's basic workflow: you point it at Ruby files, it analyzes them, and it generates RBS signatures. The more context you provide through actual usage, the more confident TypeProf becomes about the types.

How TypeProf infers types

TypeProf uses abstract interpretation to track how values move through your program. It doesn't execute your code - instead, it simulates execution symbolically, building up knowledge about what types exist at each point.

Let's see this in action with a method that performs calculations:

lib/calculator.rb
class Calculator
  def add(a, b)
    a + b
  end

  def double(x)
    add(x, x)
  end

  def sum_array(numbers)
    total = 0
    numbers.each do |num|
      total = add(total, num)
    end
    total
  end
end

Run TypeProf:

 
bundle exec typeprof lib/calculator.rb
Output
# TypeProf 0.30.1

# lib/calculator.rb
class Calculator
  def add: (Complex | Float | Integer | Rational, untyped) -> (Complex | Float | Integer | Rational)
  def double: (untyped) -> (Complex | Float | Integer | Rational)
  def sum_array: (untyped) -> (Complex | Float | Integer | Rational)
end

TypeProf inferred that these methods work with integers, but how? It saw total = 0 which is an integer literal, then tracked that total gets passed to add along with elements from the numbers array. Since total is an integer, the array elements must also be integers for add to work consistently.

The inference becomes more interesting when you give TypeProf concrete usage examples. Create a file that calls these methods:

lib/calc_demo.rb
require_relative 'calculator'

calc = Calculator.new
puts calc.add(5, 3)
puts calc.double(7)
puts calc.sum_array([1, 2, 3, 4, 5])

Run TypeProf on both files:

 
bundle exec typeprof lib/calculator.rb lib/calc_demo.rb
Output
# TypeProf 0.30.1

# lib/calculator.rb
class Calculator
  def add: (Complex | Float | Integer | Rational, Integer) -> (Complex | Float | Integer | Rational)
  def double: (Integer) -> (Complex | Float | Integer | Rational)
  def sum_array: ([Integer, Integer, Integer, Integer, Integer]) -> (Complex | Float | Integer | Rational)
end

# lib/calc_demo.rb

The signatures match because the usage confirms the inferred types. TypeProf saw you passing integer literals and arrays of integers, which validates its analysis.

Now let's see what happens with methods that work with multiple types:

lib/formatter.rb
class Formatter
  def format(value)
    if value.is_a?(String)
      value.upcase
    elsif value.is_a?(Integer)
      value.to_s
    else
      value.inspect
    end
  end
end

Run TypeProf:

 
bundle exec typeprof lib/formatter.rb
Output
# TypeProf 0.30.1

# lib/formatter.rb
class Formatter
  def format: (untyped) -> untyped
end

TypeProf falls back to untyped for the parameter because it can't determine from the code alone what types will actually be passed. The method handles multiple types differently, but without concrete usage, TypeProf stays conservative.

Provide usage examples to help TypeProf:

lib/format_demo.rb
require_relative 'formatter'

formatter = Formatter.new
formatter.format("hello")
formatter.format(42)
formatter.format([1, 2, 3])

Run TypeProf on both files:

 
bundle exec typeprof lib/formatter.rb lib/format_demo.rb
Output
# TypeProf 0.30.1

# lib/formatter.rb
class Formatter
  def format: (Integer | String | [Integer, Integer, Integer]) -> String
end

# lib/format_demo.rb

Now TypeProf inferred a union type based on the actual usage patterns. It saw three different types being passed and generated a signature that accepts any of them.

This demonstrates an important principle: TypeProf's accuracy improves when you provide representative usage. The more your codebase shows how methods get called, the better TypeProf understands the intended types.

Understanding TypeProf's limitations

TypeProf works remarkably well for straightforward Ruby code, but it has boundaries. Understanding where it struggles helps you know when to write signatures manually or refactor code for better inference.

Dynamic method definitions confuse TypeProf because it analyzes code statically. Here's an example:

lib/dynamic.rb
class DynamicClass
  [:foo, :bar, :baz].each do |name|
    define_method(name) do
      "Called #{name}"
    end
  end
end

Run TypeProf:

 
bundle exec typeprof lib/dynamic.rb
Output
# TypeProf 0.30.1

# lib/dynamic.rb
class DynamicClass
end

TypeProf doesn't generate signatures for the dynamically defined methods because it can't determine at analysis time what methods will exist. The define_method call happens too dynamically for static analysis to follow.

For code like this, write the RBS signature manually:

 
mkdir sig
sig/dynamic.rbs
class DynamicClass
  def foo: () -> String
  def bar: () -> String
  def baz: () -> String
end

Methods that use method_missing face similar issues:

lib/proxy.rb
class Proxy
  def method_missing(name, *args)
    "Proxied: #{name}"
  end

  def respond_to_missing?(name, include_private = false)
    true
  end
end

Run TypeProf:

 
bundle exec typeprof lib/proxy.rb
Output
# TypeProf 0.30.1

# lib/proxy.rb
class Proxy
  def method_missing: (untyped, *untyped) -> String
  def respond_to_missing?: (untyped, ?false) -> true
end

TypeProf generates signatures for the actual methods defined, but it can't infer what methods method_missing will handle. You'll need to document those separately in RBS.

TypeProf also struggles with complex control flow and conditional logic:

lib/complex.rb
class Complex
  def process(input, mode)
    result = case mode
    when :double
      input * 2
    when :triple
      input * 3
    when :stringify
      input.to_s
    end

    result
  end
end

Run TypeProf:

 
bundle exec typeprof lib/complex.rb
Output
# TypeProf 0.30.1

# lib/complex.rb
class Complex < Numeric
  def process: (untyped, untyped) -> nil
end

TypeProf inferred the return type is (Integer | String)? because the case statement might return nil if mode doesn't match any branch. It also knows that when the mode matches :double or :triple, an integer returns, but when mode is :stringify, a string returns.

The nullable return (? at the end) happens because TypeProf recognizes the case statement might not match anything. Fix this by adding an else clause:

lib/complex.rb
class Complex
  def process(input, mode)
    result = case mode
    when :double
      input * 2
    when :triple
      input * 3
    when :stringify
      input.to_s
else
input
end result end end

Run TypeProf again:

 
bundle exec typeprof lib/complex.rb
Output
# TypeProf 0.30.1

# lib/complex.rb
class Complex < Numeric
  def process: (untyped, untyped) -> untyped
end

Now the return type is no longer nullable because TypeProf knows the else branch ensures something always returns.

External dependencies also limit TypeProf's effectiveness. When your code calls methods from gems, TypeProf needs RBS signatures for those gems to understand the types:

lib/http_client.rb
require 'net/http'

class HttpClient
  def fetch(url)
    uri = URI(url)
    Net::HTTP.get(uri)
  end
end

Run TypeProf:

 
bundle exec typeprof lib/http_client.rb
Output
# TypeProf 0.30.1

# lib/http_client.rb
class HttpClient
  def fetch: (untyped) -> untyped
end

TypeProf successfully inferred the signature because Ruby's standard library includes RBS signatures. If you use gems without RBS signatures, TypeProf will generate untyped for those interactions.

The key is recognizing when TypeProf's output needs refinement. Use it as a starting point, then review and improve the generated signatures based on your actual requirements.

Final thoughts

This article showed you how TypeProf automatically generates RBS type signatures by analyzing Ruby code. The tool examines how values flow through your program and infers types without requiring annotations, making it practical to bootstrap type coverage quickly.

TypeProf works best with straightforward Ruby code where type flow is clear. Provide concrete usage examples to improve accuracy, and recognize when dynamic features like metaprogramming require manual RBS signatures. The combination of automated generation and targeted refinement creates comprehensive type documentation efficiently.

As Ruby's official type inference tool, TypeProf represents a low-friction path to static type checking. Generate signatures quickly, review them for accuracy, and maintain them alongside your code as it evolves.

Got an article suggestion? Let us know
Next article
Top 10 Ruby on Rails Alternatives for Web Development
Discover the top 10 Ruby on Rails alternatives. Compare Django, Laravel, Express.js, Spring Boot & more with detailed pros, cons & features.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.