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:
- Backend developer changes an API response format
- Frontend breaks silently in production
- Hours of debugging to track down the mismatch
- 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:
- Add to Gemfile:
gem "camille"
- 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
- 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! ย ๐