Build a Typesafe API with tRPC and Deno
Deno is an all-in-one, zero-config toolchain for writing JavaScript and TypeScript with natively supports Web Platform APIs, making it an ideal choice for quickly building backends and APIs. To make our API easier to maintain, we can use tRPC, a TypeScript RPC (Remote Procedure Call) framework that enables you to build fully type-safe APIs without schema declarations or code generation.
In this tutorial, we’ll build a simple type-safe API with tRPC and Deno that returns information about dinosaurs:
You can find all the code for this tutorial in this GitHub repo.
🚨️ Deno 2 is here. 🚨️
With backwards compatibility with Node/npm, built-in package management, all-in-one zero-config toolchain, and native TypeScript and web API support, writing JavaScript has never been simpler.
Set up tRPC
To get started with tRPC in Deno, we’ll need to install the required dependencies. Thanks to Deno’s npm compatibility, we can use the npm versions of tRPC packages along with Zod for input validation:
deno install npm:@trpc/server@next npm:@trpc/client@next npm:zod jsr:@std/path
This installs the most recent tRPC server and client packages,
Zod for runtime type validation, and
the Deno Standard Library’s path
utility. These
packages will allow us to build a type-safe API layer between our client and
server code.
This will create a deno.json
file in the project root to manage the npm and
jsr dependencies:
{
"imports": {
"@std/path": "jsr:@std/path@^1.0.6",
"@trpc/client": "npm:@trpc/client@^11.0.0-rc.593",
"@trpc/server": "npm:@trpc/server@^11.0.0-rc.593",
"zod": "npm:zod@^3.23.8"
}
}
Set up the tRPC server
The first step in building our tRPC application is setting up the server. We’ll start by initializing tRPC and creating our base router and procedure builders. These will be the foundation for defining our API endpoints.
Create a server/trpc.ts
file:
// server/trpc.ts
import { initTRPC } from "@trpc/server";
/**
* Initialization of tRPC backend
* Should be done only once per backend!
*/
const t = initTRPC.create();
/**
* Export reusable router and procedure helpers
* that can be used throughout the router
*/
export const router = t.router;
export const publicProcedure = t.procedure;
This initializes tRPC and exports the router and procedure builders that we’ll
use to define our API endpoints. The publicProcedure
allows us to create
endpoints that don’t require authentication.
Next, we’ll create a simple data layer to manage our dinosaur data. Create a
server/db.ts
file with the below:
// server/db.ts
import { join } from "@std/path";
type Dino = { name: string; description: string };
const dataPath = join("data", "data.json");
async function readData(): Promise<Dino[]> {
const data = await Deno.readTextFile(dataPath);
return JSON.parse(data);
}
async function writeData(dinos: Dino[]): Promise<void> {
await Deno.writeTextFile(dataPath, JSON.stringify(dinos, null, 2));
}
export const db = {
dino: {
findMany: () => readData(),
findByName: async (name: string) => {
const dinos = await readData();
return dinos.find((dino) => dino.name === name);
},
create: async (data: { name: string; description: string }) => {
const dinos = await readData();
const newDino = { ...data };
dinos.push(newDino);
await writeData(dinos);
return newDino;
},
},
};
This creates a simple file-based database that reads and writes dinosaur data to a JSON file. In a production environment, you’d typically use a proper database, but this will work well for our demo.
⚠️️ In this tutorial, we hard code data and use a file-based database. However, you can connect to a variety of databases and use ORMs like Drizzle or Prisma.
Finally, we’ll need to provide the actual data. Let’s create a ./data.json
file with some sample dinosaur data:
// data/data.json
[
{
"name": "Aardonyx",
"description": "An early stage in the evolution of sauropods."
},
{
"name": "Abelisaurus",
"description": "\"Abel's lizard\" has been reconstructed from a single skull."
},
{
"name": "Abrictosaurus",
"description": "An early relative of Heterodontosaurus."
},
{
"name": "Abrosaurus",
"description": "A close Asian relative of Camarasaurus."
},
...
]
Now, we can create our main server file that defines our tRPC router and
procedures. Create a server/index.ts
file:
// server/index.ts
import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { z } from "zod";
import { db } from "./db.ts";
import { publicProcedure, router } from "./trpc.ts";
const appRouter = router({
dino: {
list: publicProcedure.query(async () => {
const dinos = await db.dino.findMany();
return dinos;
}),
byName: publicProcedure.input(z.string()).query(async (opts) => {
const { input } = opts;
const dino = await db.dino.findByName(input);
return dino;
}),
create: publicProcedure
.input(z.object({ name: z.string(), description: z.string() }))
.mutation(async (opts) => {
const { input } = opts;
const dino = await db.dino.create(input);
return dino;
}),
},
examples: {
iterable: publicProcedure.query(async function* () {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 500));
yield i;
}
}),
},
});
// Export type router type signature, this is used by the client.
export type AppRouter = typeof appRouter;
const server = createHTTPServer({
router: appRouter,
});
server.listen(3000);
This sets up three main endpoints:
dino.list
: Returns all dinosaursdino.byName
: Returns a specific dinosaur by namedino.create
: Creates a new dinosaurexamples.iterable
: A demonstration of tRPC’s support for async iterables
The server is configured to listen on port 3000 and will handle all tRPC requests.
While you can run the server now, you won’t be able to access any of the routes and have it return data. Let’s fix that!
Set up the tRPC client
With our server ready, we can create a client that consumes our API with full
type safety. Create a client/index.ts
file:
// client/index.ts
/**
* This is the client-side code that uses the inferred types from the server
*/
import {
createTRPCClient,
splitLink,
unstable_httpBatchStreamLink,
unstable_httpSubscriptionLink,
} from "@trpc/client";
/**
* We only import the `AppRouter` type from the server - this is not available at runtime
*/
import type { AppRouter } from "../server/index.ts";
// Initialize the tRPC client
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: unstable_httpSubscriptionLink({
url: "http://localhost:3000",
}),
false: unstable_httpBatchStreamLink({
url: "http://localhost:3000",
}),
}),
],
});
const dinos = await trpc.dino.list.query();
console.log("Dinos:", dinos);
const createdDino = await trpc.dino.create.mutate({
name: "Denosaur",
description:
"A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast.",
});
console.log("Created dino:", createdDino);
const dino = await trpc.dino.byName.query("Denosaur");
console.log("Denosaur:", dino);
const iterable = await trpc.examples.iterable.query();
for await (const i of iterable) {
console.log("Iterable:", i);
}
This client code demonstrates several key features of tRPC:
- Type inference from the server router. The client automatically inherits
all type definitions from the server through the
AppRouter
type import. This means you get complete type support and compile-time type checking for all your API calls. If you modify a procedure on the server, TypeScript will immediately flag any incompatible client usage. - Making queries and mutations. The example demonstrates two types of API
calls: Queries (
list
andbyName
) used for fetching data without side effects, and mutations (create
) used for operations that modify server-side state. The client automatically knows the input and output types for each procedure, providing type safety throughout the entire request cycle. - Working with async iterables. The
examples.iterable
demonstrates tRPC’s support for streaming data using async iterables. This feature is particularly useful for real-time updates or processing large datasets in chunks.
Now, let’s start our server to see it in action. In our deno.json
config file,
let’s create a new property tasks
with the following commands:
{
"tasks": {
"start": "deno -A server/index.ts",
"client": "deno -A client/index.ts"
}
// Other properties in deno.json remain the same.
}
We can list our available tasks with deno task
:
deno task
Available tasks:
- start
deno -A server/index.ts
- client
deno -A client/index.ts
Now, we can start the server with deno task start
. After that’s running, we
can run the client with deno task client
. You should see an output like this:
deno task client
Dinos: [
{
name: "Aardonyx",
description: "An early stage in the evolution of sauropods."
},
{
name: "Abelisaurus",
description: "Abel's lizard has been reconstructed from a single skull."
},
{
name: "Abrictosaurus",
description: "An early relative of Heterodontosaurus."
},
...
]
Created dino: {
name: "Denosaur",
description: "A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast."
}
Denosaur: {
name: "Denosaur",
description: "A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast."
}
Iterable: 0
Iterable: 1
Iterable: 2
Success! Running the ./client/index.ts
shows how to create a tRPC client and
use its JavaScript API to interact with the database. But how can we check if
the tRPC client is inferring the right types from the database? Let’s modify the
code snippet below in ./client/index.ts
to pass a number
instead of a
string
as the description
:
// ...
const createdDino = await trpc.dino.create.mutate({
name: "Denosaur",
description:
- "A dinosaur that lives in the deno ecosystem. Eats Nodes for breakfast.",
+ 100,
});
console.log("Created dino:", createdDino);
// ...
When we re-run the client:
deno task client
...
error: Uncaught (in promise) TRPCClientError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": [
"description"
],
"message": "Expected string, received number"
}
]
at Function.from (file:///Users/andyjiang/Library/Caches/deno/npm/registry.npmjs.org/@trpc/client/11.0.0-rc.608/dist/TRPCClientError.mjs:35:20)
at file:///Users/andyjiang/Library/Caches/deno/npm/registry.npmjs.org/@trpc/client/11.0.0-rc.608/dist/links/httpBatchStreamLink.mjs:118:56
at eventLoopTick (ext:core/01_core.js:175:7)
tRPC successfully threw an invalid_type
error, since it was expecting a
string
instead of a number
.
What’s next?
Now that you have a basic understanding of how to use tRPC with Deno, you could:
- Build out an actual frontend using Next.js or React
- Add authentication to your API using tRPC middleware
- Implement real-time features using tRPC subscriptions
- Add input validation for more complex data structures
- Integrate with a proper database like PostgreSQL or use an ORM like Drizzle or Prisma
- Deploy your application to Deno Deploy or any public cloud via Docker
🦕 Happy type safety coding with Deno and tRPC!
🚨️ Want to learn more Deno? 🚨️
Check out our new Learn Deno tutorial series, where you’ll learn:
…and more, in short, bite-sized videos. New tutorials published every Tuesday and Thursday.