In this tutorial, we'll build a complete full-stack application: a task management API with a FastAPI backend and a TypeScript React frontend — all connected through pyRPC with end-to-end type safety.
What we're building
A simple task manager. Users can list tasks, create new ones, toggle completion status, and delete tasks. The data lives in memory (for simplicity), but the patterns are the same for any database.
Step 1: Set up the backend
mkdir pyrpc-tasks cd pyrpc-tasks pip install pyrpc-core pyrpc-fastapi uvicorn mkdir server
Create server/app.py:
from pyrpc_core import rpc, model
from pyrpc_fastapi import mount_fastapi
from fastapi import FastAPI
from dataclasses import dataclass
from typing import Optional
app = FastAPI()
@model
class Task:
id: int
title: str
completed: bool = False
# In-memory store
_tasks: list[Task] = []
_next_id = 1
@rpc
def list_tasks() -> list[Task]:
return list(_tasks)
@rpc
def create_task(title: str) -> Task:
global _next_id
task = Task(id=_next_id, title=title, completed=False)
_tasks.append(task)
_next_id += 1
return task
@rpc
def toggle_task(task_id: int) -> Optional[Task]:
for task in _tasks:
if task.id == task_id:
task.completed = not task.completed
return task
return None
@rpc
def delete_task(task_id: int) -> bool:
global _tasks
before = len(_tasks)
_tasks = [t for t in _tasks if t.id != task_id]
return len(_tasks) < before
mount_fastapi(app)Each procedure is a plain Python function with type annotations. pyRPC handles serialization, validation, and routing automatically. Start the server:
uvicorn server.app:app --reload
Step 2: Generate the TypeScript types
npx pyrpc codegen --url http://localhost:8000 --out ./src/types
This generates the @pyrpc/types module with inferred types for Task, list_tasks, create_task, toggle_task, and delete_task.
Step 3: Set up the frontend
npx create-vite@latest frontend --template react-ts cd frontend npm install @pyrpc/client npm run dev
Create src/client.ts:
import { createClient } from "@pyrpc/client"
import type { Types } from "./types"
export const client = createClient<Types>({
url: "http://localhost:8000/pyrpc",
})Now client is a fully typed proxy. Every procedure is available as a method with the correct parameters and return types.
Step 4: Build the UI
Here's the main App.tsx:
import { useEffect, useState } from "react"
import { client } from "./client"
interface Task {
id: number
title: string
completed: boolean
}
export default function App() {
const [tasks, setTasks] = useState<Task[]>([])
const [title, setTitle] = useState("")
const loadTasks = async () => {
const result = await client.list_tasks()
setTasks(result)
}
useEffect(() => { loadTasks() }, [])
const handleCreate = async () => {
if (!title.trim()) return
await client.create_task(title.trim())
setTitle("")
await loadTasks()
}
const handleToggle = async (id: number) => {
await client.toggle_task(id)
await loadTasks()
}
const handleDelete = async (id: number) => {
await client.delete_task(id)
await loadTasks()
}
return (
<div className="max-w-md mx-auto mt-10 p-6">
<h1 className="text-xl font-bold mb-4">Tasks</h1>
<div className="flex gap-2 mb-6">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="New task..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={handleCreate}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Add
</button>
</div>
<ul className="space-y-2">
{tasks.map((task) => (
<li key={task.id} className="flex items-center gap-3">
<input
type="checkbox"
checked={task.completed}
onChange={() => handleToggle(task.id)}
/>
<span className={task.completed ? "line-through" : ""}>
{task.title}
</span>
<button
onClick={() => handleDelete(task.id)}
className="ml-auto text-red-500 text-sm"
>
Delete
</button>
</li>
))}
</ul>
</div>
)
}Notice that every client.* call is fully typed. If you change the return type of list_tasks in Python, TypeScript will flag the mismatch immediately. There's no API response to reverse-engineer, no OpenAPI spec to consult — the types are the truth.
What this looks like in practice
When you type client., your editor shows autocomplete for all four procedures: list_tasks, create_task, toggle_task, delete_task. Each one shows the expected parameters and return type. If you pass a string where a number is expected, TypeScript gives you a red squiggly line — before you ever run the code.
This is the same experience you'd get with tRPC on a TypeScript backend, but now your backend is Python. The type bridge works in both directions: the Python functions are validated with Pydantic, and the TypeScript client mirrors those constraints at compile time.
Next steps
From here, you can:
- Add a database (SQLAlchemy, Prisma, etc.) behind the procedures
- Add authentication with a middleware or decorator
- Switch to Flask by changing one import
- Deploy with Docker — the FastAPI app is a standard ASGI application
The full source code for this tutorial is available in the examples directory.