Achieving End-to-End Typesafe APIs: tRPC vs Freestyle
In the world of full-stack TypeScript development, achieving end-to-end type safety has become a holy grail. It promises to eliminate a whole class of runtime errors, enhance developer productivity, and provide a seamless development experience. Two solutions that have emerged to tackle this challenge are tRPC and Freestyle. Let’s explore how each approach handles building a simple greeting API, with a focus on creating typesafe APIs.
tRPC has gained significant popularity for its ability to provide end-to-end typesafe APIs without the need for code generation. Its tagline, “Move Fast and Break Nothing,” encapsulates its philosophy of boosting productivity while maintaining robust type safety.
Let’s see how we’d implement a simple greeting API using tRPC:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
export const appRouter = router({
greeting: publicProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => `Hello ${input.name}` as const),
});
export type AppRouter = typeof appRouter;
On the client side, we can use this API with full type safety:
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000',
}),
],
});
const res = await trpc.greeting.query({ name: 'John' });
// res is inferred as `Hello ${string}`
tRPC shines in its simplicity and its ability to provide automatic typesafety without any build or compile steps. It’s framework agnostic and has a light bundle size, making it easy to integrate into existing projects.
Freestyle takes a different approach to achieving end-to-end type safety. It aims to unify the frontend and backend into a single TypeScript codebase, eliminating the need for a separate API layer altogether.
Freestyle’s philosophy is to leverage the APIs already built into ECMAScript standards, rather than adding new opinionated APIs. This means you can use TypeScript as your database, blob storage, RPC layer, and more.
Let’s implement the same greeting functionality using Freestyle:
import { cloudstate } from 'freestyle-sh';
@cloudstate
class GreeterCS {
static id = 'greeter' as const;
greeting(name: string) {
return `Hello ${name}` as const;
}
}
On the client side, we can interact with this cloudstate class directly:
import { useCloud } from "freestyle-sh";
export function Greeting({ name }: { name: string }) {
const greeter = useCloud<typeof GreeterCS>("greeter");
const greeting = await greeter.greeting(name);
return <div>{greeting}</div>;
// greeting is inferred as `Hello ${string}`
}
Freestyle’s approach allows developers to work with backend logic as if it were local state, providing a seamless and typesafe development experience. No Zod for type validation—just pure TypeScript.
To better illustrate the differences between tRPC and Freestyle, let’s implement a more complex feature: a Todo list application with real-time updates.
We’ll first create a file for sharing types between the server and client:
export type Todo = {
id: string;
text: string;
completed: boolean;
};
Next, we can implement the Todo list application using tRPC:
import type { Todo } from '../../shared/types';
import { initTRPC } from '@trpc/server';
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
import { z } from 'zod';
const t = initTRPC.create();
// Create an event emitter for the subscription
const ee = new EventEmitter();
// Initialize the todos Map
const todos: Map<string, Todo> = new Map();
// Define the tRPC router
export const appRouter = t.router({
addTodo: t.procedure.input(z.string()).mutation(({ input }) => {
const id = Date.now().toString();
const newTodo: Todo = { id, text: input, completed: false };
todos.set(id, newTodo);
ee.emit('todoChange', Array.from(todos.values()).reverse());
return newTodo;
}),
getTodos: t.procedure.query(() => {
return Array.from(todos.values()).reverse();
}),
toggleTodo: t.procedure.input(z.string()).mutation(({ input }) => {
const todo = todos.get(input);
if (todo) {
todo.completed = !todo.completed;
todos.set(input, todo);
ee.emit('todoChange', Array.from(todos.values()).reverse());
}
return todo;
}),
onTodoChange: t.procedure.subscription(() => {
return observable<Todo[]>((emit) => {
const onTodoChange = (todos: Todo[]) => {
emit.next(todos);
};
ee.on('todoChange', onTodoChange);
return () => {
ee.off('todoChange', onTodoChange);
};
});
}),
});
export type AppRouter = typeof appRouter;
On the client side, we need to set up the tRPC client. We’ll want to use another package, React Query, for data fetching and mutation:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCReact, httpBatchLink } from '@trpc/react-query';
import { createWSClient, wsLink, splitLink } from '@trpc/client';
import { AppRouter } from '../../server/api/root';
// Create a tRPC client
export const trpc = createTRPCReact<AppRouter>();
// Create a QueryClient
const queryClient = new QueryClient();
// Create a tRPC client
const trpcClient = trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({
client: createWSClient({
url: 'ws://localhost:3000', // Adjust this URL to your WebSocket endpoint
}),
}),
false: httpBatchLink({
url: 'http://localhost:3000/trpc', // Adjust this URL to your HTTP endpoint
}),
}),
],
});
export function TodoListWrapper() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>
</trpc.Provider>
);
}
export function TodoList() {
const { data: todos = [] } = trpc.getTodos.useQuery();
const utils = trpc.useContext();
const addTodoMutation = trpc.addTodo.useMutation({
onSuccess: () => {
// Invalidate and refetch the todos query after adding a new todo
utils.getTodos.invalidate();
},
});
const toggleTodoMutation = trpc.toggleTodo.useMutation({
onSuccess: () => {
// Invalidate and refetch the todos query after toggling a todo
utils.getTodos.invalidate();
},
});
const addTodo = async (text: string) => {
await addTodoMutation.mutateAsync(text);
};
const toggleTodo = async (id: string) => {
await toggleTodoMutation.mutateAsync(id);
};
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
</li>
))}
</ul>
<button onClick={() => addTodo('New Todo')}>Add Todo</button>
</div>
);
}
Now, let’s see how the same Todo list application can be implemented using Freestyle:
import { cloudstate, invalidate, useCloud } from 'freestyle-sh';
@cloudstate
export class TodoListCS {
static id = 'todo-list' as const;
items = new Map<string, TodoItemCS>();
addItem(text: string) {
const item = new TodoItemCS(text);
this.items.set(item.id, item);
// forces the client to refetch the list
invalidate(useCloud<typeof TodoListCS>('todo-list').getItems);
return item.info();
}
getItems() {
return Array.from(this.items.values())
.map((item) => item.info())
.toReversed();
}
}
@cloudstate
export class TodoItemCS {
id = crypto.randomUUID();
completed = false;
constructor(public text: string) {
this.text = text;
}
info() {
return { id: this.id, text: this.text, completed: this.completed };
}
toggleCompletion() {
this.completed = !this.completed;
// forces the client to refetch the list
invalidate(useCloud<typeof TodoListCS>('todo-list').getItems);
return { completed: this.completed };
}
}
On the client side, we can use these cloudstate classes directly:
import { useCloud } from 'freestyle-sh';
import { useCloudQuery, useCloudMutation } from 'freestyle-sh/react';
import type { TodoItemCS, TodoListCS } from '../cloudstate/todo-list';
export function TodoList() {
const todoList = useCloud<typeof TodoListCS>('todo-list');
const { data: items } = useCloudQuery(todoList.getItems);
const { mutate: addItem } = useCloudMutation(todoList.addItem);
// render the todo list UI
return (
<div>
<ul>
{items.map((item) => (
<li key={item.id}>
<input
type="checkbox"
checked={item.completed}
onChange={() => useCloud<typeof TodoItemCS>(item.id).toggleCompletion()}
/>
<span>{item.text}</span>
</li>
))}
</ul>
<button onClick={() => addItem('New Todo')}>Add Todo</button>
</div>
);
}
Freestyle’s built-in useCloudQuery
hook automatically handles real-time updates and invalidation, so zero subscription boilerplate or additional packages like React Query are needed.
After examining both the simple greeting API and the more complex Todo list application, we can summarize the key differences between tRPC and Freestyle:
Code Organization:
Real-time Updates:
invalidate
function.State Management:
@cloudstate
decorator automatically handles state persistence for class properties.Type Safety:
Client-Side Usage:
Learning Curve:
Lines of Code and Functionality:
Implementation | Backend | Client | Total |
---|---|---|---|
tRPC | 41 | 66 | 107 |
Freestyle | 32 | 22 | 54 |
tRPC: Twice as many lines of code due to explicit subscription setup, query/mutation handling, Zod schema definitions, etc.
It’s worth echoing the words of Rich Harris, creator of Svelte, from his famous blog post on writing less code:
This isn’t just boring plumbing that takes up extra space on the screen, it’s also extra surface area for bugs.
Freestyle: Less code—while including both the RPC layer and persistent storage 🤯.
Both tRPC and Freestyle offer powerful solutions for achieving end-to-end type safety in full-stack TypeScript applications. The choice between them depends on your specific project requirements, team preferences, and development philosophy.
tRPC excels in:
Freestyle shines in:
Ultimately, both tools represent significant advancements in the TypeScript ecosystem. They push the boundaries of what’s possible in terms of type safety and developer experience, leading to more robust, maintainable, and developer-friendly applications.
As you evaluate these options for your next project, consider factors such as your team’s familiarity with traditional API structures, your need for built-in persistence, and your preference for code organization. Whichever path you choose, embracing end-to-end type safety is a step towards more reliable and efficient full-stack TypeScript development.
Subscribe to the latest product updates, articles, and tutorials from Freestyle, a YC-backed company. Be among the first to build full-stack in just TypeScript.