DEV Community

Alex Spinov
Alex Spinov

Posted on

ts-rest Has Free Type-Safe REST APIs — Here's How to Get tRPC-Like Safety Without tRPC

tRPC is great but forces you into its ecosystem. ts-rest gives you the same type safety for standard REST APIs.

What is ts-rest?

ts-rest is a type-safe REST API framework. Define your API contract once, get full type safety on both client and server — with standard HTTP methods and paths.

Quick Start

bun add @ts-rest/core
bun add @ts-rest/express      # or @ts-rest/next, @ts-rest/fastify
bun add @ts-rest/react-query   # for React client
Enter fullscreen mode Exit fullscreen mode

Define the Contract

// contract.ts (shared between client and server)
import { initContract } from '@ts-rest/core';
import { z } from 'zod';

const c = initContract();

export const contract = c.router({
  getUsers: {
    method: 'GET',
    path: '/users',
    responses: {
      200: z.array(z.object({
        id: z.string(),
        name: z.string(),
        email: z.string(),
      })),
    },
    query: z.object({
      limit: z.number().optional(),
      offset: z.number().optional(),
    }),
  },
  getUser: {
    method: 'GET',
    path: '/users/:id',
    responses: {
      200: z.object({ id: z.string(), name: z.string(), email: z.string() }),
      404: z.object({ message: z.string() }),
    },
  },
  createUser: {
    method: 'POST',
    path: '/users',
    body: z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }),
    responses: {
      201: z.object({ id: z.string(), name: z.string(), email: z.string() }),
      400: z.object({ message: z.string() }),
    },
  },
  deleteUser: {
    method: 'DELETE',
    path: '/users/:id',
    responses: {
      204: z.void(),
      404: z.object({ message: z.string() }),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Server Implementation (Express)

import { createExpressEndpoints, initServer } from '@ts-rest/express';
import express from 'express';
import { contract } from './contract';

const app = express();
app.use(express.json());

const s = initServer();

const router = s.router(contract, {
  getUsers: async ({ query }) => {
    const users = await db.users.findMany({
      take: query.limit || 10,
      skip: query.offset || 0,
    });
    return { status: 200, body: users };
  },
  getUser: async ({ params }) => {
    const user = await db.users.findUnique({ where: { id: params.id } });
    if (!user) return { status: 404, body: { message: 'User not found' } };
    return { status: 200, body: user };
  },
  createUser: async ({ body }) => {
    const user = await db.users.create({ data: body });
    return { status: 201, body: user };
  },
  deleteUser: async ({ params }) => {
    await db.users.delete({ where: { id: params.id } });
    return { status: 204, body: undefined };
  },
});

createExpressEndpoints(contract, router, app);
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Client Usage

import { initClient } from '@ts-rest/core';
import { contract } from './contract';

const client = initClient(contract, {
  baseUrl: 'http://localhost:3000',
  baseHeaders: { Authorization: 'Bearer token' },
});

// Fully typed!
const { status, body } = await client.getUsers({ query: { limit: 10 } });
if (status === 200) {
  body.forEach(user => console.log(user.name)); // TypeScript knows this is User[]
}

const newUser = await client.createUser({
  body: { name: 'Alice', email: 'alice@example.com' },
});
Enter fullscreen mode Exit fullscreen mode

React Query Integration

import { initQueryClient } from '@ts-rest/react-query';
import { contract } from './contract';

const api = initQueryClient(contract, { baseUrl: '/api' });

function UserList() {
  const { data, isLoading } = api.getUsers.useQuery(['users'], {
    query: { limit: 20 },
  });

  if (isLoading) return <p>Loading...</p>;
  if (data?.status !== 200) return <p>Error</p>;

  return (
    <ul>
      {data.body.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

ts-rest vs tRPC vs OpenAPI

Feature ts-rest tRPC OpenAPI
Type Safety Full Full Codegen
REST Standard Yes No (RPC) Yes
URL Structure Standard paths Procedure calls Standard paths
Zod Validation Built-in Built-in Separate
React Query Built-in Built-in Generated
Cross-language REST API (any client) TS only Any language
OpenAPI Export Yes Plugin Native

Need type-safe data APIs? Check out my Apify actors — structured, validated data extraction. For custom solutions, email spinov001@gmail.com.

Top comments (0)