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: Move Fast and Break Nothing

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:

src/server/api/root.ts
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:

src/client/index.ts
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: The TypeScript Native Cloud

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:

src/cloudstate/greeter.ts
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:

src/components/Greeting.tsx
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.

A More Complex Example: Todo List Application

To better illustrate the differences between tRPC and Freestyle, let’s implement a more complex feature: a Todo list application with real-time updates.

Todo List: tRPC Implementation

We’ll first create a file for sharing types between the server and client:

src/shared/types.ts
export type Todo = {
	id: string;
	text: string;
	completed: boolean;
};

Next, we can implement the Todo list application using tRPC:

src/server/api/root.ts
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:

src/client/components/TodoList.tsx
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>
    );
}

Todo List: Freestyle Implementation

Now, let’s see how the same Todo list application can be implemented using Freestyle:

src/cloudstate/todo-list.ts
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:

src/components/TodoList.tsx
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.

Key Differences

After examining both the simple greeting API and the more complex Todo list application, we can summarize the key differences between tRPC and Freestyle:

  1. Code Organization:

    • tRPC: Separates server-side router definitions from client-side query/mutation hooks.
    • Freestyle: Unifies backend and frontend code in a single paradigm.
  2. Real-time Updates:

    • tRPC: Requires manual setup of subscriptions and query invalidation.
    • Freestyle: Provides built-in real-time updates through the invalidate function.
  3. State Management:

    • tRPC: Developers need to manage state storage separately (e.g., databases in production).
    • Freestyle: @cloudstate decorator automatically handles state persistence for class properties.
  4. Type Safety:

    • tRPC: Uses Zod for input validation and derives types from these schemas.
    • Freestyle: Relies entirely on TypeScript’s native type system.
  5. Client-Side Usage:

    • tRPC: Requires explicit client setup and query/mutation handling using an additional package, React Query.
    • Freestyle: Allows direct method calls on cloudstate instances with automatic real-time updates.
  6. Learning Curve:

    • tRPC: Builds on familiar React and API concepts but introduces its own patterns and hooks.
    • Freestyle: Introduces new concepts like cloudstate but offers a more unified mental model.
  7. Lines of Code and Functionality:

    ImplementationBackendClientTotal
    tRPC4166107
    Freestyle322254
    • 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 🤯.

Conclusion: Choosing the Right Tool for the Job

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:

  • Adding type safety to existing API setups
  • Projects requiring framework agnosticism
  • Scenarios where a clear separation of concerns is preferred

Freestyle shines in:

  • Greenfield projects seeking a unified full-stack approach
  • Rapid prototyping and development
  • Scenarios where built-in state management and persistence are valuable

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.

Get Updates on our Unified TypeScript Stack

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.