Loading

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

/images/blogs/ts_rest_tut_banner.png

Table of contents

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

  1. We can start by cloning the Rapid starter made by me.

    git clone https://github.com/imprakharshukla/rapid
    
  2. Now we can install the dependencies using pnpm.

    pnpm install
    
  3. Here is the folder structure of the project.

    .
    ├── apps
       ├── backend
       ├── demo
       └── landing
    └── packages
        ├── auth
        ├── config
        ├── contract
        ├── db
        └── ui
    
  4. The /packages/contract folder is where we will define our shared contract. The contract will be used by both the client and the server.

  5. 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

  1. 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.

  1. 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",
                },
              };
            }
          },
        },
      });
    };
    
  2. 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;
    };
    
  3. 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

  1. 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>
    
  2. 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;
    
  3. 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"], {});
    }
    
  4. 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.