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.

TypedScript: Imagining CoffeeScript with Types

The content envisions a hypothetical programming language called “TypedScript,” merging the elegance of CoffeeScript with TypeScript’s type safety. It advocates for optional types, clean syntax, aggressive type inference, and elegance in generics, while maintaining CoffeeScript’s aesthetic. The idea remains theoretical, noting practical challenges with adoption in the current ecosystem.

After writing my love letter to CoffeeScript, I couldn’t stop thinking: what if CoffeeScript had embraced types instead of fading away? What if someone had built a typed version that kept all the syntactic elegance while adding the type safety that makes TypeScript so powerful?

Let’s imagine that world. Let’s design what I’ll call “TypedScript” (or maybe CoffeeType? TypedCoffee? We’ll workshop the name). The goal: keep everything that made CoffeeScript beautiful while adding first-class support for types and generics.

The Core Principles

Before we dive into syntax, let’s establish what we’re trying to achieve:

  1. Types should be optional but encouraged. You can write untyped code and gradually add types.
  2. Syntax should stay clean. No angle brackets everywhere, no visual noise.
  3. Type inference should be aggressive. The compiler should figure out as much as possible.
  4. Generics should be elegant. No <T, U, V> mess.
  5. The Ruby/Python aesthetic must be preserved. Significant whitespace, minimal punctuation, readable code.

Basic Type Annotations

Let’s start simple. In TypeScript, you write:

const name: string = "Ivan";
const age: number = 30;
const isActive: boolean = true;
Code language: JavaScript (javascript)

In TypedScript, I’d imagine:

name: String = "Ivan"
age: Number = 30
isActive: Boolean = true
Code language: JavaScript (javascript)

Or with type inference (which should work most of the time):

name = "Ivan"        # inferred as String
age = 30             # inferred as Number
isActive = true      # inferred as Boolean
Code language: PHP (php)

The colon for type annotations feels natural. It’s what TypeScript uses, and it doesn’t clash with CoffeeScript’s existing syntax.

Function Signatures

TypeScript function types can get verbose:

function greet(name: string, age?: number): string {
  return age 
    ? `Hello ${name}, you are ${age}` 
    : `Hello ${name}`;
}

const add = (a: number, b: number): number => a + b;
Code language: JavaScript (javascript)

TypedScript could look like this:

greet = (name: String, age?: Number) -> String
  if age?
    "Hello #{name}, you are #{age}"
  else
    "Hello #{name}"

add = (a: Number, b: Number) -> Number
  a + b
Code language: JavaScript (javascript)

Even cleaner with inference:

greet = (name: String, age?: Number) ->
  if age?
    "Hello #{name}, you are #{age}"
  else
    "Hello #{name}"

add = (a: Number, b: Number) -> a + b
Code language: JavaScript (javascript)

The return type is inferred from the actual return value. This is already how CoffeeScript works (implicit returns), so we just layer types on top.

Interfaces and Type Definitions

TypeScript interfaces are pretty clean, but they still require curly braces:

interface User {
  id: string;
  name: string;
  email: string;
  age?: number;
  roles: string[];
}
Code language: PHP (php)

In TypedScript, we could use indentation:

type User
  id: String
  name: String
  email: String
  age?: Number
  roles: [String]
Code language: JavaScript (javascript)

Or for inline types:

user: {id: String, name: String, email: String}
Code language: CSS (css)

Arrays could use the Ruby-inspired [Type] syntax. Tuples could be [String, Number]. Maps could be {String: User}.

Classes with Types

TypeScript classes are already pretty good, but they’re still verbose:

class UserService {
  private users: User[] = [];
  
  constructor(private apiClient: ApiClient) {}
  
  async getUser(id: string): Promise<User> {
    const response = await this.apiClient.get(`/users/${id}`);
    return response.data;
  }
  
  addUser(user: User): void {
    this.users.push(user);
  }
}
Code language: JavaScript (javascript)

TypedScript version:

class UserService
  users: [User] = []
  
  constructor: (@apiClient: ApiClient) ->
  
  getUser: (id: String) -> Promise<User>
    response = await @apiClient.get "/users/#{id}"
    response.data
  
  addUser: (user: User) -> Void
    @users.push user
Code language: HTML, XML (xml)

The @ syntax for instance variables is preserved, and we just add type annotations where needed. Constructor parameter properties (@apiClient: ApiClient) combine declaration and assignment in one elegant line.

Generics: The Tricky Part

This is where TypeScript gets ugly. Generics in TypeScript look like this:

class Container<T> {
  private value: T;
  
  constructor(value: T) {
    this.value = value;
  }
  
  map<U>(fn: (value: T) => U): Container<U> {
    return new Container(fn(this.value));
  }
}

function identity<T>(value: T): T {
  return value;
}

const result = identity<string>("hello");
Code language: JavaScript (javascript)

The angle brackets are noisy, and they clash with comparison operators. TypedScript needs a different approach. What if we used a more natural syntax inspired by mathematical notation?

class Container of T
  value: T
  
  constructor: (@value: T) ->
  
  map: (fn: (T) -> U) -> Container of U for any U
    new Container fn(@value)

identity = (value: T) -> T for any T
  value

result = identity "hello"  # type inferred

The of keyword introduces type parameters for classes. The for any T suffix introduces type parameters for functions. When calling generic functions, types are inferred automatically in most cases.

For multiple type parameters:

class Pair of K, V
  constructor: (@key: K, @value: V) ->
  
  map: (fn: (V) -> U) -> Pair of K, U for any U
    new Pair @key, fn(@value)

Union Types and Intersections

TypeScript uses | for unions and & for intersections:

type Result = Success | Error;
type Employee = Person & Worker;
Code language: JavaScript (javascript)

TypedScript could keep this, but make it more readable:

type Result = Success | Error

type Employee = Person & Worker

# Or with more complex types
type Response = 
  | {status: "success", data: User}
  | {status: "error", message: String}
Code language: PHP (php)

Advanced Generic Constraints

TypeScript has complex generic constraints:

function findMax<T extends Comparable>(items: T[]): T {
  return items.reduce((max, item) => 
    item.compareTo(max) > 0 ? item : max
  );
}
Code language: JavaScript (javascript)

In TypedScript:

findMax = (items: [T]) -> T for any T extends Comparable
  items.reduce (max, item) ->
    if item.compareTo(max) > 0 then item else max
Code language: JavaScript (javascript)

Practical Example: Building a Generic Repository

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

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

class ApiRepository<T> implements Repository<T> {
  constructor(
    private endpoint: string,
    private client: HttpClient
  ) {}
  
  async findById(id: string): Promise<T | null> {
    try {
      const response = await this.client.get(`${this.endpoint}/${id}`);
      return response.data;
    } catch (error) {
      return null;
    }
  }
  
  async findAll(): Promise<T[]> {
    const response = await this.client.get(this.endpoint);
    return response.data;
  }
  
  async save(entity: T): Promise<T> {
    const response = await this.client.post(this.endpoint, entity);
    return response.data;
  }
  
  async delete(id: string): Promise<void> {
    await this.client.delete(`${this.endpoint}/${id}`);
  }
}
Code language: JavaScript (javascript)

The TypedScript version:

interface Repository of T
  findById: (id: String) -> Promise<T?>
  findAll: () -> Promise<[T]>
  save: (entity: T) -> Promise<T>
  delete: (id: String) -> Promise<Void>

class ApiRepository of T implements Repository of T
  constructor: (@endpoint: String, @client: HttpClient) ->
  
  findById: (id: String) -> Promise<T?>
    try
      response = await @client.get "#{@endpoint}/#{id}"
      response.data
    catch error
      null
  
  findAll: () -> Promise<[T]>
    response = await @client.get @endpoint
    response.data
  
  save: (entity: T) -> Promise<T>
    response = await @client.post @endpoint, entity
    response.data
  
  delete: (id: String) -> Promise<Void>
    await @client.delete "#{@endpoint}/#{id}"

# Usage
userRepo = new ApiRepository of User "users", httpClient
users = await userRepo.findAll()
Code language: HTML, XML (xml)

Look at how clean that is. No angle brackets, no semicolons, no excessive braces. The type information is there, but it doesn’t dominate the code.

Type Guards and Narrowing

TypeScript’s type guards work well:

function isString(value: unknown): value is string {
  return typeof value === "string";
}

if (isString(data)) {
  console.log(data.toUpperCase());
}
Code language: JavaScript (javascript)

TypedScript could use a similar pattern:

isString = (value: Unknown) -> value is String
  typeof value == "string"

if isString data
  console.log data.toUpperCase()
Code language: JavaScript (javascript)

Utility Types

TypeScript has utility types like Partial<T>, Pick<T, K>, Omit<T, K>. These could work in TypedScript with a more natural syntax:

# TypeScript
type PartialUser = Partial<User>;
type UserPreview = Pick<User, "id" | "name">;
type UserWithoutEmail = Omit<User, "email">;

# TypedScript
type PartialUser = Partial of User
type UserPreview = Pick of User, "id" | "name"
type UserWithoutEmail = Omit of User, "email"
Code language: PHP (php)

The Existential Operator with Types

Remember CoffeeScript’s beloved ? operator? It would work beautifully with nullable types:

user: User? = await findUser id  # User | null

name = user?.name ? "Guest"
user?.profile?.update()
callback?()
Code language: PHP (php)

The ? in User? means nullable, just like TypeScript’s User | null or User | undefined.

Real-World Example: A Todo App

Let’s put it all together with a realistic example:

type Todo
  id: String
  title: String
  completed: Boolean
  createdAt: Date

type TodoFilter = "all" | "active" | "completed"

class TodoStore
  todos: [Todo] = []
  filter: TodoFilter = "all"
  
  constructor: (@storage: Storage) ->
    @loadTodos()
  
  loadTodos: () -> Void
    data = @storage.get "todos"
    @todos = if data? then JSON.parse data else []
  
  saveTodos: () -> Void
    @storage.set "todos", JSON.stringify @todos
  
  addTodo: (title: String) -> Todo
    todo: Todo =
      id: generateId()
      title: title
      completed: false
      createdAt: new Date()
    
    @todos.push todo
    @saveTodos()
    todo
  
  toggleTodo: (id: String) -> Boolean
    todo = @todos.find (t) -> t.id == id
    return false unless todo?
    
    todo.completed = !todo.completed
    @saveTodos()
    true
  
  deleteTodo: (id: String) -> Boolean
    index = @todos.findIndex (t) -> t.id == id
    return false if index == -1
    
    @todos.splice index, 1
    @saveTodos()
    true
  
  getFilteredTodos: () -> [Todo]
    switch @filter
      when "active" then @todos.filter (t) -> !t.completed
      when "completed" then @todos.filter (t) -> t.completed
      else @todos

generateId = () -> String
  Math.random().toString(36).substr 2, 9

Compare that to the TypeScript equivalent and tell me it isn’t more elegant. The types are there, providing safety and documentation, but they don’t overwhelm the code. You can still read it naturally.

Why This Matters

TypeScript won because it added types to JavaScript without fundamentally changing the language. That was smart from an adoption standpoint. But it meant keeping JavaScript’s verbose syntax.

If TypedScript had existed, we could have had both: the elegance of CoffeeScript and the safety of TypeScript. We could write code that’s both beautiful and robust.

The tragedy is that this never happened. CoffeeScript’s creator, Jeremy Ashkenas, explicitly rejected adding types. He felt they went against CoffeeScript’s philosophy of simplicity. Meanwhile, TypeScript embraced JavaScript’s syntax for compatibility.

Could This Still Happen?

Technically, someone could build this. The CoffeeScript compiler is open source. TypeScript’s type system is well-documented. A sufficiently motivated team could fork CoffeeScript and add a type system.

But would anyone use it? Probably not. The JavaScript ecosystem has moved on. TypeScript has won. The tooling, the community, the momentum are all there. Starting a new compile-to-JavaScript language in 2025 would be fighting an uphill battle.

Still, it’s fun to imagine. And who knows? Maybe in some parallel universe, TypedScript is the dominant language for web development, and developers there are writing beautiful, type-safe code that makes our TypeScript look verbose and clunky.

A developer can dream.

The Syntax Reference

For anyone curious, here’s a quick reference of what TypedScript syntax could look like:

# Basic types
name: String = "Ivan"
age: Number = 30
active: Boolean = true
data: Any = anything()
nothing: Void = undefined

# Arrays and tuples
numbers: [Number] = [1, 2, 3]
tuple: [String, Number] = ["Ivan", 30]

# Objects
user: {name: String, age: Number} = {name: "Ivan", age: 30}

# Nullable types
optional: String? = null

# Union types
status: "pending" | "active" | "complete" = "pending"
value: String | Number = 42

# Functions
greet: (name: String) -> String = (name) -> "Hello #{name}"

# Generic functions
identity = (value: T) -> T for any T
  value

# Generic classes
class Container of T
  value: T
  constructor: (@value: T) ->

# Interfaces
interface Comparable of T
  compareTo: (other: T) -> Number

# Type aliases
type UserId = String
type Result of T = {ok: true, value: T} | {ok: false, error: String}

# Constraints
sorted = (items: [T]) -> [T] for any T extends Comparable of T
  items.sort (a, b) -> a.compareTo b

Closing Thoughts

Would TypedScript be better than TypeScript? For me, yes. The cleaner syntax, the Ruby-inspired aesthetics, the focus on readability, all while keeping the benefits of static typing. It would be the best of both worlds.

But “better” is subjective. TypeScript’s compatibility with JavaScript is a huge advantage. Its massive ecosystem is irreplaceable. Its tooling is mature and battle-tested.

TypedScript would be a beautiful language that few people use. And maybe that’s okay. Not every good idea wins. Sometimes the practical choice beats the elegant one.

But I still wish I could write my production code in TypedScript. I think it would be a joy.

What do you think? Would you use TypedScript if it existed? What syntax choices would you make differently? Let me know in the comments.

A Love Letter to CoffeeScript and HAML: When Rails Frontend Development Was Pure Joy

The author reflects on the nostalgia of older coding practices, specifically with Ruby on Rails, CoffeeScript, and HAML. They appreciate the simplicity, conciseness, and readability of these technologies compared to modern alternatives like TypeScript. While acknowledging TypeScript’s superiority in type safety, they express a longing for the elegant developer experience of the past.

There’s something bittersweet about looking back at old codebases. Recently, I found myself diving into a Ruby on Rails project from 2012, and I was immediately transported back to an era when frontend development felt different. Better, even. The stack was CoffeeScript, HAML, and Rails’ asset pipeline, and you know what? It was glorious.

I know what you’re thinking. “CoffeeScript? That thing died years ago. TypeScript won. Get over it.” And you’re right. TypeScript did win. It’s everywhere now, and for good reasons. But let me tell you why, after all these years, I still get a little nostalgic pang when I think about writing CoffeeScript, and why part of me still thinks it was the better language.

The Rails Way: Opinionated and Proud

First, let’s set the scene. This was the golden age of Rails, when “convention over configuration” wasn’t just a tagline. It was a philosophy that permeated everything. The asset pipeline handled all your JavaScript and CSS compilation. You’d drop a .coffee file in app/assets/javascripts, write your code, and Rails would handle the rest. No webpack configs, no Babel presets, no decision fatigue about which bundler to use.

Your views lived in HAML files that looked like this:

.user-profile
  .header
    %h1= @user.name
    %p.bio= @user.bio
  
  .actions
    = link_to "Edit Profile", edit_user_path(@user), class: "btn btn-primary"
    = link_to "Delete Account", user_path(@user), method: :delete, 
      data: { confirm: "Are you sure?" }, class: "btn btn-danger"
Code language: JavaScript (javascript)

And your JavaScript looked like this:

class UserProfile
  constructor: (@element) ->
    @setupEventListeners()
  
  setupEventListeners: ->
    @element.find('.btn-danger').on 'click', (e) =>
      @handleDelete(e)
  
  handleDelete: (e) ->
    return unless confirm('Really delete?')
    
    $.ajax
      url: $(e.target).attr('href')
      method: 'DELETE'
      success: => @onDeleteSuccess()
      error: => @onDeleteError()
  
  onDeleteSuccess: ->
    @element.fadeOut()
    Notifications.show 'Account deleted successfully'

$ ->
  $('.user-profile').each ->
    new UserProfile($(this))
Code language: CSS (css)

Look at that. It’s beautiful. It’s concise. It’s expressive. And it just works.

Why HAML Was a Breath of Fresh Air

Let’s talk about HAML first. If you’ve never used it, HAML (HTML Abstraction Markup Language) was a templating language that let you write HTML without all the angle brackets. Instead of this:

<div class="container">
  <div class="row">
    <div class="col-md-6">
      <h1>Welcome</h1>
      <p class="lead">This is my website</p>
    </div>
  </div>
</div>
Code language: HTML, XML (xml)

You wrote this:

.container
  .row
    .col-md-6
      %h1 Welcome
      %p.lead This is my website
Code language: CSS (css)

The difference is striking. HAML forced you to write clean, properly indented markup. You couldn’t forget to close a tag because there were no closing tags. The structure was defined by indentation, Python-style. This meant your templates were always consistently formatted, always readable, and always correctly nested.

HAML also integrated beautifully with Ruby. Want to interpolate a variable? Just use =. Want to add a conditional? Use standard Ruby syntax. The mental model was simple: it’s just Ruby that outputs HTML.

- if current_user.admin?
  .admin-panel
    %h2 Admin Controls
    = render partial: 'admin/controls'
- else
  .user-message
    %p You don't have access to this section.
Code language: PHP (php)

No context switching between template syntax and programming language syntax. It was all Ruby, all the way down.

CoffeeScript: JavaScript for People Who Don’t Like JavaScript

Now, let’s get to the controversial part: CoffeeScript. For those who missed it, CoffeeScript was a language that compiled to JavaScript, created by Jeremy Ashkenas in 2009. It took heavy inspiration from Ruby and Python, offering a cleaner syntax that eliminated much of JavaScript’s syntactic noise.

Here’s the thing people forget: JavaScript in 2011 was terrible. No modules, no classes, no arrow functions, no destructuring, no template strings, no const or let. You had var, function expressions, and pain. So much pain.

CoffeeScript gave us:

Arrow functions (before ES6):

numbers = [1, 2, 3, 4, 5]
doubled = numbers.map (n) -> n * 2

Class syntax (before ES6):

class Animal
  constructor: (@name) ->
  
  speak: ->
    console.log "#{@name} makes a sound"

class Dog extends Animal
  speak: ->
    console.log "#{@name} barks"
Code language: CSS (css)

String interpolation (before ES6):

name = "Ivan"
greeting = "Hello, #{name}!"
Code language: JavaScript (javascript)

Destructuring (before ES6):

{name, age} = user
[first, second, rest...] = numbers

Comprehensions (still not in JavaScript):

adults = (person for person in people when person.age >= 18)

CoffeeScript didn’t just add syntax sugar. It changed how you thought about JavaScript. The code was more expressive, more concise, and more Ruby-like. For Rails developers, it felt like home.

The Magic of the Asset Pipeline

What made this stack truly shine was how it all fit together. The Rails asset pipeline was like magic. Possibly black magic, but magic nonetheless.

You’d organize your code like this:

app/assets/
  javascripts/
    application.coffee
    models/
      user.coffee
      post.coffee
    views/
      users/
        profile.coffee
      posts/
        index.coffee

In your application.coffee, you’d require your dependencies:

#= require jquery
#= require jquery_ujs
#= require_tree ./models
#= require_tree ./views
Code language: PHP (php)

Rails would automatically compile everything, concatenate it in the right order, minify it for production, and serve it with cache-busting fingerprints. You didn’t think about build tools. You just wrote code.

The same applied to stylesheets. Drop a .scss file in app/assets/stylesheets, and it would be compiled and served. Want to use a gem that includes assets? Add it to your Gemfile, and its assets would automatically be available. No CDN links, no manual script tags.

Was it perfect? No. Was it sometimes confusing when assets weren’t loading in the order you expected? Yes. But the developer experience was smooth. You could go from idea to implementation incredibly quickly.

Why CoffeeScript Still Feels Better Than TypeScript

Okay, here’s where I’m going to lose some of you. TypeScript is objectively the right choice for modern JavaScript development. It has type safety, incredible tooling, massive community support, and it’s actively developed by Microsoft. CoffeeScript is essentially dead, with minimal updates and a dwindling community.

And yet… CoffeeScript still feels better to write.

Let me explain. TypeScript added types to JavaScript, which is fantastic. But it kept JavaScript’s verbose syntax. You still have curly braces everywhere, you still need semicolons (or don’t, which becomes its own debate), you still have visual noise.

Compare these:

TypeScript:

interface User {
  name: string;
  email: string;
  age: number;
}

class UserService {
  constructor(private apiClient: ApiClient) {}
  
  async getUser(id: string): Promise<User> {
    const response = await this.apiClient.get(`/users/${id}`);
    return response.data;
  }
  
  filterAdults(users: User[]): User[] {
    return users.filter((user) => user.age >= 18);
  }
}
Code language: JavaScript (javascript)

CoffeeScript:

class UserService
  constructor: (@apiClient) ->
  
  getUser: (id) ->
    response = await @apiClient.get "/users/#{id}"
    response.data
  
  filterAdults: (users) ->
    users.filter (user) -> user.age >= 18
Code language: CSS (css)

The CoffeeScript version is cleaner. There’s less visual noise, less ceremony. The @ symbol for instance properties is brilliant. It’s immediately obvious what’s a property and what’s a local variable. The implicit returns mean you’re not constantly writing return statements. The significant whitespace enforces good formatting.

Yes, TypeScript gives you type safety. That’s huge. But CoffeeScript gives you readability. And in my experience, readable code is maintainable code. I can glance at CoffeeScript and immediately understand what it’s doing. TypeScript requires more parsing, more mental overhead.

The list comprehensions in CoffeeScript are particularly beautiful:

# CoffeeScript
evenSquares = (n * n for n in numbers when n % 2 == 0)

# TypeScript
const evenSquares = numbers
  .filter((n) => n % 2 === 0)
  .map((n) => n * n);
Code language: PHP (php)

Both work, but the CoffeeScript version reads like English: “n squared for each n in numbers when n is even.” It’s declarative and expressive.

The Existential Operator: A Love Story

One of CoffeeScript’s best features was the existential operator (?). It was like optional chaining before optional chaining existed, but more powerful:

# Safe property access
name = user?.profile?.name

# Default values
speed = options?.speed ? 75

# Function existence check
callback?()

# Existence assignment
value ?= "default"
Code language: PHP (php)

That last one, ?=, was particularly great. It means “assign if the variable is null or undefined.” It’s cleaner than value = value || "default" and more correct (because it doesn’t overwrite falsy-but-valid values like 0 or "").

TypeScript eventually got optional chaining (?.) and nullish coalescing (??), which is great. But it took years, and CoffeeScript had it from the start.

What We Lost

When the JavaScript community moved from CoffeeScript to ES6 and then TypeScript, we gained a lot. Type safety, better tooling, standardization. But we also lost something.

We lost the joy of writing concise code. We lost the elegance of Ruby-inspired syntax. We lost the community that valued readability and expressiveness over completeness and type safety.

Modern JavaScript development often feels like you’re fighting the tools. Configuring TypeScript, setting up ESLint, configuring Prettier, choosing between competing libraries, debugging sourcemaps, dealing with module resolution issues. It’s powerful, but it’s exhausting.

With CoffeeScript and Rails, you just wrote code. The decisions were made for you. The tools were integrated. The conventions were clear. It was opinionated, and that was a feature, not a bug.

The Verdict

Would I start a new project with CoffeeScript and HAML today? Probably not. The ecosystem has moved on. TypeScript has won, and for most use cases, it’s the right choice. React and Vue have replaced server-rendered templates. The world has changed.

But do I miss it? Absolutely.

I miss the simplicity. I miss the elegance. I miss being able to write beautiful, concise code without worrying about types and interfaces and generics. I miss HAML’s clean markup and the way it forced you to write good HTML. I miss the Rails asset pipeline just working without configuration.

Most of all, I miss the developer experience of that era. We were moving fast, building things quickly, and having fun doing it. The code was readable, the stack was coherent, and everything felt like it fit together.

Maybe that’s just nostalgia talking. Maybe I’m romanticizing the past and forgetting the pain points. But when I look at that old CoffeeScript code, I don’t see technical debt. I see craft. I see code that was written with care, that values clarity over cleverness, that respects the reader’s time.

And honestly? I still think CoffeeScript’s syntax is better. TypeScript is more powerful, more practical, and more maintainable at scale. But CoffeeScript is more beautiful.

Sometimes, that matters too.


What are your thoughts? Did you work with CoffeeScript and HAML back in the day? Do you miss them, or are you glad we’ve moved on? Let me know in the comments or reach out on Twitter.

The Hidden Economics of “Free” AI Tools: Why the SaaS Premium Still Matters

This post discusses the hidden costs of DIY solutions in SaaS, emphasizing the benefits of established SaaS tools over “free” AI-driven alternatives. It highlights issues like time tax, knowledge debt, reliability, support challenges, security risks, and scaling problems. Ultimately, it advocates for a balanced approach that leverages AI to enhance, rather than replace, reliable SaaS infrastructure.

This is Part 2 of my series on the evolution of SaaS. If you haven’t read Part 1: The SaaS Model Isn’t Dead, it’s Evolving Beyond the Hype of “Vibe Coding”, start there for the full context. In this post, I’m diving deeper into the hidden costs that most builders don’t see until it’s too late.

In my last post, I argued that SaaS isn’t dead, it’s just evolving beyond the surface-level appeal of vibe coding. Today, I want to dig deeper into something most builders don’t realize until it’s too late: the hidden costs of “free” AI-powered alternatives.

Because here’s the uncomfortable truth: when you replace a $99/month SaaS tool with a Frankenstein stack of AI prompts, no-code platforms, and API glue, you’re not saving money. You’re just moving the costs somewhere else, usually to places you can’t see until they bite you.

Let’s talk about what really happens when you choose the “cheaper” path.

The Time Tax: When Free Becomes Expensive

Picture this: you’ve built your “MVP” in a weekend. It’s glorious. ChatGPT wrote half the code, Zapier connects your Airtable to your Stripe account, and a Make.com scenario handles email notifications. Total monthly cost? Maybe $20 in API fees.

You’re feeling like a genius.

Then Monday morning hits. A customer reports an error. The Zapier workflow failed silently. You spend two hours digging through logs (when you can find them) only to discover that Airtable changed their API rate limits, and now your automation hits them during peak hours.

You patch it with a delay. Problem solved.

Until Wednesday, when three more edge cases emerge. The Python script you copied from ChatGPT doesn’t handle timezone conversions properly. Your payment flow breaks for international customers. The no-code platform you’re using doesn’t support the webhook format you need.

Each fix takes 30 minutes to 3 hours.

By Friday, you’ve spent more time maintaining your “free” stack than you would have spent just using Stripe Billing and ConvertKit.

This is the time tax. And unlike your SaaS subscription, you can’t expense it or write it off. It’s just gone, stolen from building features, talking to customers, or actually running your business.

The question isn’t whether your DIY solution costs less. It’s whether your time is worth $3/hour.

The Knowledge Debt: Building on Borrowed Understanding

Here’s a scenario that plays out constantly in the AI-first era:

A developer prompts Claude to build a payment integration. The AI generates beautiful code, type-safe, well-structured, handles edge cases. The developer copies it, tests it once, and ships it.

It works perfectly for two months.

Then Stripe deprecates an API endpoint. Or a customer discovers a refund edge case. Or the business wants to add subscription tiers.

Now what?

The developer stares at 200 lines of code they didn’t write and don’t fully understand. They can prompt the AI again, but they don’t know which parts are safe to modify. They don’t know why certain patterns were used. They don’t know what will break.

This is knowledge debt, the accumulated cost of using code you haven’t internalized.

Compare this to using a proper SaaS tool like Stripe Billing or Chargebee. You don’t understand every line of their code either, but you don’t need to. They handle the complexity. They migrate your data when APIs change. They’ve already solved the edge cases.

When you build with barely-understood AI-generated code, you get the worst of both worlds: you’re responsible for maintenance without having the knowledge to maintain it effectively.

This isn’t a knock on AI tools. It’s a reality check about technical debt in disguise.

The Reliability Gap: When “Good Enough” Isn’t

Let’s zoom out and talk about production-grade systems.

When you use Slack, it has 99.99% uptime. That’s not luck, it’s the result of on-call engineers, redundant infrastructure, automated failovers, and millions of dollars in operational excellence.

When you stitch together your own “Slack alternative” using Discord webhooks, Airtable, and a Telegram bot, what’s your uptime?

You don’t even know, because you’re not measuring it.

And here’s the thing: your customers notice.

They notice when notifications arrive 3 hours late because your Zapier task got queued during peak hours. They notice when your checkout flow breaks because you hit your free-tier API limits. They notice when that one Python script running on Replit randomly stops working.

Reliability isn’t a feature you can bolt on later. It’s the foundation everything else is built on.

This is why companies still pay for Datadog instead of writing their own monitoring. Why they use PagerDuty instead of email alerts. Why they choose AWS over running servers in their garage.

Not because they can’t build these things themselves, but because reliability at scale requires obsessive attention to details that don’t show up in MVP prototypes.

Your vibe-coded solution might work 95% of the time. But that missing 5% is where trust dies and customers churn.

The Support Nightmare: Who Do You Call?

Imagine this email from a customer:

“Hi, I tried to upgrade my account but got an error. Can you help?”

Simple enough, right?

Except your “upgrade flow” involves:

  • A Stripe Checkout session (managed by Stripe)
  • A webhook that triggers Make.com (managed by Make.com)
  • Which updates Airtable (managed by Airtable)
  • Which triggers a Zapier workflow (managed by Zapier)
  • Which sends data to your custom API (deployed on Railway)
  • Which updates your database (hosted on PlanetScale)

One of these broke. Which one? You have no idea.

You start debugging:

  • Check Stripe logs. Payment succeeded.
  • Check Make.com execution logs. Ran successfully.
  • Check Airtable. Record updated.
  • Check Zapier. Task queued but not processed yet.

Ah. Zapier’s free tier queues tasks during high-traffic periods. The upgrade won’t process for another 15 minutes.

You explain this to the customer. They’re confused and frustrated. So are you.

Now imagine that same scenario with a proper SaaS tool like Memberstack or MemberSpace. The customer emails them. They check their logs, identify the issue, and fix it. Done.

When you own the entire stack, you own all the problems too. And most founders don’t realize how much time “customer support for your custom infrastructure” actually takes until they’re drowning in it.

The Security Illusion: Compliance Costs You Can’t See

Pop quiz: Is your AI-generated authentication system GDPR compliant?

Does it properly hash passwords? Does it prevent timing attacks? Does it implement proper session management? Does it handle token refresh securely? Does it log security events appropriately?

If you’re not sure, you’ve got a problem.

Because when you use Auth0, Clerk, or AWS Cognito, these questions are answered for you. They have security teams, penetration testers, and compliance certifications. They handle GDPR, CCPA, SOC2, and whatever acronym-soup regulation applies to your industry.

When you roll your own auth with AI-generated code, you own all of that responsibility.

And here’s what most people don’t realize: security incidents are expensive. Not just in terms of fines and legal costs, but in reputation damage and customer trust.

One breach can kill a startup. And saying “but ChatGPT wrote the code” isn’t a legal defense.

The same logic applies to payment handling, data storage, and API security. Every shortcut you take multiplies your risk surface.

SaaS tools don’t just sell features, they sell peace of mind. They carry the liability so you don’t have to.

The Scale Wall: When Growth Breaks Everything

Your vibe-coded MVP works perfectly for your first 10 customers. Then you get featured on Product Hunt.

Suddenly you have 500 new signups in 24 hours.

Your Airtable base hits record limits. Your free-tier API quotas are maxed out. Your Make.com scenarios are queuing tasks for hours. Your Railway instance keeps crashing because you didn’t configure autoscaling. Your webhook endpoints are timing out because they weren’t designed for concurrent requests.

Everything is on fire.

This is the scale wall, the moment when your clever shortcuts stop being clever and start being catastrophic.

Real SaaS products are built to scale. They handle traffic spikes. They have redundancy. They auto-scale infrastructure. They cache aggressively. They optimize database queries. They monitor performance.

Your vibe-coded stack probably does none of these things.

And here’s the brutal part: scaling isn’t something you can retrofit easily. It’s architectural. You can’t just “add more Zapier workflows” your way out of it.

At this point, you face a choice: either rebuild everything properly (which takes months and risks losing customers during the transition), or artificially limit your growth to stay within the constraints of your fragile infrastructure.

Neither option is appealing.

The Integration Trap: When Your Stack Doesn’t Play Nice

One of the biggest promises of the AI-powered, no-code revolution is that everything integrates with everything.

Except it doesn’t. Not really.

Sure, Zapier connects to 5,000+ apps. But those integrations are surface-level. You get basic CRUD operations, not deep functionality.

Want to implement complex business logic? Want custom error handling? Want to batch process data efficiently? Want real-time updates instead of 15-minute polling?

Suddenly you’re writing custom code anyway, except now you’re writing it in the weird constraints of whatever platform you’ve chosen, rather than in a proper application where you have full control.

The irony is thick: you chose no-code to avoid complexity, but you ended up with a different kind of complexity, one that’s harder to debug and impossible to version control properly.

Meanwhile, a well-designed SaaS tool either handles your use case natively or provides a proper API for custom integration. You’re not fighting the platform; you’re using it as intended.

The Real Cost Comparison

Let’s do some actual math.

Vibe-coded stack:

  • Zapier Pro: $20/month
  • Make.com: $15/month
  • Airtable Pro: $20/month
  • Railway: $10/month
  • Various API costs: $15/month
  • Total: $80/month

Your time:

  • Initial setup: 20 hours
  • Weekly maintenance: 3 hours
  • Monthly debugging: 5 hours
  • Customer support for stack issues: 2 hours
  • Monthly time cost: ~20 hours

If your time is worth even $50/hour (a modest rate for a technical founder), that’s $1,000/month in opportunity cost.

Total real cost: $1,080/month.

Proper SaaS stack:

  • Stripe Billing: Included with processing fees
  • Memberstack: $25/month
  • ConvertKit: $29/month
  • Vercel: $20/month
  • Total: $74/month + processing fees

Your time:

  • Initial setup: 4 hours
  • Weekly maintenance: 0.5 hours
  • Monthly debugging: 1 hour
  • Customer support for stack issues: 0 hours (vendor handles it)
  • Monthly time cost: ~3 hours

At $50/hour, that’s $150/month in opportunity cost.

Total real cost: $224/month.

The “more expensive” SaaS stack actually costs 80% less when you account for time.

And we haven’t even factored in:

  • The revenue lost from downtime
  • The customers lost from poor reliability
  • The scaling issues you’ll hit later
  • The security risks you’re accepting
  • The knowledge debt you’re accumulating

When DIY Makes Sense (And When It Doesn’t)

Look, I’m not saying you should never build anything custom. There are absolutely times when DIY is the right choice.

Build custom when:

  • The functionality is core to your competitive advantage
  • No existing tool solves your exact problem
  • You have the expertise to maintain it long-term
  • You’re building something genuinely novel
  • You have the team capacity to own it forever

Use SaaS when:

  • The functionality is commodity (auth, payments, email, etc.)
  • Reliability and uptime are critical
  • You want to focus on your core product
  • You’re a small team with limited time
  • You need compliance and security guarantees
  • You value your time more than monthly fees

The pattern is simple: build what makes you unique, buy what makes you functional.

The AI-Assisted Middle Ground

Here’s where it gets interesting: AI doesn’t just enable vibe coding. It also enables smarter SaaS integration.

You can use Claude or ChatGPT to:

  • Generate integration code for SaaS APIs faster
  • Debug webhook issues more efficiently
  • Build wrapper libraries around vendor SDKs
  • Create custom workflows on top of stable platforms

This is the sweet spot: using AI to accelerate your work with reliable tools, rather than using AI to replace reliable tools entirely.

Think of it like this: AI is an incredible co-pilot. But you still need the plane to have wings.

The Evolution Continues

My argument isn’t that AI tools are bad or that vibe coding is wrong. It’s that we need to be honest about the tradeoffs.

The next generation of successful products won’t be built by people who reject AI, and they won’t be built by people who reject SaaS.

They’ll be built by people who understand when to use each.

People who can vibe-code a prototype in a weekend, then have the discipline to replace it with proper infrastructure before it scales. People who use AI to augment their capabilities, not replace their judgment.

The future isn’t “AI vs. SaaS.” It’s “AI-enhanced SaaS.”

Tools that are easier to integrate because AI helps you. APIs that are easier to understand because AI explains them. Systems that are easier to maintain because AI helps you debug.

But beneath all that AI magic, there’s still reliable infrastructure, accountable teams, and boring old uptime guarantees.

Because at the end of the day, customers don’t care about your tech stack. They care that your product works when they need it.

Build for the Long Game

If you’re building something that matters, something you want customers to depend on, something you want to grow into a real business, you need to think beyond the MVP phase.

You need to think about what happens when you hit 100 users. Then 1,000. Then 10,000.

Will your clever weekend hack still work? Or will you be spending all your time keeping the lights on instead of building new features?

The most successful founders I know aren’t the ones who move fastest. They’re the ones who move sustainably, who build foundations that can support growth without collapsing.

They use AI to move faster. They use SaaS to stay reliable. They understand that both are tools, not religions.

Final Thoughts: Respect the Craft

There’s a romance to the idea of building everything yourself. Of being the 10x developer who needs nothing but an AI assistant and pure willpower.

But romance doesn’t ship products. Discipline does.

The best software is invisible. It just works. And making something “just work”, consistently, reliably, at scale, is harder than anyone admits.

So use AI. Vibe-code your prototypes. Move fast and experiment.

But when it’s time to ship, when it’s time to serve real customers, when it’s time to build something that lasts, respect the craft.

Choose boring, reliable infrastructure. Pay for the SaaS tools that solve solved problems. Invest in quality over cleverness.

Because the goal isn’t to build the most innovative tech stack.

The goal is to build something customers love and trust.

And trust, as it turns out, is built on the boring stuff. The stuff that works when you’re not looking. The stuff that scales without breaking. The stuff someone else maintains at 3 AM so you don’t have to.

That’s what SaaS really sells.

And that’s why it’s not dead, it’s just getting started.


What’s your experience balancing custom-built solutions with SaaS tools? Have you hit the scale wall or the reliability gap? Share your stories in the comments. I’d love to hear what you’ve learned.

If you found this useful, follow me for more posts on building sustainable products in the age of AI, where we embrace new tools without forgetting old wisdom.

Rails Templating Showdown: Slim vs ERB vs Haml vs Phlex – Which One Should You Use?

This guide compares Ruby on Rails templating engines: ERB, Slim, Haml, and Phlex. It highlights each engine’s pros and cons, focusing on aspects like performance, readability, and learning curve. Recommendations are made based on project type, emphasizing the importance of choosing the right engine for optimal efficiency and maintainability.

If you’ve been working with Ruby on Rails for any length of time, you’ve probably encountered the age-old question: which templating engine should I use? With ERB as the default, Slim and Haml as popular alternatives, and Phlex as the new kid on the block, the choice can feel overwhelming.

In this comprehensive guide, I’ll break down each option, compare their strengths and weaknesses, and help you make an informed decision for your Rails projects.

Understanding the Landscape

Before diving into specifics, let’s understand what we’re comparing. Template engines are tools that help you generate HTML dynamically by embedding Ruby code within markup. Each engine has a different philosophy about how this should be done.

ERB (Embedded Ruby)

What is it? ERB is Rails’ default templating engine. It embeds Ruby code directly into HTML using special tags.

Syntax Example

<div class="user-profile">
  <h1><%= @user.name %></h1>
  <% if @user.admin? %>
    <span class="badge">Admin</span>
  <% end %>
  <ul class="posts">
    <% @user.posts.each do |post| %>
      <li><%= link_to post.title, post_path(post) %></li>
    <% end %>
  </ul>
</div>
Code language: HTML, XML (xml)

Pros

Zero Learning Curve: If you know HTML and Ruby, you already know ERB. There’s no new syntax to learn, making it perfect for beginners and mixed teams.

Universal Support: Every Rails developer knows ERB. Every gem, tutorial, and Stack Overflow answer uses ERB. This ubiquity is valuable.

No Setup Required: It works out of the box with every Rails installation. No gems to add, no configuration needed.

Familiar to Other Ecosystems: The concept of embedding code in angle brackets exists in PHP, ASP, JSP, and many other frameworks. Developers coming from other backgrounds will feel at home.

Cons

Verbose: Writing closing tags for everything gets tedious. Your files become longer than they need to be.

Easy to Create Messy Code: Because ERB doesn’t enforce structure, it’s easy to mix business logic with presentation logic, leading to hard-to-maintain views.

Repetitive: You’ll find yourself typing the same patterns over and over. The lack of shortcuts makes ERB feel inefficient once you’ve experienced alternatives.

When to Use ERB

ERB is ideal when you’re starting a new project with junior developers, working with a team that values convention over optimization, or building simple CRUD applications where template complexity is minimal. It’s also the safe choice for open-source projects where maximum accessibility matters.

Slim

What is it? Slim is a lightweight templating engine focused on reducing syntax to its bare essentials. Its motto is “what’s left when you take the fat off ERB.”

Syntax Example

.user-profile
  h1 = @user.name
  - if @user.admin?
    span.badge Admin
  ul.posts
    - @user.posts.each do |post|
      li = link_to post.title, post_path(post)
Code language: JavaScript (javascript)

Pros

Dramatically Less Code: Slim templates are typically 30-40% shorter than their ERB equivalents. This means faster writing and easier scanning.

Clean and Readable: Once you learn the syntax, Slim templates are remarkably easy to read. The indentation-based structure naturally enforces good organization.

Fast Performance: Slim compiles to Ruby code that’s often faster than ERB, though the difference is negligible in most applications.

Enforces Good Structure: The indentation requirement prevents messy, unstructured code. You can’t create a Slim template that doesn’t follow proper nesting.

Cons

Learning Curve: Team members need to learn new syntax. The first week will involve frequent reference to documentation.

Indentation Sensitivity: Like Python, Slim uses significant whitespace. A misplaced space or tab can break your template, which can be frustrating when debugging.

Less Common: Fewer developers know Slim compared to ERB. Hiring and onboarding may take slightly longer.

Limited Ecosystem Examples: While most gems work fine with Slim, documentation and examples are usually in ERB, requiring mental translation.

When to Use Slim

Slim shines in applications with complex views where you want to maximize readability and minimize boilerplate. It’s perfect for teams that value developer experience and are willing to invest a small amount of time upfront to learn the syntax. If you find yourself frustrated by ERB’s verbosity, Slim is your answer.

Haml

What is it? Haml (HTML Abstraction Markup Language) was one of the first popular alternatives to ERB. It uses indentation to represent HTML structure and eliminates closing tags.

Syntax Example

.user-profile
  %h1= @user.name
  - if @user.admin?
    %span.badge Admin
  %ul.posts
    - @user.posts.each do |post|
      %li= link_to post.title, post_path(post)
Code language: JavaScript (javascript)

Pros

Mature and Stable: Haml has been around since 2006. It’s battle-tested and reliable with excellent documentation.

Cleaner Than ERB: Like Slim, Haml eliminates closing tags and reduces boilerplate significantly.

Good Ecosystem Support: Many gems and libraries explicitly support Haml, and you’ll find plenty of examples and resources online.

Enforces Structure: The indentation requirement keeps your code organized and prevents deeply nested chaos.

Cons

Slower Than Slim: Haml is noticeably slower than Slim in benchmarks, though for most applications this won’t matter.

More Verbose Than Slim: The % prefix for tags makes Haml slightly more verbose than Slim’s minimalist approach.

Indentation Sensitivity: Like Slim, whitespace matters. Mixing tabs and spaces will cause problems.

Feeling Dated: While still widely used, Haml hasn’t evolved as quickly as Slim. It lacks some of the refinements that make Slim feel more modern.

When to Use Haml

Choose Haml if you want an alternative to ERB but prefer a more established option with extensive community support. It’s a safe middle ground between ERB’s verbosity and Slim’s minimalism. Haml is particularly good if you’re maintaining a legacy codebase that already uses it.

Phlex

What is it? Phlex represents a radical departure from traditional templating. Instead of mixing Ruby with HTML-like syntax, Phlex uses pure Ruby classes to build views. It’s component-oriented and type-safe.

Syntax Example

class UserProfile < Phlex::HTML
  def initialize(user)
    @user = user
  end

  def template
    div(class: "user-profile") do
      h1 { @user.name }
      span(class: "badge") { "Admin" } if @user.admin?
      ul(class: "posts") do
        @user.posts.each do |post|
          li { a(href: post_path(post)) { post.title } }
        end
      end
    end
  end
end
Code language: HTML, XML (xml)

Pros

Pure Ruby: No context switching between Ruby and template syntax. Your entire view is just Ruby code, which means better IDE support, easier refactoring, and familiar debugging.

Component Architecture: Phlex encourages building reusable components, leading to better code organization and DRY principles.

Type Safety: Because it’s pure Ruby, you can use tools like Sorbet or RBS for type checking your views.

Excellent Performance: Phlex is extremely fast, often outperforming other template engines significantly.

Testable: Components are just Ruby classes, making them easy to unit test without rendering overhead.

No Markup Parsing: Since there’s no template syntax to parse, there’s one less layer of complexity in your stack.

Cons

Paradigm Shift: Phlex requires a completely different way of thinking about views. This isn’t just new syntax—it’s a new architecture.

Verbose for Simple Views: For basic templates, Phlex can feel like overkill. Writing div { h1 { "Hello" } } instead of <div><h1>Hello</h1></div> doesn’t feel like progress for simple cases.

Limited Ecosystem: Phlex is new. There are fewer examples, fewer ready-made components, and a smaller community.

No Designer-Friendly Workflow: Because Phlex is pure Ruby, front-end developers or designers who aren’t comfortable with Ruby will struggle to contribute to views.

Steep Learning Curve: Understanding how to structure Phlex components well takes time and experience.

When to Use Phlex

Phlex is ideal for component-heavy applications where you want maximum reusability and testability. It’s perfect for design systems, UI libraries, or applications with complex, interactive interfaces. Choose Phlex if your team is comfortable with Ruby and values type safety and performance. It’s also excellent for API-driven applications where you’re building JSON responses rather than full HTML pages.

The Comparison Matrix

Let me break down how these engines stack up across key criteria:

Performance

Winner: Phlex Phlex is the fastest, followed closely by Slim. Haml is slower, and ERB sits in the middle. However, for most applications, template rendering isn’t the bottleneck—database queries and business logic are.

Readability

Winner: Slim Once learned, Slim offers the best balance of conciseness and clarity. ERB is readable but verbose. Haml is good but slightly cluttered with % symbols. Phlex requires Ruby fluency to read comfortably.

Learning Curve

Winner: ERB ERB has virtually no learning curve. Slim and Haml require a day or two to feel comfortable. Phlex requires rethinking your entire approach to views.

Ecosystem Support

Winner: ERB ERB is universal. Everything supports it. Slim and Haml have good support but sometimes require translation. Phlex is still building its ecosystem.

Maintainability

Winner: Phlex/Slim Phlex’s component architecture and Slim’s enforced structure both lead to highly maintainable codebases. ERB’s flexibility can become a maintainability liability. Haml sits in the middle.

Team Onboarding

Winner: ERB Any Rails developer can contribute to ERB templates immediately. The alternatives require training time.

My Recommendations

After years of using all these engines in production, here’s what I recommend:

For New Projects with Small Teams

Use Slim. You’ll write less code, maintain cleaner views, and the learning investment pays off quickly. The performance gains are nice, but the real benefit is how much easier it is to scan and understand Slim templates.

For Large Teams or Open Source

Stick with ERB. The universal knowledge and zero onboarding friction outweigh the benefits of alternatives. Don’t underestimate the value of every Rails developer being able to contribute immediately.

For Component-Heavy Applications

Choose Phlex. If you’re building a complex UI with lots of reusable components, Phlex’s architecture will save you time in the long run. The learning curve is worth it for applications where component composition is central.

For Existing Projects

Don’t Rewrite. If your project already uses Haml or Slim, keep using it. If it uses ERB and you’re happy with it, don’t change. The cost of conversion rarely justifies the benefits.

For Learning

Start with ERB, then try Slim. Master Rails with its default templating engine first. Once you’re comfortable, experiment with Slim on a side project. After you understand the tradeoffs, you’ll be equipped to make informed decisions.

Mixing Engines

Here’s something many developers don’t realize: you can use multiple templating engines in the same Rails application. You might use ERB for most views but Phlex for a complex component or Slim for your admin interface.

This flexibility means you’re not locked into one choice forever. Start with ERB and migrate specific areas to alternatives as needs arise.

The Future

The Rails templating landscape is evolving. Phlex represents a new wave of thinking about views as components rather than templates. Meanwhile, tools like ViewComponent bridge the gap between traditional templates and component architecture.

My prediction? We’ll see more hybrid approaches where simple CRUD views use traditional templates while complex UIs leverage component-based systems like Phlex.

Conclusion

There’s no universally correct answer to “which templating engine should I use?” The right choice depends on your team, your project, and your priorities.

  • ERB for maximum compatibility and zero friction
  • Slim for optimal developer experience and clean code
  • Haml for a mature alternative with good ecosystem support
  • Phlex for component-driven architecture and maximum performance

My personal preference? I use Slim for most projects. The productivity boost is real, the syntax becomes second nature quickly, and I appreciate how it naturally encourages better code organization. But I’ve shipped successful applications with all four engines, and I wouldn’t hesitate to use any of them given the right context.

What matters most isn’t which engine you choose, but that you use it consistently and well. A well-structured ERB codebase beats a messy Slim project every time.

What’s your experience with Rails templating engines? Have you tried alternatives to ERB? I’d love to hear your thoughts in the comments below.


Want to dive deeper into Rails development? Subscribe to my newsletter for weekly tips and insights on building better Rails applications.

Why AI Startups Should Choose Rails Over Python

AI startups often fail due to challenges in supporting layers and product development rather than model quality. Rails offers a fast and structured path for founders to build scalable applications, integrating seamlessly with AI services. While Python excels in research, Rails is favored for production, facilitating swift feature implementation and reliable infrastructure.

TLDR;

Most AI startups fail because they cannot ship a product
not because the model is not good enough
Rails gives founders the fastest path from idea to revenue
Python is still essential for research but Rails wins when the goal is to build a business.

The Real Challenge in AI Today

People love talking about models
benchmarks
training runs
tokens
context windows
all the shiny parts

But none of this is why AI startups fail

Startups fail because the supporting layers around the model are too slow to build:

  • Onboarding systems
  • Billing and subscription logic
  • Admin dashboards
  • User management
  • Customer support tools
  • Background processing
  • Iterating on new features
  • Fixing bugs
  • Maintaining stability

The model is never the bottleneck
The product is
This is exactly where Rails becomes your unfair advantage

Why Rails Gives AI Startups Real Speed

Rails focuses on shipping
It gives you a complete system on day one
The framework removes most of the decisions that slow down small teams
Instead of assembling ten libraries you just start building

The result is simple
A solo founder or a tiny team can move with the speed of a full engineering department,
Everything feels predictable,
Everything fits together,
Everything works the moment you touch it.

Python gives you freedom,
Rails gives you momentum,
Momentum is what gets a startup off the ground.

Rails and AI Work Together Better Than Most People Think

There is a common myth that AI means Python
Only partially true
Python is the best language for training and experimenting
But the moment you are building a feature for real users you need a framework that is designed for production

Rails integrates easily with every useful AI service:

  • OpenAI
  • Anthropic
  • Perplexity
  • Groq
  • Nvidia
  • Mistral
  • Any vector database
  • Any embedding store

Rails makes AI orchestration simple
Sidekiq handles background jobs
Active Job gives structure
Streaming responses work naturally
You can build an AI agent inside a Rails app without hacking your way through a forest of scripts

The truth is that you do not need Python to run AI in production
You only need Python if you plan to become a research lab
Most founders never will

Rails Forces You to Think in Systems

AI projects built in Python often turn into a stack of disconnected scripts
One script imports the next
Another script cleans up data
Another runs an embedding job
This continues until even the founder has no idea what the system actually does

Rails solves this by design
It introduces structure: Controllers, Services, Models, Jobs, Events
It forces you to think in terms of a real application rather than a set of experiments

This shift is a superpower for founders
AI is moving from research to production
Production demands structure
Rails gives you structure without slowing you down

Why Solo Founders Thrive With Rails

When you are building alone you cannot afford chaos
You need to create a system that your future self can maintain
Rails gives you everything that normally requires a team

You can add authentication in a few minutes
You can build a clean admin interface in a single afternoon
You can create background workflows without debugging weird timeouts
You can send emails without configuring a jungle of libraries
You can go from idea to working feature in the same day

This is what every founder actually needs
Not experiments
Not scripts
A product that feels real
A product you can ship this week
A product you can charge money for

Rails gives you that reality


Real Companies That Prove Rails Is Still a Winning Choice

Rails is not a nostalgia framework
It is the foundation behind some of the biggest products ever created

GitHub started with Rails
Shopify grew massive on Rails
Airbnb used Rails in its early explosive phase
Hulu
Zendesk
Basecamp
Dribbble
All Rails

Modern AI driven companies also use Rails in production
Shopify uses it to power AI commerce features
Intercom uses it to support AI customer support workflows
GitHub still relies on Rails for internal systems even as it builds Copilot
Stripe uses Rails for internal tools because the Python stack is too slow for building complex dashboards

These are not lightweight toy projects
These are serious companies that trust Rails because it just works

What You Gain When You Choose Rails

The biggest advantage is development speed
Not theoretical speed
Real speed
The kind that lets you finish an entire feature before dinner

Second
You escape the burden of endless decisions
The framework already gives you the right defaults
You do not waste time choosing from twenty possible libraries for each part of the system

Third
Rails was built for production
This matters more than people admit
You get caching, background jobs, templates, email, tests, routing, security, all included, all consistent, all reliable

Fourth
Rails fits perfectly with modern AI infrastructure: Vector stores, embedding workflows, agent orchestration, streaming responses. It works out of the box with almost no friction

This combination is rare
Rails gives you speed and stability at the same time
Most frameworks give you one or the other
Rails gives you both

Where Rails Is Not the Best Too

There are honest limits. If you are training models working with massive research datasets, writing CUDA kernels or doing deep ML research Python remains the right choice.

If you come from Python the Rails conventions can feel magical or strange at first You might wonder why things happen automatically. But the conventions are there to help you move faster

Hiring can be more challenging in certain regions
There are fewer Rails developers
but the ones you find are usually very strong
and often much more experienced in building actual products

You might also deal with some bias. A few people still assume Rails is old
These people are usually too young to remember that Rails built half the modern internet

The One Thing Every Founder Must Understand

The future of AI will not be won by better models. Models are quickly becoming a commodity. The real victory will go to the teams that build the best products around the models:

  • Onboarding
  • UX
  • speed
  • reliability
  • iteration
  • support
  • support tools
  • customer insights
  • monetization
  • all the invisible details that turn a clever idea into a real business

Rails is the best framework in the world for building these supporting layers fast. This is why it remains one of the most effective choices for early stage AI startups

Use Python for research
Use Rails to build the business
This is the strategy that gives you the highest chance of reaching customers
and more importantly
the highest chance of winning

The AI-Native Rails App: What a 2025 Architecture Looks Like

Introduction

For the first time in decades of building products, I’m seeing a shift that feels bigger than mobile or cloud.
AI-native architecture isn’t “AI added into the app” it’s the app shaped around AI from day one.

In this new world:

  • Rails is no longer the main intelligence layer
  • Rails becomes the orchestrator
  • The AI systems do the thinking
  • The Rails app enforces structure, rules, and grounding

And honestly? Rails has never felt more relevant than in 2025.

In this post, I’m breaking down exactly what an AI-native Rails architecture looks like today, why it matters, and how to build it with real, founder-level examples from practical product work.

1. AI-Native Rails vs. AI-Powered Rails

Many apps today use AI like this:

User enters text → you send it to OpenAI → you show the result

That’s not AI-native.
That’s “LLM glued onto a CRUD app.”

AI-native means:

  • Your DB supports vector search
  • Your UI expects streaming
  • Your workflows assume LLM latency
  • Your logic expects probabilistic answers
  • Your system orchestrates multi-step reasoning
  • Your workers coordinate long-running tasks
  • Your app is built around contextual knowledge, not just forms
A 2025 AI-native Rails stack looks like this:
  • Rails 7/8
  • Hotwire (Turbo + Stimulus)
  • Sidekiq or Solid Queue
  • Postgres with PgVector
  • OpenAI, Anthropic, or Groq APIs
  • Langchain.rb for tooling and structure
  • ActionCable for token-by-token streaming
  • Comprehensive logging and observability

This is the difference between a toy and a business.

2. Rails as the AI Orchestrator

AI-native architecture can be summarized in one sentence:

Rails handles the constraints, AI handles the uncertainty.

Rails does:

  • validation
  • data retrieval
  • vector search
  • chain orchestration
  • rule enforcement
  • tool routing
  • background workflows
  • streaming to UI
  • cost tracking

The AI does:

  • reasoning
  • summarization
  • problem-solving
  • planning
  • generating drafts
  • interpreting ambiguous input

In an AI-native system:

Rails is the conductor. The AI is the orchestra.

3. Real Example: AI Customer Support for Ecommerce

Most ecommerce AI support systems are fragile:

  • they hallucinate answers
  • they guess policies
  • they misquote data
  • they forget context

An AI-native Rails solution works very differently.

Step 1: User submits a question

A Turbo Frame or Turbo Stream posts to:

POST /support_queries

Rails saves:

  • user
  • question
  • metadata

Step 2: Rails triggers two workers

(1) EmbeddingJob
– Create embeddings via OpenAI
– Save vector into PgVector column

(2) AnswerGenerationJob
– Perform similarity search on:

  1. product catalog
  2. order history
  3. return policies
  4. previous chats
  5. FAQ rules
    – Pass retrieved context into LLM
    – Validate JSON output
    – Store reasoning steps (optional)

Step 3: Stream the answer

ActionCable + Turbo Streams push tokens as they arrive.

broadcast_append_to "support_chat_#{id}"Code language: JavaScript (javascript)

The user sees the answer appear live, like a human typing.

Why this architecture matters for founders

  • Accuracy skyrockets with grounding
  • Cost drops because vector search reduces tokens
  • Hallucinations fall due to enforced structure
  • You can audit the exact context used
  • UX improves dramatically with streaming
  • Support cost decreases 50–70% in real deployments

This isn’t AI chat inside Rails.

This is AI replacing Tier-1 support, with Rails as the backbone of the system.

4. Example: Founder Tools for Strategy, Decks, and Roadmaps

Imagine building a platform where founders upload:

  • pitch decks
  • PDFs
  • investor emails
  • spreadsheets
  • competitor research
  • user feedback
  • product specs

Old SaaS approach:
You let GPT speculate.

AI-native approach:
You let GPT reason using real company documents.

How it works

Step 1: Upload documents

Rails converts PDFs → text → chunks → embeddings.

Step 2: Store a knowledge graph

PgVector stores embeddings.
Metadata connects insights.

Step 3: Rails defines structure

Rails enforces:

  • schemas
  • output formats
  • business rules
  • agent constraints
  • allowed tools
  • validation filters

Step 4: Langchain.rb orchestrates the reasoning

But Rails sets the boundaries.
The AI stays inside the rails (pun intended).

Step 5: Turbo Streams show ongoing progress

Founders see:

  • “Extracting insights…”
  • “Analyzing competitors…”
  • “Summarizing risks…”
  • “Drafting roadmap…”

This builds trust and increases perceived value.

5. Technical Breakdown: What You Need to Build

Below is the exact architecture I recommend.

1. Rails + Hotwire Frontend

Turbo Streams = real-time AI experience.

  • Streams for token output
  • Frames for async updates
  • No need for React overhead

2. PgVector for AI Memory

Install extension + migration.

Example schema:

create_table :documents do |t|
  t.text :content
  t.vector :embedding, limit: 1536
  t.timestamps
end
Code language: JavaScript (javascript)

Vectors become queryable like any column.

3. Sidekiq or Solid Queue for AI Orchestration

LLM calls must never run in controllers.

Recommended jobs:

  • EmbeddingJob
  • ChunkingJob
  • RetrievalJob
  • LLMQueryJob
  • GroundedAnswerJob
  • AgentWorkflowJob

4. AI Services Layer

Lightweight Ruby service objects.

Embedding example:

class Embeddings::Create
  def call(text)
    OpenAI::Client.new.embeddings(
      model: "text-embedding-3-large",
      input: text
    )["data"][0]["embedding"]
  end
endCode language: CSS (css)

5. Retrieval Layer

Document.order(Arel.sql("embedding <-> '#{embedding}' ASC")).limit(5)Code language: HTML, XML (xml)

Grounding prevents hallucinations and cuts costs.

6. Streaming with ActionCable

Token streaming UX looks magical and retains users.

7. Observability Layer (Non-Optional)

Track:

  • prompts
  • model
  • cost
  • context chunks
  • errors
  • retries
  • latency

AI systems break differently than traditional code.
Logging is survival.


6. How To Start Building This (Exact Steps)

Here’s the fast-track setup:

Step 1: Enable PgVector

Install and migrate.

Step 2: Build an Embedding Service

Clean, testable, pure Ruby.

Step 3: Add Worker Pipeline

One worker per step.
No logic inside controllers.

Step 4: Create Retrieval Functions

Structured context retrieval before every LLM call.

Step 5: Build Token Streaming

Turbo Streams + ActionCable.

Step 6: Add Prompt Templates & A/B Testing

Prompt engineering is your new growth lever.

7. Why Rails Wins the AI Era

AI products are:

  • async
  • slow
  • streaming-heavy
  • stateful
  • data-driven
  • orchestration heavy
  • context dependent

Rails was made for this style of work.

Python builds models.
Rails builds businesses.

We are entering an era where:

Rails becomes the best framework in the world for shipping AI-powered products fast.

And I’m betting on it again like I did 15 years ago but with even more conviction.

Closing Thoughts

Your product is no longer a set of forms.
In the AI era, your product is:

  • memory
  • context
  • retrieval
  • reasoning
  • workflows
  • streaming interfaces
  • orchestration

Rails is the perfect orchestrator for all of it.

The Two Hardest Problems in Software Development: Naming Things & Cache Invalidation

The post discusses the common struggles developers face with naming conventions and cache invalidation, humorously portraying them as universal challenges irrespective of experience or technology. It emphasizes that while AI and Ruby tools assist in these areas, the inherent complexities require human reasoning. Ultimately, these issues highlight the uniquely human aspects of software development.

A joke. A reality. A shared developer trauma now with AI and Ruby flavor.

Every industry has its running jokes. Lawyers have billable hours. Doctors have unreadable handwriting. Accountants battle ancient spreadsheets.

Developers?
We have two immortal bosses at the end of every level:

1. Naming things
2. Cache invalidation

These aren’t just memes.
They’re universal rites of passage, the kind of problems that don’t care about your stack, your years of experience, or your productivity plans for the day.

And as modern as our tools get, these two battles remain undefeated.


1. Naming Things: A Daily Existential Crisis

You would think building multi-region distributed systems or designing production-grade blockchains is harder than deciding how to name a method.

But no.
Naming is where confidence goes to die.

There’s something profoundly humbling about:

  • Typing data, deleting it,
  • typing result, deleting it,
  • typing payload, staring at it, deleting it again,
  • then settling on something like final_sanitized_output and hoping future-you understands the intention.

Naming = Thinking

A name isn’t just a word.
It’s a miniature problem statement.

A good name answers:

  • What is this?
  • Why does it exist?
  • What is it supposed to do?
  • Is it allowed to change?
  • Should anyone else touch it?

A bad name answers none of that but invites everyone on your team to ping you on Slack at 22:00 asking “hey what does temp2 mean?”

Not being a native English speaker? Welcome to the Hard Mode DLC

For those of us whose brain grew up in Croatian or Slovenian, naming in English is a special kind of fun.

You might know exactly what you want to say in your own language, but English gives you:

  • three misleading synonyms,
  • one obscure word nobody uses,
  • and a fourth option that feels right but actually means “a small marine snail.”

Sometimes you choose a word that sounds good.
Later a native speaker reviews it and politely suggests:
“Did you mean something else?”

Yes.
I meant something else.
I just didn’t know the word.

And every developer from Europe, Asia, or South America collectively understands this pain.


2. Cache Invalidation: “Why is my app showing data from last week?”

Caching seems easy on paper:

Save expensive data → Serve it fast → Refresh it when needed.

Unfortunately, “when needed” is where the nightmares begin.

Cache invalidation is unpredictable because it lives at the intersection of:

  • time
  • state
  • concurrency
  • user behavior
  • background jobs
  • frameworks
  • deployment pipelines
  • the moon cycle
  • your personal sins

You delete the key.
The stale value still appears.
You restart the server.
It refuses to die.

You clear your browser cache.
Nothing changes.

Then you realize:
Ah. It’s Cloudflare.
Or Redis.
Or Rails fragment caching.
Or your CDN.
Or… you know what, it doesn’t matter anymore. You’re in too deep.


3. “But can’t AI fix it?”

Not… really.
Not even close.

Large language models can:

  • produce suggestions,
  • generate name variations,
  • summarize logic,
  • help brainstorm alternative wording.

But they don’t actually understand your domain, your codebase, your long-term architecture, or your internal conventions.

Their naming suggestions are based on statistical patterns in text not on:

  • business logic
  • your future plans
  • subtle behavior differences
  • what will still make sense in six months
  • what your teammates expect
  • what your product owner actually wants

AI might suggest a “good enough” name that reads nicely,
but it won’t know that half your system expects a value to mean something slightly different, or that “order” conflicts with another concept named “Order” in a separate context.

And with cache invalidation?
AI can generate explanations but it can’t magically deduce your system’s lifetime, caching layers, or deployment quirks.
It cannot predict race conditions or magically detect all the hidden layers where stale data might be hiding like a gremlin.

AI helps you write code faster.
But it does not remove the need for deep understanding, consistent thinking, and human judgment.


4. How Ruby Tries to Save Us From Ourselves

Ruby and Ruby on Rails in particular has spent two decades trying to soften the blow of both naming and system complexity.

Not by solving the problem completely, but by making the playground safer.

Ruby’s Naming Conventions = Guardrails for Humans

Ruby tries to push developers toward sanity through:

  • clear method naming idioms (predicate?, bang!, _before_type_cast)
  • consistent pluralization rules
  • convention-driven file names and classes
  • ActiveRecord naming patterns like Userusers, Personpeople

Rails developers don’t choose how to name directories, controllers, helpers, or models.
Rails chooses for you.

This is not a limitation it’s freedom.

The fewer decisions you have to make about structure, the more mental energy you save for meaningful names, not framework boilerplate.

Ruby Reduces the “Naming Chaos Budget”

Thanks to convention-over-configuration:

  • folders behave predictably
  • classes match filenames
  • methods follow community patterns
  • model names map directly to database entities
  • you don’t spend half your day wondering where things live

Ruby doesn’t fix naming.
It simply reduces the size of the battlefield.

Ruby Also Softens Caching Pain… a Bit

Rails gives you:

  • fragment caching
  • Russian doll caching
  • cache keys with versioning (cache_key_with_version)
  • automatic key invalidation via ActiveRecord touch
  • expiry helpers (expires_in, expires_at)
  • per-request cache stores
  • caching tied to view rendering

Rails tries to help you avoid stale data by structuring caching around data freshness instead of low-level keys.

But even then…

The moment you have:

  • multiple services,
  • background jobs,
  • external APIs,
  • or anything distributed…

Ruby smiles kindly and whispers:
“You’re on your own now, my friend.”


Why These Problems Never Disappear

Because they aren’t technical problems.
They are human constraints on top of technical systems.

  • Naming requires clarity of thinking.
  • Caching requires clarity of system behavior.
  • AI can assist, but it cannot replace understanding.
  • Ruby can guide you, but it cannot decide for you.

The tools help.
The frameworks help.
AI helps.

But at the end of the day, the two hardest problems remain hard because they require the one thing no machine or framework can automate:

your own reasoning.


Final Thoughts

Some days you ship incredible features.
Some days you wage war against a variable name.
Some days you fight stale cached data for three hours before realizing the problem was a CDN rule from 2018.

And every developer, everywhere on Earth, understands these moments.

Naming things and cache invalidation aren’t just computer science problems.
They’re reminders of why software development is deeply human full of ambiguity, creativity, and shared misery.

But honestly?

That’s what keeps it fun.

PgVector for AI Memory in Production Applications

PgVector is a PostgreSQL extension designed to enhance memory in AI applications by storing and querying vector embeddings. This enables large language models (LLMs) to retrieve accurate information, personalize responses, and reduce hallucinations. PgVector’s efficient indexing and simple integration provide a reliable foundation for AI memory, making it essential for developers building AI products.

Introduction

As AI moves from experimentation into real products, one challenge appears over and over again: memory. Large language models (LLMs) are incredibly capable, but they can’t store long-term knowledge about users or applications out-of-the-box. They respond only to what they see in the prompt and once the prompt ends, the memory disappears.

This is where vector databases and especially PgVector step in.

PgVector is a PostgreSQL extension that adds first-class vector similarity search to a database you probably already use. With its rise in popularity especially in production AI systems it has become one of the simplest and most powerful ways to build AI memory.

This post is a deep dive into PgVector, how it works, why it matters, and how to implement it properly for real LLM-powered features.


What Is PgVector?

PgVector is an open-source PostgreSQL extension that adds support for storing and querying vector data types. These vectors represent high‑dimensional numerical representations embeddings generated from AI models.

Examples:

  • A sentence embedding from OpenAI might be a vector of 1,536 floating‑point numbers.
  • An image embedding from CLIP might be 512 or 768 numbers.
  • A user profile embedding might be custom‑generated from your own model.

PgVector lets you:

  • Store these vectors
  • Index them efficiently
  • Query them using similarity search (cosine, inner product, Euclidean)

This enables your LLM applications to:

  • Retrieve knowledge
  • Add persistent memory
  • Reduce hallucinations
  • Add personalization or context
  • Build recommendation engines

And all of that without adding a new complex piece of infrastructure because it works inside PostgreSQL.


How PgVector Works

At its core, PgVector introduces a new column type:

vector(1536)

You decide the dimension based on your embedding model. PgVector then stores the vector and allows efficient search using:

  • Cosine distance (1 – cosine similarity)
  • Inner product
  • Euclidean (L2)

Similarity Search

Similarity search means: given an embedding vector, find the stored vectors that are closest to it.

This is crucial for LLM memory.

Instead of asking the model to “remember” everything or hallucinating answers, we retrieve the most relevant facts, messages, documents, or prior interactions before the LLM generates a response.

Indexing

PgVector supports two main index types:

  • IVFFlat (fast, approximate search – great for production)
  • HNSW (hierarchical – even faster for large datasets)

Example index creation:

CREATE INDEX ON memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

Using PgVector With Embeddings

Step 1: Generate Embeddings

You generate embeddings from any model:

  • OpenAI Embeddings
  • Azure
  • HuggingFace models
  • Cohere
  • Llama.cpp
  • Custom fine‑tuned transformers

Example (OpenAI):

POST https://api.openai.com/v1/embeddingsCode language: JavaScript (javascript)
{"model": "text-embedding-3-large","input": "Hello world"}Code language: JSON / JSON with Comments (json)

This returns a vector like:

[0.0213, -0.0045, 0.9983, ...]Code language: JSON / JSON with Comments (json)

Step 2: Store Embeddings in PostgreSQL

A table for memory might look like:

CREATE TABLE memory (id SERIAL PRIMARY KEY, content TEXT NOT NULL, embedding vector(1536), metadata JSONB, created_at TIMESTAMP DEFAULT NOW());Code language: PHP (php)

Insert data:

INSERT INTO memory (content, embedding) VALUES ('User likes Japanese and Mexican cuisine', '[0.234, -0.998, ...]');Code language: JavaScript (javascript)

Step 3: Query Similar Records

SELECT content, (embedding <=> '[0.23, -0.99, ...]') AS distance FROM memory ORDER BY embedding <=> '[0.23, -0.99, ...]' LIMIT 5;Code language: PHP (php)

This returns the top 5 most relevant memory snippets and those will be added to the prompt context.


Storing Values for AI Memory

What You Store Depends on Your Application

You can store:

  • Chat history messages
  • User preferences
  • Past actions
  • Product details
  • Documents
  • Errors and solutions
  • Knowledge base articles
  • User profiles

Recommended Structure

A flexible structure:

{
  "type": "preference",
  "user_id": 42,
  "source": "chat",
  "topic": "food",
  "tags": ["japanese", "mexican"]
}Code language: JSON / JSON with Comments (json)

This gives you the ability to:

  • Filter search by metadata
  • Separate memories per user
  • Restrict context retrieval by type

Temporal Decay (Optional)

You can implement ranking adjustments:

  • Recent memories score higher
  • Irrelevant memories score lower
  • Outdated memories auto‑expire

This creates human‑like memory behavior.


Reducing Hallucinations With PgVector

LLMs hallucinate when they lack context.

Most hallucinations are caused by missing information, not by model failure.

PgVector solves this by ensuring the model always receives:

  • The top relevant facts
  • Accurate summaries
  • Verified data

Retrieval-Augmented Generation (RAG)

You transform a prompt from:

Without RAG:

“Tell me about Ivan’s garden in Canada.”

With RAG:

“Tell me about Ivan’s garden in Canada. Here are relevant facts from memory: The garden is 20m². – Located in Canada. – Used for planting vegetables.”

The model no longer needs to guess.

Why This Reduces Hallucination

Because the model:

  • Is not guessing user data
  • Only completes based on retrieved facts
  • Gets guardrails through data-driven knowledge
  • Becomes deterministic

PgVector acts like a mental database for the AI.


Adding PgVector to a Production App

Here’s the blueprint.

1. Install the extension

CREATE EXTENSION IF NOT EXISTS vector;

2. Create your memory table

Use the structure that fits your domain.

3. Create an index

CREATE INDEX memory_embedding_idx
ON memory USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

4. Create a Memory Service

Your backend service should:

  • Accept content
  • Generate embeddings
  • Store them with metadata

And another service should:

  • Take an embedding
  • Query top-N matches
  • Return the context

5. Use RAG in your LLM pipeline

Every LLM call becomes:

  1. Embed the question
  2. Retrieve relevant memory
  3. Construct prompt
  4. Call the LLM
  5. Store new memories (if needed)

6. Add Guardrails

Production memory systems need:

  • Permission control (per user)
  • Expiration rules
  • Filters (e.g., exclude private data)
  • Maximum memory size

7. Add Analytics

Track:

  • Hit rate (how often memory is used)
  • Relevance quality
  • Retrieval time

Common Pitfalls and How to Avoid Them

❌ Storing whole conversation transcripts

This leads to massive token usage. Instead, store summaries.

❌ Retrieving too many memories

Keep context small. 3–10 items is ideal.

❌ Wrong distance metric

Most embedding models work best with cosine similarity.

❌ Using RAG without metadata filters

You don’t want another user’s memory leaking into the context.

❌ No indexing

Without IVFFlat/HNSW, retrieval becomes extremely slow.


When Should You Use PgVector?

Use it if you:

  • Already use PostgreSQL
  • Want simple deployment
  • Want memory that scales to millions of rows
  • Need reliability and ACID guarantees
  • Want to avoid new infrastructure like Pinecone, Weaviate, or Milvus

Do NOT use it if you:

  • Need billion‑scale vector search
  • Require ultra‑low latency for real‑time gaming or streaming
  • Need dynamic sharding across many nodes

But for 95% of AI apps, PgVector is perfect.


Conclusion

PgVector is the bridge between normal production data and the emerging world of AI memory. For developers building real applications chatbots, agents, assistants, search engines, personalization engines it offers the most convenient and stable foundation.

You get:

  • Easy deployment
  • Reliable storage
  • Fast similarity search
  • A complete memory layer for AI

This turns your LLM features from fragile experiments into solid, predictable production systems.

If you’re building AI products in 2025, PgVector isn’t “nice to have” it’s a core architectural component.

Saving Money With Embeddings in AI Memory Systems: Why Ruby on Rails is Perfect for LangChain

In the exploration of AI memory systems and embeddings, the author highlights the hidden costs in AI development, emphasizing token management. Leveraging Ruby on Rails streamlines the integration of LangChain for efficient memory handling. Adopting strategies like summarization and selective retrieval significantly reduces expenses, while maintaining readability and scalability in system design.

Over the last few months of rebuilding my Rails muscle memory, I’ve been diving deep into AI memory systems and experimenting with embeddings. One of the biggest lessons I’ve learned is that the cost of building AI isn’t just in the model it’s in how you use it. Tokens, storage, retrieval these are the hidden levers that determine whether your AI stack remains elegant or becomes a runaway expense.

And here’s the good news: with Ruby on Rails, managing these complexities becomes remarkably simple. Rails has always been about turning complicated things into something intuitive and maintainable and when you pair it with LangChain, it feels like magic.


Understanding the Cost of Embeddings

Most people think that running large language models is expensive because of the model itself. That’s only partially true. In practice, the real costs come from:

  • Storing too much raw content: Every extra paragraph you embed costs more in tokens, both for the embedding itself and for later retrieval.
  • Embedding long texts instead of summaries: LLMs don’t need the full novel they often just need the distilled version. Summaries are shorter, cheaper, and surprisingly effective.
  • Retrieving too many memories: Pulling 50 memories for a simple question can cost more than the model call itself. Smart retrieval strategies can drastically cut costs.
  • Feeding oversized prompts into the model: Every extra token in your prompt adds up. Cleaner prompts = cheaper calls.

I’ve seen projects where embedding every word of a document seemed “safe,” only to realize months later that the token bills were astronomical. That’s when I started thinking in terms of summary-first embeddings.


How Ruby on Rails Makes It Easy

Rails is my natural playground for building systems that scale reliably without over-engineering. Why does Rails pair so well with AI memory systems and LangChain? Several reasons:

Migrations Are Elegant
With Rails, adding a vector column with PgVector feels like any other migration. You can define your tables, indexes, and limits in one concise block:

 class AddMemoriesTable < ActiveRecord::Migration[7.1] 
   def change 
     enable_extension "vector" 
     create_table :memories do |t| 
       t.text :content, null: false 
       t.vector :embedding, limit: 1536 
       t.jsonb :metadata 
       t.timestamps 
     end 
   end 
end 


There’s no need for complicated schema scripts. Rails handles the boring but essential details for you.

ActiveRecord Makes Embedding Storage a Breeze
Storing embeddings in Rails is almost poetic. With a simple model, you can create a memory with content, an embedding, and metadata in a single call:

Memory.create!(
  content: "User prefers Japanese and Mexican cuisine.", 
  embedding: embedding_vector,
  metadata: { type: :preference, user_id: 42 }
)Code language: CSS (css)

And yes, you can query those memories by similarity in a single, readable line:

Memory.order(Arel.sql("embedding <=> '[#{query_embedding.join(',')}]'")).limit(5)Code language: HTML, XML (xml)

Rails keeps your code readable and maintainable while you handle sophisticated vector queries.

LangChain Integration is Natural
LangChain is all about chaining LLM calls, memory storage, and retrieval. In Rails, you already have everything you need: models, services, and job queues. You can plug LangChain into your Rails services to:


Saving Money with Smart Embeddings

Here’s the approach I’ve refined over multiple projects:

  1. Summarize Before You Embed
    Instead of embedding full documents, feed the model a summary. A 50-word summary costs fewer tokens but preserves the semantic meaning needed for retrieval.
  2. Limit Memory Retrieval
    You rarely need more than 5–10 memories for a single model call. More often than not, extra memories just bloat your prompt and inflate costs.
  3. Use Metadata Wisely
    Store small, structured metadata alongside your embeddings to filter memories before similarity search. For example, filter by user_id or type instead of pulling all records into the model.
  4. Cache Strategically
    Don’t re-embed unchanged content. Use Rails validations, background jobs, and services to embed only when necessary.

When you combine these strategies, the savings are significant. In some projects, embedding costs dropped by over 70% without losing retrieval accuracy.


Why I Stick With Rails and PostgreSQL

There are many ways to build AI memory systems. You could go with specialized databases, microservices, or cloud vector stores. But here’s what keeps me on Rails and Postgres:

  • Reliability: Postgres is mature, stable, and production-ready. PgVector adds vector search without changing the foundation.
  • Scalability: Rails scales surprisingly well when you keep queries efficient and leverage background jobs.
  • Developer Happiness: Rails lets me iterate quickly. I can prototype, test, and deploy AI memory features without feeling like I’m juggling ten different systems.
  • Future-Proofing: Rails projects can last years without a complete rewrite. AI infrastructure is still evolving having a stable base matters.

Closing Thoughts

AI memory doesn’t have to be complicated or expensive. By thinking carefully about embeddings, summaries, retrieval, and token usage and by leveraging Rails with LangChain you can build memory systems that are elegant, fast, and cost-effective.

For me, Rails is more than a framework. It’s a philosophy: build systems that scale naturally, make code readable, and keep complexity under control. Add PgVector and LangChain to that mix, and suddenly AI memory feels like something you can build without compromise.

In the world of AI, where complexity grows faster than budgets, that kind of simplicity is priceless.