skip to content

Gradual Type Signatures

RBS files describe Ruby types beside the code, TypeProf infers them and Steep checks code against them, while Sorbet uses inline sigs. Interviewers ask what types catch in a dynamic language.

on this pageshow

explore

questions

5

In Ruby's RBS signature language, how do you declare a class's methods, including optional, keyword, nilable and block parameters?

level: middleimportance: should knowfreq 28%

answer

  1. separate .rbs files under sig/
  2. def name: (params) -> return
  3. ?opt, key:, ?key: keyword
  4. Type? means Type or nil
  5. { (Elem) -> void } for a block

basics

~20 s

RBS declares Ruby types in separate .rbs files, usually under sig/: def name: (Integer, ?String, key: Symbol) -> String. A leading ? marks an optional parameter, a trailing ? a nilable type, and { (T) -> void } a block.

solid answer

~40 s

An RBS file mirrors the Ruby structure: `class Billing::Invoice ... end` containing members. A method is `def add_line: (String description, Integer cents, ?quantity: Integer) -> self`: positional types in order, `?` before an optional positional or keyword, `key: Type` for a required keyword, `*T` and `**T` for rest parameters, and the return type after `->`. `LineItem?` means `LineItem | nil`, `|` builds unions, `Array[LineItem]` and `Hash[String, Integer]` are generics, `bool`, `void` and `untyped` are built in. A block is written before the arrow, `() { (LineItem) -> void } -> self`, with `?{ ... }` when it is optional, and overloads are joined with `|`. Instance variables are `@paid_at: Time?`, attributes `attr_reader total_cents: Integer`, class methods `def self.load: (String) -> Invoice`. `rbs -I sig validate` checks the files.

code

ruby · 15 lines
ruby
module Billing
  class Invoice
    attr_reader :customer_id, :lines

    def initialize(customer_id:, currency: :usd)
      @customer_id = customer_id
      @currency = currency
      @lines = []
    end

    def find_line(description)
      lines.find { it.description == description }
    end
  end
end

go deeper

for a junior

Recall that RBS lives in .rbs files under sig/ and that a method is written def name: (ParamTypes) -> ReturnType.

for a middle

Explain the parameter forms, ?optional, key:, ?key:, *rest and **rest, the difference between ?T and T?, block syntax and overloads with |.

for a senior

Show judgement in the signatures: interfaces for duck types, void versus a real return type, generics instead of untyped, and rbs validate in the build.

for a principal

Treat signatures as a published contract: once sig/ ships in a gem, callers type-check against it, so signature changes need the same review as API changes.

## What RBS is **RBS** is Ruby's language for describing types. It is not Ruby syntax and Ruby never reads it while your program runs: signatures live in **separate `.rbs` files**, conventionally in a `sig/` directory that mirrors `lib/`, and tools such as Steep, TypeProf and editors read them. RBS has shipped with Ruby as a bundled gem since Ruby 3.0, and the gem's own signatures for core classes and the standard library come with it. ## Declarations and members A file contains **declarations**: `class`, `module`, `interface`, `type` aliases, constants and globals. Inside a class you write **members**: - `@paid_at: Time?` declares an instance variable. - `attr_reader total_cents: Integer` declares the reader method and its `@total_cents` variable. - `def name: METHOD-TYPE` declares an instance method; `def self.name:` a singleton (class) method; `def self?.name:` a `module_function`. - `include`, `extend` and `prepend` declare mixins; `private` and `public` change the visibility of the following definitions. ## The method type A method type reads `(parameters) block -> return`: | Ruby parameter | RBS form | |---|---| | required positional | `Integer` or `Integer cents` | | optional positional | `?Integer` | | rest positional | `*String` | | required keyword | `currency: Symbol` | | optional keyword | `?currency: Symbol` | | rest keywords | `**untyped` | | block | `{ (LineItem) -> void }`, or `?{ ... }` if optional | Parameter names are optional documentation. Overloads are joined with `|`, and a return type that is itself a union needs parentheses: `-> (Integer | Float)`. ## The types you use most 1. **Class instance types**: `String`, `Billing::LineItem`. 2. **Generics**: `Array[LineItem]`, `Hash[String, Integer]`, `Enumerator[LineItem, self]`. 3. **Optional types**: `LineItem?` is `LineItem | nil`. Watch the position: `?Integer` in a parameter list means *may be omitted*, `Integer?` means *may be nil*, and `?Integer?` means both. 4. **Unions and literals**: `:usd | :eur` is a union of two symbol literals. 5. **Records and tuples**: `{ id: Integer, name: String }` is a Hash with fixed keys, `[Integer, String]` a fixed-size Array. 6. **Interfaces**: an `interface _ToCents` declaration with a `def to_cents: () -> Integer` member describes a duck type; interface names start with `_`. 7. **Base types**: `bool` (`true | false`), `boolish` (any value used as a condition), `void` (a result you should not use), `self`, `instance`, `nil`, and `untyped`, which switches checking off. ## A billing example ```rbs type Billing::currency = :usd | :eur | :gbp module Billing class Invoice attr_reader customer_id: String attr_reader lines: Array[LineItem] @paid_at: Time? def initialize: (customer_id: String, ?currency: Billing::currency) -> void def add_line: (String description, Integer cents, ?quantity: Integer) -> self def find_line: (String description) -> LineItem? def paid?: () -> bool def each_line: () { (LineItem) -> void } -> self | () -> Enumerator[LineItem, self] def self.load: (String json) -> Invoice end end ``` `initialize` returns `void` because its value is never used; `each_line` is overloaded for the block and no-block calls, just as `Array#each` is in the core signatures. ## Checking the file itself - `rbs -I sig validate` parses the files and checks that every name they mention resolves. - `rbs -I sig method Billing::Invoice add_line` prints the resolved method type. - A gem ships its signatures by packaging `sig/`; RBS loads it automatically when another project uses the gem as a library. ## Common mistakes - Writing `Integer | nil` everywhere instead of `Integer?`, or confusing `?Integer` with `Integer?`. - Declaring a keyword argument positionally, `(Symbol currency)`, which describes a different call. - Reaching for `untyped` so often that the checker has nothing to check.

  • What is the difference between `?String` and `String?` in an RBS method type?
    `?String` in a parameter list marks an **optional positional parameter**: the caller may omit it. `String?` is an **optional type**, `String | nil`: the value may be nil. `(?String? name)` combines them, so the argument may be left out, given a String, or given nil.
  • Why would you declare an interface such as `_ToCents` instead of a class type?
    An interface describes behaviour, not ancestry: `interface _ToCents` with `def to_cents: () -> Integer` accepts any object that has that method, so `Money`, `Integer` wrappers or test doubles all fit without sharing a superclass. It is how RBS expresses Ruby's duck typing in a signature.

saying these in an interview costs you the question

  • RBS type annotations are written inside the Ruby method definitions themselves.
  • Ruby checks RBS signatures on every method call at runtime.
  • ?Integer in a parameter list means the argument may be nil.
  • Keyword arguments are declared positionally, in the order they are passed.
  • untyped means the value is always nil.
  • A block must be declared as an extra &block parameter type.
open as a page

How do you set up Steep to type-check a Ruby library against its RBS signatures, and what does steep check report?

level: middleimportance: should knowfreq 22%

basics

~20 s

steep init writes a Steepfile; a target lists the Ruby to check and the sig directory. rbs collection install supplies gem signatures, and steep check reports mismatches such as Ruby::NoMethod on a value that may be nil.

open as a page

For typing a Ruby billing library, how does a Sorbet sig block differ from an RBS signature or an inline # @rbs comment?

level: seniorimportance: should knowfreq 25%

basics

~20 s

A Sorbet sig is Ruby code, sig { params(cents: Integer).returns(Receipt) }, checked statically by srb tc and, with sorbet-runtime, on real calls. RBS lives in .rbs files or experimental # @rbs comments, has no runtime cost and is checked by Steep.

open as a page

When adding RBS signatures to an existing Ruby library, how do rbs prototype rb and TypeProf differ in the signatures they generate?

level: middleimportance: nice to knowfreq 15%

basics

~20 s

rbs prototype rb reads only the syntax, so it emits every class, method and instance variable with mostly untyped types. TypeProf abstractly runs the code and infers real types from the calls it sees; both outputs are drafts to edit.

open as a page

On a large Ruby billing codebase, how would you roll out RBS and Steep incrementally so type checking pays off without stalling feature work?

level: principalimportance: nice to knowfreq 14%

basics

~20 s

Start with the public API and money-handling paths, seed signatures with rbs prototype and TypeProf, check them with lenient Steepfile targets that tighten over time, validate signatures against the test suite with RBS_TEST_TARGET, and track progress with steep stats.

open as a page