๐Ÿš€ Building Type-Safe APIs with Camille: A Rails-to-TypeScript Bridge

How to eliminate API contract mismatches and generate TypeScript clients automatically from your Rails API

๐Ÿ”ฅ The Problem: API Contract Chaos

If you’ve ever worked on a project with a Rails backend and a TypeScript frontend, you’ve probably experienced this scenario:

  1. Backend developer changes an API response format
  2. Frontend breaks silently in production
  3. Hours of debugging to track down the mismatch
  4. Manual updates to TypeScript types that drift out of sync

Sound familiar? This is the classic API contract problem that plagues full-stack development.

๐Ÿ›ก๏ธ Enter Camille: Your API Contract Guardian

Camille is a gem created by Basecamp that solves this problem elegantly by:

  • Defining API contracts once in Ruby
  • Generating TypeScript types automatically
  • Validating responses at runtime to ensure contracts are honored
  • Creating typed API clients for your frontend

Let’s explore how we implemented Camille in a real Rails API project.

๐Ÿ—๏ธ Our Implementation: A User Management API

We built a simple Rails API-only application with user management functionality. Here’s how Camille transformed our development workflow:

1๏ธโƒฃ Defining the Type System

First, we defined our core data types in config/camille/types/user.rb:

using Camille::Syntax

class Camille::Types::User < Camille::Type
  include Camille::Types

  alias_of(
    id: String,
    name: String,
    biography: String,
    created_at: String,
    updated_at: String
  )
end

This single definition becomes the source of truth for what a User looks like across your entire stack.

2๏ธโƒฃ Creating API Schemas

Next, we defined our API endpoints in config/camille/schemas/users.rb:

using Camille::Syntax

class Camille::Schemas::Users < Camille::Schema
  include Camille::Types

  # GET /user - Get a random user
  get :show do
    response(User)
  end

  # POST /user - Create a new user
  post :create do
    params(
      name: String,
      biography: String
    )
    response(User | { error: String })
  end
end

Notice the union type User | { error: String } – Camille supports sophisticated type definitions including unions, making your contracts precise and expressive.

3๏ธโƒฃ Implementing the Rails Controller

Our controller implementation focuses on returning data that matches the Camille contracts:

class UsersController < ApplicationController
  def show
    @user = User.random_user

    if @user
      render json: UserSerializer.serialize(@user), status: :ok
    else
      render json: { error: "No users found" }, status: :not_found
    end
  end

  def create
    @user = User.new(user_params)

    return validation_error(@user) unless @user.valid?
    return random_failure if simulate_failure?

    if @user.save
      render json: UserSerializer.serialize(@user), status: :ok
    else
      validation_error(@user)
    end
  end

  private

  def user_params
    params.permit(:name, :biography)
  end
end

4๏ธโƒฃ Creating a Camille-Compatible Serializer

The key to making Camille work is ensuring your serializer returns exactly the hash structure defined in your types:

class UserSerializer
  # Serializes a user object to match Camille::Types::User format
  def self.serialize(user)
    {
      id: user.id,
      name: user.name,
      biography: user.biography,
      created_at: user.created_at.iso8601,
      updated_at: user.updated_at.iso8601
    }
  end
end

๐Ÿ’ก Pro tip: Notice how we convert timestamps to ISO8601 strings to match our String type definition. Camille is strict about types!

5๏ธโƒฃ Runtime Validation Magic

Here’s where Camille shines. When we return data that doesn’t match our contract, Camille catches it immediately:

# This would throw a Camille::Controller::TypeError
render json: @user  # ActiveRecord object doesn't match hash contract

# This works perfectly
render json: UserSerializer.serialize(@user)  # Hash matches contract

The error messages are incredibly helpful:

Camille::Controller::TypeError (
Type check failed for response.
Expected hash, got #<User id: "58601411-4f94-4fd2-a852-7a4ecfb96ce2"...>.
)

๐ŸŽฏ Frontend Benefits: Auto-Generated TypeScript

While we focused on the Rails side, Camille’s real power shows on the frontend. It generates TypeScript types like:

// Auto-generated from your Ruby definitions
export interface User {
  id: string;
  name: string;
  biography: string;
  created_at: string;
  updated_at: string;
}

export type CreateUserResponse = User | { error: string };

๐Ÿงช Testing with Camille

We created comprehensive tests to ensure our serializers work correctly:

class UserSerializerTest < ActiveSupport::TestCase
  test "serialize returns correct hash structure" do
    result = UserSerializer.serialize(@user)

    assert_instance_of Hash, result
    assert_equal 5, result.keys.length

    # Check all required keys match Camille type
    assert_includes result.keys, :id
    assert_includes result.keys, :name
    assert_includes result.keys, :biography
    assert_includes result.keys, :created_at
    assert_includes result.keys, :updated_at
  end

  test "serialize returns timestamps as ISO8601 strings" do
    result = UserSerializer.serialize(@user)

    iso8601_regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|\.\d{3}Z)$/
    assert_match iso8601_regex, result[:created_at]
    assert_match iso8601_regex, result[:updated_at]
  end
end

โš™๏ธ Configuration and Setup

Setting up Camille is straightforward:

  1. Add to Gemfile:
gem "camille"
  1. Configure in config/camille.rb:
Camille.configure do |config|
  config.ts_header = <<~EOF
    // DO NOT EDIT! This file is automatically generated.
    import request from './request'
  EOF
end
  1. Generate TypeScript:
rails camille:generate

๐Ÿ’Ž Best Practices We Learned

๐ŸŽจ 1. Dedicated Serializers

Don’t put serialization logic in models. Create dedicated serializers that focus solely on Camille contract compliance.

๐Ÿ” 2. Test Your Contracts

Write tests that verify your serializers return the exact structure Camille expects. This catches drift early.

๐Ÿ”€ 3. Use Union Types

Leverage Camille’s union types (User | { error: String }) to handle success/error responses elegantly.

โฐ 4. String Timestamps

Convert DateTime objects to ISO8601 strings for consistent frontend handling.

๐Ÿšถโ€โ™‚๏ธ 5. Start Simple

Begin with basic types and schemas, then evolve as your API grows in complexity.

๐Ÿ“Š The Impact: Before vs. After

โŒ Before Camille:

  • โŒ Manual TypeScript type definitions
  • โŒ Runtime errors from type mismatches
  • โŒ Documentation drift
  • โŒ Time wasted on contract debugging

โœ… After Camille:

  • โœ… Single source of truth for API contracts
  • โœ… Automatic TypeScript generation
  • โœ… Runtime validation catches issues immediately
  • โœ… Self-documenting APIs
  • โœ… Confident deployments

โšก Performance Considerations

You might worry about runtime validation overhead. In our testing:

  • Development: Invaluable for catching issues early
  • Test: Perfect for ensuring contract compliance
  • Production: Consider disabling for performance-critical apps
# Disable in production if needed
config.camille.validate_responses = !Rails.env.production?

๐ŸŽฏ When to Use Camille

โœ… Perfect for:

  • Rails APIs with TypeScript frontends
  • Teams wanting strong API contracts
  • Projects where type safety matters
  • Microservices needing clear interfaces

๐Ÿค” Consider alternatives if:

  • You’re using GraphQL (already type-safe)
  • Simple APIs with stable contracts
  • Performance is absolutely critical

๐ŸŽ‰ Conclusion

Camille transforms Rails API development by bringing type safety to the Rails-TypeScript boundary. It eliminates a whole class of bugs while making your API more maintainable and self-documenting.

The initial setup requires some discipline – you need to think about your types upfront and maintain serializers. But the payoff in reduced debugging time and increased confidence is enormous.

For our user management API, Camille caught several type mismatches during development that would have been runtime bugs in production. The auto-generated TypeScript types kept our frontend in perfect sync with the backend.

If you’re building Rails APIs with TypeScript frontends, give Camille a try. Your future self (and your team) will thank you.


Want to see the complete implementation? Check out our example repository with a fully working Rails + Camille setup.

๐Ÿ“š Resources:

Have you used Camille in your projects? Share your experiences in the comments below! ๐Ÿ’ฌ

Happy Rails API Setup! ย ๐Ÿš€