Ruby 5.0: What If Ruby Had First-Class Types?

The article envisions a reimagined Ruby with optional, inline type annotations called TypedRuby, addressing limitations of current solutions like Sorbet and RBS. It proposes a syntax that integrates seamlessly with Ruby’s philosophy, emphasizing readability and gradual typing while considering generics and union types. TypedRuby represents a potential evolution in Ruby’s design.

After imagining a typed CoffeeScript, I realized we need to go deeper. CoffeeScript was inspired by Ruby, but what about Ruby itself? Ruby has always been beautifully expressive, but it’s also been dynamically typed from day one. And while Sorbet and RBS have tried to add types, they feel bolted on. Awkward. Not quite Ruby.

What if Ruby had been designed with types from the beginning? Not as an afterthought, not as a separate file you maintain, but as a natural, optional part of the language itself? Let’s explore what that could look like.

The Problem with Sorbet and RBS

Before we reimagine Ruby with types, let’s acknowledge why the current solutions haven’t caught on widely.

Sorbet requires you to add # typed: true comments and use a separate type checker. Types look like this:

# typed: true
extend T::Sig

sig { params(name: String, age: Integer).returns(String) }
def greet(name, age)
  "Hello #{name}, you are #{age}"
end
Code language: PHP (php)

RBS requires separate .rbs files with type signatures:

# user.rbs
class User
  attr_reader name: String
  attr_reader age: Integer
  
  def initialize: (name: String, age: Integer) -> void
  def greet: () -> String
end
Code language: CSS (css)

Both solutions have the same fundamental problem: they don’t feel like Ruby. Sorbet’s sig blocks are verbose and repetitive. RBS splits your code across multiple files, breaking the single-file mental model that makes Ruby so pleasant.

What we need is something that feels native. Something Matz might have designed if static typing had been a priority in 1995.

Core Design Principles

Let’s establish what TypedRuby should be:

  1. Types are optional everywhere. You can gradually type your codebase.
  2. Types are inline. No separate files, no sig blocks.
  3. Types feel like Ruby. Natural syntax that matches Ruby’s philosophy.
  4. Duck typing coexists with static typing. You choose when to be strict.
  5. Generic types are first-class. Collections, custom classes, everything.
  6. The syntax is minimal. Ruby is beautiful; types shouldn’t ruin that.

Basic Type Annotations

In TypeScript, you use colons. In Sorbet, you use sig blocks. TypedRuby could use a more natural Ruby approach with the :: operator we already know:

# Current Ruby
name = "Ivan"
age = 30

# TypedRuby with inline types
name :: String = "Ivan"
age :: Integer = 30

# Or with type inference
name = "Ivan"  # inferred as String
age = 30       # inferred as Integer
Code language: PHP (php)

The :: operator already means “scope resolution” in Ruby, but in this context (before assignment), it means “has type”. It’s familiar to Ruby developers and reads naturally.

Method Signatures

Current Sorbet approach:

extend T::Sig

sig { params(name: String, age: T.nilable(Integer)).returns(String) }
def greet(name, age = nil)
  age ? "Hello #{name}, #{age}" : "Hello #{name}"
end
Code language: JavaScript (javascript)

TypedRuby approach:

def greet(name :: String, age :: Integer? = nil) :: String
  age ? "Hello #{name}, #{age}" : "Hello #{name}"
end
Code language: JavaScript (javascript)

Or with Ruby 3’s endless method syntax:

def greet(name :: String, age :: Integer? = nil) :: String =
  age ? "Hello #{name}, #{age}" : "Hello #{name}"
Code language: JavaScript (javascript)

Much cleaner. The types are right there with the parameters, and the return type is at the end where it reads naturally: “define greet with these parameters, returning a String.”

Classes and Attributes

Current approach with Sorbet:

class User
  extend T::Sig
  
  sig { returns(String) }
  attr_reader :name
  
  sig { returns(Integer) }
  attr_reader :age
  
  sig { params(name: String, age: Integer).void }
  def initialize(name, age)
    @name = name
    @age = age
  end
end

TypedRuby approach:

class User
  attr_reader of String, :name
  attr_reader of Integer, :age
  
  def initialize(@name :: String, @age :: Integer)
  end
  
  def birthday :: void
    @age += 1
  end
  
  def greet :: String
    "I'm #{@name}, #{@age} years old"
  end
end
Code language: CSS (css)

Even better, we could introduce parameter properties like TypeScript:

class User
  def initialize(@name :: String, @age :: Integer, @email :: String)
    # @name, @age, and @email are automatically instance variables
  end
end
Code language: CSS (css)

Generics: The Ruby Way

This is where it gets interesting. Ruby already has a beautiful way of working with collections. TypedRuby needs to extend that naturally.

TypeScript uses angle brackets:

class Container<T> {
  private value: T;
  constructor(value: T) { this.value = value; }
}
Code language: JavaScript (javascript)

Sorbet uses square brackets:

class Container
  extend T::Generic
  T = type_member
  
  sig { params(value: T).void }
  def initialize(value)
    @value = value
  end
end

TypedRuby could use a more natural syntax with of:

class Container of T
  def initialize(@value :: T)
  end
  
  def get :: T
    @value
  end
  
  def map of U, &block :: (T) -> U :: Container of U
    Container.new(yield @value)
  end
end

# Usage
container = Container of String with.new("hello")
lengths = container.map { |s| s.length }  # Container of Integer

For multiple type parameters:

class Pair of K, V
  def initialize(@key :: K, @value :: V)
  end
  
  def map_value of U, &block :: (V) -> U :: Pair of K, U
    Pair.new(@key, yield @value)
  end
end
Code language: CSS (css)

Generic Methods

Methods can be generic too:

def identity of T, value :: T :: T
  value
end

def find_first of T, items :: Array of T, &predicate :: (T) -> Boolean :: T?
  items.find(&predicate)
end

# Usage
result = find_first([1, 2, 3, 4]) { |n| n > 2 }  # Integer?
Code language: PHP (php)

Array and Hash Types

Ruby’s arrays and hashes need type support:

# Arrays
numbers :: Array of Integer = [1, 2, 3, 4, 5]
names :: Array of String = ["Alice", "Bob", "Charlie"]

# Or using shorthand
numbers :: [Integer] = [1, 2, 3, 4, 5]
names :: [String] = ["Alice", "Bob", "Charlie"]

# Hashes
user_ages :: Hash of String, Integer = {
  "Alice" => 30,
  "Bob" => 25
}

# Or using shorthand
user_ages :: {String => Integer} = {
  "Alice" => 30,
  "Bob" => 25
}

# Symbol keys (very common in Ruby)
config :: {Symbol => String} = {
  host: "localhost",
  port: "3000"
}
Code language: PHP (php)

Union Types

Ruby’s dynamic nature often uses union types implicitly. Let’s make it explicit:

# TypeScript: string | number
value :: String | Integer = "hello"
value = 42  # OK

# Method with union return type
def find_user(id :: Integer) :: User | nil
  User.find_by(id: id)
end

# Multiple unions
status :: "pending" | "active" | "completed" = "pending"
Code language: PHP (php)

Nullable Types

Ruby uses nil everywhere. TypedRuby needs to handle this elegantly:

# The ? suffix means "or nil"
name :: String? = nil
name = "Ivan"  # OK

# Methods that might return nil
def find_user(id :: Integer) :: User?
  User.find_by(id: id)
end

# Safe navigation works with types
user :: User? = find_user(123)
email = user&.email  # String? inferred
Code language: PHP (php)

Interfaces and Modules

Ruby uses modules for interfaces. TypedRuby could extend this:

interface Comparable of T
  def <=>(other :: T) :: Integer
end

interface Enumerable of T
  def each(&block :: (T) -> void) :: void
end

# Implementation
class User
  include Comparable of User
  
  attr_reader :name :: String
  
  def initialize(@name :: String)
  end
  
  def <=>(other :: User) :: Integer
    name <=> other.name
  end
end
Code language: HTML, XML (xml)

Type Aliases

Creating reusable type definitions:

type UserId = Integer
type Email = String
type UserStatus = "active" | "inactive" | "banned"

type Result of T = 
  { success: true, value: T } |
  { success: false, error: String }

def create_user(name :: String) :: Result of User
  user = User.create(name: name)
  
  if user.persisted?
    { success: true, value: user }
  else
    { success: false, error: user.errors.full_messages.join(", ") }
  end
end
Code language: JavaScript (javascript)

Practical Example: A Repository Pattern

Let’s build something real. Here’s a generic repository in TypedRuby:

interface Repository of T
  def find(id :: Integer) :: T?
  def all :: [T]
  def create(attributes :: Hash) :: T
  def update(id :: Integer, attributes :: Hash) :: T?
  def delete(id :: Integer) :: Boolean
end

class ActiveRecordRepository of T implements Repository of T
  def initialize(@model_class :: Class)
  end
  
  def find(id :: Integer) :: T?
    @model_class.find_by(id: id)
  end
  
  def all :: [T]
    @model_class.all.to_a
  end
  
  def create(attributes :: Hash) :: T
    @model_class.create!(attributes)
  end
  
  def update(id :: Integer, attributes :: Hash) :: T?
    record = find(id)
    return nil unless record
    
    record.update!(attributes)
    record
  end
  
  def delete(id :: Integer) :: Boolean
    record = find(id)
    return false unless record
    
    record.destroy!
    true
  end
end

# Usage
user_repo = ActiveRecordRepository of User .new(User)
users :: [User] = user_repo.all
user :: User? = user_repo.find(123)
Code language: CSS (css)

Blocks and Procs with Types

Blocks are fundamental to Ruby. They need proper type support:

# Block parameter types
def map of T, U, items :: [T], &block :: (T) -> U :: [U]
  items.map(&block)
end

# Proc types
callback :: Proc of (String) -> void = ->(msg) { puts msg }
transformer :: Proc of (Integer) -> String = ->(n) { n.to_s }

# Lambda types
double :: Lambda of (Integer) -> Integer = ->(x) { x * 2 }

# Method that accepts a block with types
def with_timing of T, &block :: () -> T :: T
  start_time = Time.now
  result = yield
  duration = Time.now - start_time
  
  puts "Took #{duration} seconds"
  result
end

# Usage
result :: String = with_timing { expensive_operation() }
Code language: PHP (php)

Rails Integration

Ruby is often Rails. TypedRuby needs to work beautifully with Rails. Here’s where we need to think carefully about syntax. For method calls that take parameters, we can use a generic-style syntax that feels natural.

Generic-style method calls for associations:

class User < ApplicationRecord
  # Using 'of' with method calls (like generic instantiation)
  has_many of Post, :posts
  belongs_to of Company, :company
  has_one of Profile?, :profile
  
  # Or postfix style (reads more naturally)
  has_many :posts of Post
  belongs_to :company of Company
  has_one :profile of Profile?
  
  # For validations, types on the attribute names
  validates :email of String, presence: true, uniqueness: true
  validates :age of Integer, numericality: { greater_than: 0 }
  
  # Scopes with return types
  scope :active of Relation[User], -> { where(status: "active") }
  scope :by_name of Relation[User], ->(name :: String) {
    where("name LIKE ?", "%#{name}%")
  }
  
  # Typed callbacks still use :: for return types
  before_save :normalize_email
  
  def normalize_email :: void
    self.email = email.downcase.strip
  end
  
  # Typed instance methods
  def full_name :: String
    "#{first_name} #{last_name}"
  end
  
  def posts_count :: Integer
    posts.count
  end
end
Code language: HTML, XML (xml)

Alternative: Square bracket syntax (like actual generics):

class User < ApplicationRecord
  # Using square brackets like generic type parameters
  has_many[Post] :posts
  belongs_to[Company] :company
  has_one[Profile?] :profile
  
  # With additional options
  has_many[Post] :posts, dependent: :destroy
  has_many[Comment] :comments, through: :posts
  
  # Validations
  validates[String] :email, presence: true, uniqueness: true
  validates[Integer] :age, numericality: { greater_than: 0 }
  
  # Scopes
  scope[Relation[User]] :active, -> { where(status: "active") }
  scope[Relation[User]] :by_name, ->(name :: String) {
    where("name LIKE ?", "%#{name}%")
  }
end
Code language: HTML, XML (xml)

Comparison of syntaxes:

# Option 1: Postfix 'of' (most Ruby-like)
has_many :posts of Post
validates :email of String, presence: true

# Option 2: Prefix 'of' (generic-like)
has_many of Post, :posts
validates of String, :email, presence: true

# Option 3: Square brackets (actual generics)
has_many[Post] :posts
validates[String] :email, presence: true

# Option 4: 'as:' keyword (traditional keyword argument)
has_many :posts, as: [Post]
validates :email, as: String, presence: true

# Option 5: '<>' Angled brackets (traditional keyword argument)
has_many<[Post]> :posts
validates<String> :email, presence: true
Code language: PHP (php)

I personally prefer Option 2 (prefix ‘of’) because:

  • It reads naturally in English: “has many of Post type”
  • The symbol comes first (Ruby convention)
  • It’s unambiguous and parser-friendly
  • It feels like a natural Ruby extension

Full Rails example with postfix ‘of’:

class User < ApplicationRecord
  has_many :posts of Post, dependent: :destroy
  has_many :comments of Comment, through: :posts
  belongs_to :company of Company
  has_one :profile of Profile?
  
  validates :email of String, presence: true, uniqueness: true
  validates :age of Integer, numericality: { greater_than: 0 }
  validates :status of "active" | "inactive" | "banned", inclusion: { in: %w[active inactive banned] }
  
  scope :active of Relation[User], -> { where(status: "active") }
  scope :by_company of Relation[User], ->(company_id :: Integer) {
    where(company_id: company_id)
  }
  
  before_save :normalize_email
  after_create :send_welcome_email
  
  def normalize_email :: void
    self.email = email.downcase.strip
  end
  
  def full_name :: String
    "#{first_name} #{last_name}"
  end
  
  def recent_posts(limit :: Integer = 10) :: [Post]
    posts.order(created_at: :desc).limit(limit).to_a
  end
end

class PostsController < ApplicationController
  def index :: void
    @posts :: [Post] = Post.includes(:user).order(created_at: :desc)
  end
  
  def show :: void
    @post :: Post = Post.find(params[:id])
  end
  
  def create :: void
    @post :: Post = Post.new(post_params)
    
    if @post.save
      redirect_to @post, notice: "Post created"
    else
      render :new, status: :unprocessable_entity
    end
  end
  
  private
  
  def post_params :: Hash
    params.require(:post).permit(:title, :body, :user_id)
  end
end
Code language: HTML, XML (xml)

How it works under the hood:

The of keyword in method calls would be syntactic sugar that the parser recognizes:

# What you write:
has_many :posts of Post

# What the parser sees:
has_many(:posts, __type__: Post)

# Rails can then use this:
def has_many(name, **options)
  type = options.delete(:__type__)
  
  # Define the association
  define_method(name) do
    # ... normal association logic
  end
  
  # Store type information for runtime validation/documentation
  if type
    association_types[name] = type
    
    # Optional runtime validation in development
    if Rails.env.development?
      define_method(name) do
        result = super()
        validate_type!(result, type)
        result
      end
    end
  end
end
Code language: PHP (php)

This approach:

  • Keeps the symbol first (Ruby convention)
  • Uses familiar of keyword (like we use for generics)
  • Works with all existing parameters
  • Is parser-friendly and unambiguous
  • Reads naturally in English

Complex Example: A Service Object

Let’s build a realistic service object with full type safety:

type TransferResult = 
  { success: true, transaction: Transaction } |
  { success: false, error: String }

class MoneyTransferService
  def initialize(
    @from_account :: Account,
    @to_account :: Account,
    @amount :: BigDecimal
  )
  end
  
  def call :: TransferResult
    return error("Amount must be positive") if @amount <= 0
    return error("Insufficient funds") if @from_account.balance < @amount
    return error("Accounts must be different") if @from_account == @to_account
    
    transaction :: Transaction? = nil
    
    Account.transaction do
      @from_account.withdraw(@amount)
      @to_account.deposit(@amount)
      
      transaction = Transaction.create!(
        from_account: @from_account,
        to_account: @to_account,
        amount: @amount,
        status: "completed"
      )
    end
    
    { success: true, transaction: transaction }
  rescue ActiveRecord::RecordInvalid => e
    error(e.message)
  end
  
  private
  
  def error(message :: String) :: TransferResult
    { success: false, error: message }
  end
end

# Usage
service = MoneyTransferService.new(from_account, to_account, 100.50)
result :: TransferResult = service.call

case result
in { success: true, transaction: tx }
  puts "Transfer successful: #{tx.id}"
in { success: false, error: err }
  puts "Transfer failed: #{err}"
end

Pattern Matching with Types

Ruby 3 introduced pattern matching. TypedRuby makes it type-safe:

type Response of T = 
  { status: "ok", data: T } |
  { status: "error", message: String } |
  { status: "loading" }

def handle_response of T, response :: Response of T :: String
  case response
  in { status: "ok", data: data :: T }
    "Success: #{data}"
  in { status: "error", message: msg :: String }
    "Error: #{msg}"
  in { status: "loading" }
    "Loading..."
  end
end

# Usage
user_response :: Response of User = fetch_user(123)
message = handle_response(user_response)
Code language: PHP (php)

Metaprogramming with Types

Ruby’s metaprogramming is powerful but dangerous. TypedRuby could make it safer:

class Model
  def self.has_typed_attribute of T, name :: Symbol, type :: Class
    define_method(name) :: T do
      instance_variable_get("@#{name}")
    end
    
    define_method("#{name}=") :: void do |value :: T|
      instance_variable_set("@#{name}", value)
    end
  end
end

class User < Model
  has_typed_attribute of String, :name, String
  has_typed_attribute of Integer, :age, Integer
end

user = User.new
user.name = "Ivan"  # OK
user.age = 30       # OK
user.name = 123     # Type error!
Code language: HTML, XML (xml)

Gradual Typing

The beauty of TypedRuby is that it’s optional. You can mix typed and untyped code:

# Completely untyped (classic Ruby)
def process(data)
  data.map { |x| x * 2 }
end

# Partially typed
def process(data :: Array)
  data.map { |x| x * 2 }
end

# Fully typed
def process of T, data :: [T], &block :: (T) -> T :: [T]
  data.map(&block)
end

# The three can coexist in the same codebase
Code language: PHP (php)

Type System and Object Hierarchy

Here’s a crucial question: how do types relate to Ruby’s object system? In Ruby, everything is an object, and every class inherits from Object (or BasicObject). TypedRuby’s type system needs to respect this.

Types ARE classes (mostly)

In TypedRuby, most types would literally be the classes themselves:

# String is both a class and a type
name :: String = "Ivan"
puts String.class  # => Class
puts String.ancestors  # => [String, Comparable, Object, Kernel, BasicObject]

# User is both a class and a type
user :: User = User.new
puts User.class  # => Class
puts User.ancestors  # => [User, ApplicationRecord, ActiveRecord::Base, Object, ...]

This is fundamentally different from TypeScript, where types exist only at compile time. In TypedRuby, types are runtime objects too.

Special type constructors

Some type syntax creates type objects at runtime:

# Array type constructor
posts :: [Post] = []

# This is roughly equivalent to:
posts :: Array[Post] = []

# Which could be implemented as:
class Array
  def self.[](element_type)
    TypedArray.new(element_type)
  end
end

# Hash type constructor
ages :: {String => Integer} = {}

# Roughly:
ages :: Hash[String, Integer] = {}

The Type class hierarchy

TypedRuby would introduce a parallel type hierarchy:

# New base classes for type system
class Type
  # Base class for all types
end

class GenericType < Type
  # For parameterized types like Array[T], Hash[K,V]
  attr_reader :type_params
  
  def initialize(*type_params)
    @type_params = type_params
  end
end

class UnionType < Type
  # For union types like String | Integer
  attr_reader :types
  
  def initialize(*types)
    @types = types
  end
end

class NullableType < Type
  # For nullable types like String?
  attr_reader :inner_type
  
  def initialize(inner_type)
    @inner_type = inner_type
  end
end

# These would be used like:
array_of_posts = GenericType.new(Array, Post)  # [Post]
string_or_int = UnionType.new(String, Integer)  # String | Integer
nullable_user = NullableType.new(User)  # User?
Code language: CSS (css)

Runtime type checking

Because types are objects, you could check them at runtime:

def process(value :: String | Integer)
  case value
  when String
    value.upcase
  when Integer
    value * 2
  end
end

# The type annotation creates a runtime check:
def process(value)
  # Compiler inserts:
  unless value.is_a?(String) || value.is_a?(Integer)
    raise TypeError, "Expected String | Integer, got #{value.class}"
  end
  
  case value
  when String
    value.upcase
  when Integer
    value * 2
  end
end
Code language: PHP (php)

Type as values (reflection)

Types being objects means you can work with them:

def type_info of T, value :: T :: Hash
  {
    value: value,
    type: T,
    class: value.class,
    ancestors: T.ancestors
  }
end

result = type_info("hello")
puts result[:type]  # => String
puts result[:class]  # => String
puts result[:ancestors]  # => [String, Comparable, Object, ...]

# Generic types are objects too:
array_type = Array of String
puts array_type.class  # => GenericType
puts array_type.type_params  # => [String]

Method objects with type information

Ruby’s Method objects could expose type information:

class User
  def greet(name :: String) :: String
    "Hello, #{name}"
  end
end

method = User.instance_method(:greet)
puts method.parameter_types  # => [String]
puts method.return_type  # => String

# This enables runtime validation:
def call_safely(obj, method_name, *args)
  method = obj.method(method_name)
  
  # Check argument types
  method.parameter_types.each_with_index do |type, i|
    unless args[i].is_a?(type)
      raise TypeError, "Argument #{i} must be #{type}"
    end
  end
  
  obj.send(method_name, *args)
end

Duck typing still works

Even with types, Ruby’s duck typing philosophy is preserved:

# You can still use duck typing without types
def quack(duck)
  duck.quack
end

# Or enforce types when you want safety
def quack(duck :: Duck) :: String
  duck.quack
end

# Or use interfaces for structural typing
interface Quackable
  def quack :: String
end

def quack(duck :: Quackable) :: String
  duck.quack  # Works with any object that implements quack
end
Code language: CSS (css)

Type compatibility and inheritance

Types follow Ruby’s inheritance rules:

class Animal
  def speak :: String
    "Some sound"
  end
end

class Dog < Animal
  def speak :: String
    "Woof"
  end
end

# Dog is a subtype of Animal
def make_speak(animal :: Animal) :: String
  animal.speak
end

dog = Dog.new
make_speak(dog)  # OK, Dog < Animal

# Liskov Substitution Principle applies
animals :: [Animal] = [Dog.new, Cat.new, Bird.new]

The as: keyword and runtime behavior

When you write:

has_many :posts, as: [Post]
Code language: CSS (css)

This could be expanded by the Rails framework to:

has_many :posts, type_checker: -> (value) {
  value.is_a?(Array) && value.all? { |item| item.is_a?(Post) }
}
Code language: JavaScript (javascript)

Rails could use this for runtime validation in development mode, giving you immediate feedback if you accidentally assign the wrong type.

Performance considerations

Runtime type checking has overhead. TypedRuby could handle this smartly:

# In development/test: full runtime checking
ENV['RUBY_TYPE_CHECKING'] = 'strict'

# In production: types checked only at compile time
ENV['RUBY_TYPE_CHECKING'] = 'none'

# Or selective checking for critical paths
ENV['RUBY_TYPE_CHECKING'] = 'public_apis'
Code language: PHP (php)

Integration with existing Ruby

Since types are objects, they integrate seamlessly:

# Works with reflection
User.instance_methods.each do |method|
  m = User.instance_method(method)
  if m.respond_to?(:return_type)
    puts "#{method} returns #{m.return_type}"
  end
end

# Works with metaprogramming
class User
  [:name, :email, :age].each do |attr|
    define_method(attr) :: String do
      instance_variable_get("@#{attr}")
    end
  end
end

# Works with monkey patching (for better or worse)
class String
  def original_upcase :: String
    # Type information is preserved
  end
end

This approach makes TypedRuby feel like a natural evolution of Ruby rather than a foreign type system bolted on. Types are just objects, following Ruby’s “everything is an object” philosophy.

TypedRuby should infer types aggressively:

# Inferred from literal
name = "Ivan"  # String inferred

# Inferred from method return
def get_age
  30
end

age = get_age  # Integer inferred

# Inferred from array contents
numbers = [1, 2, 3, 4]  # [Integer] inferred

# Inferred from hash
user = {
  name: "Ivan",
  age: 30,
  active: true
}  # {Symbol => String | Integer | Boolean} inferred

# Explicit typing when inference isn't enough
mixed :: [Integer | String] = [1, "two", 3]
Code language: PHP (php)

Why This Could Work

Unlike Sorbet and RBS, TypedRuby would be:

  1. Native: Types are part of the language syntax, not bolted on
  2. Optional: You choose where to add types
  3. Gradual: Mix typed and untyped code freely
  4. Readable: Syntax feels like Ruby, not like Java
  5. Powerful: Full generics, unions, intersections, pattern matching
  6. Practical: Works with Rails, metaprogramming, blocks, procs

The syntax respects Ruby’s philosophy. It’s minimal, expressive, and doesn’t get in your way. When you want types, they’re there. When you don’t, they’re not.

The Implementation Challenge

Could this be built? Technically, yes. You’d need to:

  1. Extend the Ruby parser to recognize type annotations
  2. Build a type checker that understands Ruby’s semantics
  3. Make it work with Ruby’s dynamic features
  4. Integrate with existing tools (RuboCop, RubyMine, VS Code)
  5. Handle the massive existing Ruby ecosystem

The hard part isn’t the syntax. It’s making the type checker smart enough to handle Ruby’s dynamism while still being useful. Ruby’s metaprogramming, method_missing, dynamic dispatch, these all make static typing hard.

But not impossible. Crystal proved you can have Ruby-like syntax with static types. Sorbet proved you can add types to Ruby code. TypedRuby would combine the best of both: native syntax with gradual typing.

The Dream

Imagine opening a Rails codebase and seeing:

class User < ApplicationRecord
  has_many :posts :: [Post]
  
  def full_name :: String
    "#{first_name} #{last_name}"
  end
end

class PostsController < ApplicationController
  def create :: void
    @post :: Post = Post.new(post_params)
    @post.save!
    redirect_to @post
  end
end

The types are there when you need them, documenting the code and catching bugs. But they don’t dominate. The code still looks like Ruby. It still feels like Ruby.

That’s what TypedRuby could be. Not a separate type system bolted onto Ruby. Not a different language inspired by Ruby. But Ruby itself, evolved to support the type safety modern developers expect.

Would It Succeed?

Honestly? Probably not. Ruby’s community values dynamism and flexibility. Matz has explicitly said he doesn’t want mandatory typing. The ecosystem is built on duck typing and metaprogramming.

But that doesn’t mean it wouldn’t be useful. A significant portion of Ruby developers would adopt optional typing if it felt natural. Rails applications would benefit from type safety in controllers, models, and services. API clients would be more reliable. Refactoring would be safer.

The key is making it optional and making it Ruby. Not Sorbet’s verbose sig blocks. Not RBS’s separate files. Just Ruby, with types when you want them.

Conclusion

TypedRuby is a thought experiment, but it’s a valuable one. It shows what’s possible when you design types into a language from the start, rather than bolting them on later.

Ruby is beautiful. Types don’t have to ruin that beauty. With the right syntax, the right philosophy, and the right implementation, they could enhance it.

Maybe someday we’ll see Ruby 4.0 with native, optional type annotations. Maybe we won’t. But it’s fun to imagine a world where Ruby has the expressiveness we love and the type safety we need.

Until then, we have Sorbet and RBS. They’re not perfect, but they’re what we’ve got. And who knows? Maybe they’ll evolve. Maybe the syntax will improve. Maybe they’ll feel more Ruby-like over time.

Or maybe someone will read this and decide to build TypedRuby for real.

A developer can dream.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.