6 min read
Complete Typesafe REST APIs with TS-rest
Let's not depend on TRPC clients and build our own typesafe REST API with TypeScript.
Tutorials
Table of contents
- What is TS-rest?
- Pre-requisites
- Setting up the project
- Contracts
- Implementing Contracts
- Consuming the contract on the frontend
- Conclusion
What is TS-rest?
As per the official documentation,
ts-rest offers a simple way to define a contract for your API, which can be both consumed and implemented by your application, giving you end-to-end type safety without the hassle or code generation.
In simple words, we define a shared contract for our API that can be used by both the client and the server. This contract is defined using zod, which is also used for runtime validation of the request and response automatically.
Pre-requisites
TS-rest works best within a monorepo setup since it requires the shared contract to be available to both the client and the server.
While you can also set up and publish a separate package for the shared contract, this might become a hassle as each change will require a new version to be published.
Hence in this tutorial, we will be using a monorepo setup using TurboRepo and pnpm workspaces.
Setting up the project
-
We can start by cloning the Rapid starter made by me.
Learn more about Rapid
Rapid is a starter developed by me that provides you with a monorepo pre-configured with Next.js, Express, Turbo, and Prisma etc. Learn more here.
git clone https://github.com/imprakharshukla/rapid
-
Now we can install the dependencies using pnpm.
pnpm install
-
Here is the folder structure of the project.
. ├── apps │ ├── backend │ ├── demo │ └── landing └── packages ├── auth ├── config ├── contract ├── db └── ui
-
The
/packages/contract
folder is where we will define our shared contract. The contract will be used by both the client and the server. -
The aforementioned shared contracts package is already installed on the backend, but if you want to add more backend apps, you can install the shared contract package in any of the apps by running:
pnpm install @repo/contract --filter <app-name>
Contracts
-
Here’s how the contract in the Rapid starter is defined like:
import { initContract } from "@ts-rest/core"; import { z } from "zod"; const c = initContract(); export const helloContract = c.router({ getHello: { method: "GET", path: "/hello", responses: { 200: z.object({ response: z.string(), }), 500: z.object({ response: z.string(), }), }, summary: "Echo Hello", }, });
More information on TS-Rest contracts here.
Implementing Contracts
Since we already have a contract for the /hello
route, we can implement it in the backend.
-
We have a
hello
route in the backend that will return a simple JSON response.import { initServer } from "@ts-rest/express"; import Container from "typedi"; import { Logger } from "winston"; import { z } from "zod"; import { superContract } from "@repo/contract"; import HelloService from "../../services/hello"; export default (server: ReturnType<typeof initServer>) => { const logger: Logger = Container.get("logger"); const helloServiceInstance: HelloService = Container.get(HelloService); return server.router(superContract.hello, { getHello: { handler: async () => { try { const hello = helloServiceInstance.generateHello(); return { status: 200, body: { response: hello, }, }; } catch (e) { logger.error(e); return { status: 500, body: { response: "Internal Server Error", }, }; } }, }, }); };
About Services
We can separate things like data fetching and other things that might require any processing to a service file for each route. This promotes separation of concerns.
-
Now we can export the implemented contract as the Express Router and add it our express app.
import { createExpressEndpoints, initServer } from "@ts-rest/express"; import { Request, Router } from "express"; import { superContract } from "@repo/contract"; import { adminOnlyMiddleware } from "./middleware/auth"; import hello from "./routes/hello"; export default () => { const app = Router(); const s = initServer(); const helloRouter = hello(s); createExpressEndpoints(superContract.hello, helloRouter, app, { globalMiddleware: [], }); return app; };
-
Our endpoint is ready to be consumed now. We can visit it at
localhost:3002/api/hello
and that will return:{ "response": "Hello" }
Consuming the contract on the frontend
-
Finally, we can consume the contracts that we have built on the frontend. Since we have declared the contracts in the shared package, we will have to install the internal package
@repo/contract
in our frontend apps with:pnpm install @repo/contract --filter <frontend-app-name>
-
After installing the package, we can declare a universal hook and a custom client using axios instead of fetch (since fetch does not throw any errors in non-2xx status codes).
Custom Client:
import { InitClientArgs } from "@ts-rest/next"; import { InitClientReturn, initQueryClient } from "@ts-rest/react-query"; import axios, { Method, isAxiosError } from "axios"; import { superContract } from "@repo/contract"; export interface TokenProvider { getToken: () => Promise<string>; } export class RestAPI { // Uncomment to unable authorization // tokenProvider: TokenProvider; public client: InitClientReturn<typeof superContract, InitClientArgs>; constructor(tokenProvider: TokenProvider) { const baseUrl = "http://localhost:3002/api"; // Uncomment to unable authorization // this.tokenProvider = tokenProvider; this.client = initQueryClient<typeof superContract, InitClientArgs>( superContract, { baseUrl: baseUrl, baseHeaders: {}, api: async ({ path, method, headers, body }) => { // Uncomment to unable authorization // const token = await this.tokenProvider.getToken(); try { const result = await axios.request({ method: method as Method, url: `${path}`, headers: { ...headers, // Uncomment to unable authorization // Authorization: `Bearer ${token}`, }, data: body, }); const responseHeaders = new Headers(); Object.entries(result.headers).forEach(([key, value]) => { if (value !== undefined && typeof value === "string") { responseHeaders.append(key, value.toString()); } }); return { status: result.status, body: result.data, headers: responseHeaders, }; } catch (e) { if (isAxiosError(e) && e.response) { const errorHeaders = new Headers(); Object.entries(e.response.headers).forEach(([key, value]) => { if (value !== undefined && typeof value === "string") { errorHeaders.append(key, value.toString()); } }); return { status: e.response.status, body: e.response.data, headers: errorHeaders, }; } throw e; } }, } ); } }
Custom Hook
To make it easy to consume the client, we can declare a custom hook.
import { useSession } from "next-auth/react"; import { useMemo } from "react"; import { RestAPI, TokenProvider } from "~/lib/client"; const fetchToken = async () => { const response = await fetch("/api/token"); const data = await response.json(); return data.token; }; const useRestAPI = () => { const { data: session } = useSession(); const api = useMemo(() => { const tokenProvider: TokenProvider = { getToken: fetchToken, }; const restApi = new RestAPI(tokenProvider); return { restApi: restApi.client }; }, [session, fetchToken]); return { client: api.restApi }; }; export default useRestAPI;
-
We can now use this hook to access the React Query premitives in our pages.
import useRestAPI from "./features/hooks/use-rest-client"; export default function Page() { const { client } = useRestAPI(); const { data: helloData, isLoading: isHelloLoading, error, refetch: refetchHello, isRefetching: isHelloRefetching, } = client.hello.getHello.useQuery(["hello"], {}); }
Learn more React Query
Learn what React Query exposes to be used in the React Query Documentation.
-
Using the imported data and functions is as easy as it gets:
<div className="mb-4 grid gap-3 lg:max-w-md"> {(isHelloLoading || isHelloRefetching) && <Loader></Loader>} {helloData && !isHelloLoading && !isHelloRefetching && ( <p>{helloData.body.response}</p> )} <Button variant={"outline"} onClick={() => { refetchHello(); }} > Refetch </Button> </div>
Conclusion
TS-Rest provides a way to deploy an end-to-end typesafe API within minutes.
You can also explore Rapid Starter to get a headstart with a Turborepo monorepo already configured with TS-Rest and Authentication.