You have a working FastAPI app. Routes are organized, Pydantic models validate your inputs, OpenAPI documents your endpoints. But every time you add a new endpoint, you need to update your frontend types, write a new fetch call, and make sure the URL matches. It's not broken — it's just manual.
This post walks through migrating a real FastAPI application to pyRPC, showing the before and after for each piece.
Starting point: raw FastAPI
Here's a typical user management API:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class User(BaseModel):
id: int
name: str
email: str
class CreateUserInput(BaseModel):
name: str
email: str
@app.get("/users/{user_id}")
async def get_user(user_id: int) -> User:
user = await fetch_user(user_id)
if not user:
raise HTTPException(404, "User not found")
return user
@app.post("/users")
async def create_user(data: CreateUserInput) -> User:
user = await insert_user(data.name, data.email)
return userAnd the TypeScript client code that goes with it:
interface User {
id: number
name: string
email: string
}
async function getUser(userId: number): Promise<User> {
const res = await fetch(`/users/${userId}`)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
async function createUser(name: string, email: string): Promise<User> {
const res = await fetch("/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}Every endpoint URL, HTTP method, status code, request shape, and response type is hand-written and manually kept in sync. If you change the Python model, you have to find and update every TypeScript usage.
Step 1: Add pyRPC
pip install pyrpc-core pyrpc-fastapi
That's it. One install command for the core runtime and the FastAPI adapter.
Step 2: Replace routes with procedures
Instead of route decorators, you use @rpc. Instead of BaseModel, you use @model. And instead of running uvicorn app:app directly, you call mount_fastapi(app):
from pyrpc_core import rpc, model
from pyrpc_fastapi import mount_fastapi
from fastapi import FastAPI
app = FastAPI()
@model
class User:
id: int
name: str
email: str
@rpc
def get_user(user_id: int) -> User:
user = fetch_user(user_id)
if not user:
raise ValueError("User not found")
return user
@rpc
def create_user(name: str, email: str) -> User:
return insert_user(name, email)
mount_fastapi(app)A few things to notice:
- No HTTP boilerplate — No routes, no methods, no status codes. The function is the endpoint.
- Plain Python types — Primitives are validated automatically. Complex models use
@modelwhich is just a thin wrapper over Pydantic. - Exceptions become errors —
ValueErroris automatically mapped to an error response. No moreHTTPExceptionboilerplate. - Sync is fine — pyRPC handles sync functions transparently. No need to make everything
asyncunless you need it.
Step 3: Generate the TypeScript client
npx pyrpc codegen --url http://localhost:8000
This generates a @pyrpc/types package with all your model and procedure types. Then your frontend becomes:
import { createClient } from "@pyrpc/client"
import type { Types } from "@pyrpc/types"
const client = createClient<Types>()
const user = await client.get_user(1)
const newUser = await client.create_user("Alice", "alice@example.com")Compare this to the original TypeScript code. No fetch calls, no URL strings, no JSON parsing, no manual error handling, no manual types. Everything is inferred.
The diff
Here's what changed per endpoint:
| Concern | Raw FastAPI | pyRPC |
|---|---|---|
| Route | @app.get("/users/{id}") | @rpc |
| Input model | class CreateUser(BaseModel) | def create_user(name: str, email: str) |
| Error handling | raise HTTPException | raise ValueError |
| Client call | fetch(...).then(r => r.json()) | client.get_user(1) |
| Type safety | Manual | Inferred |
When should you migrate?
pyRPC isn't meant to replace every FastAPI endpoint. It's best for:
- CRUD operations — The bread and butter of most backends.
- Business logic procedures — Functions that take inputs and return outputs.
- Internal APIs — Services that call each other within your infrastructure.
It's less suited for file uploads, streaming responses, or endpoints that need fine-grained HTTP control. For those, keep your regular FastAPI routes alongside pyRPC — they coexist perfectly.
Try it yourself
The fastest way to see the difference is to open the interactive playground, write a few @rpc procedures, and watch the TypeScript types update in real time.