Contracts

Contracts are the crux of ts-rest. They are essentially schemas for your API endpoints that enable amazing type safety(and intellisense by extension), and data validation. Before we make our first contract for our User, lets first design a specification of each of our endpoints.

User Endpoints

Register a user

  1. Lets keep it at a simple /register path.
  2. Since this will be a mutation(creation of an entity), this should have a POST method.
  3. The request body should have an email, a name, and a password as required by our data model.
  4. If the user is successfully registered, return a response status of 201, and the response body should include the user data along with the id that the user was registered in our database.
  5. If a user by the specified email already exists, then return a response status of 400, with an error message in the body.

Now that we have acquired the full understanding of our endpoint, we can go ahead and write this same information as a ts-rest contract.

import { initContract } from '@ts-rest/core';
import {  z } from 'zod';
 
const contract = initContract();
 
const UserContract = contract.router({
    register: {
        method : 'POST',
        path: '/register',
        responses:{
            400 : contract.type<{ message: "User already exists" }>(),
            201: z.object({
                id : z.number(),
                name : z.string(),
                email : z.string().email(),
            })
        },
        body: z.object({
            name : z.string(),
            email : z.string().email(),
            password : z.string()
        }),
        summary : 'Register a user'
    }
});

User Login

Since we have understood what contracts are, we can go ahead and add the login route to our contract as well.

const UserContract = contract.router({
	register : {/* implementation above*/},
 
	login : {
        method : 'POST',
        path : '/login',
        responses:{
          200 : z.object({
	                id : z.number(),
	                name : z.string(),
	                email : z.string().email(),
									token : z.string()
            }),
          400 : contract.type<{ message : "Username or password incorrect"}>(),
        },
        body: z.object({
					email : z.string().email(),
					password : z.string()
				}),
        summary : 'login a user'
    }
});

In this route, we have specified that the request body must have the email, and password of the user. If the user does not exist, or the password is incorrect, we return a 400, with the appropriate message. If the email, and password are valid, then we return a 200 along with the user details.

About Authentication

One interesting detail is the token field in the response body. This token field is part of the basic bearer authentication scheme that we will implement later. Basically this token is a string that contains the user id in an encrypted form. Our other endpoints that we will implement later on will require this token so we can not only protect our routes from unwarranted accesses, but also identify which user has hit the endpoint.

Blog Posts Endpoints

Now that we have defined our user contract, we can move onto creating a contract for our posts.

Creating a post

The request body must have a title, and a content for the post. The response body will have all the post details including its id.

import { initContract } from '@ts-rest/core';
import {  z } from 'zod';
 
const contract = initContract();
 
const PostContract = contract.router({ 
  createPost: {
    method: 'POST',
    path: '/posts',
    responses: {
      201: z.object({
			  id: z.number().int(),
			  title: z.string(),
			  content: z.string(),
			  creationDate: z.coerce.date(),
			  authorId: z.number().int(),
			})
		},
    body: z.object({
			title : z.string(),
			content : z.string()
		}),
    summary: 'Create a post',
		headers: z.object({
      authorization : z.string()
    })
  }
});

If you noticed, the a post requires an authorId, and our request body does not include it. This is where the authorization header comes into place. The authorization header’s value can be deserialized to get the id of the user that makes the call, and we can essentially consider them the author of that post. We will discuss this in more detail when we actually implement the authentication middleware.

Fetching a post

Since this is a simple fetch query, and not a mutation, we can use a GET method. We can also make use of path parameters to pass the id of the post that we want to fetch. If the request is successful, the response will have 200 status. The response body will include the post data if found otherwise it will be null.

const PostContract = contract.router({ 
  createPost: {/*Implemented above*/},
 
	getPost: {
    method: 'GET',
    path: `/posts/:id`,
    pathParams : z.object({ id : z.string().transform(Number) }),
    responses: {
      200: PostSchema.nullable(),
    },
    summary: 'Get a post by id',
		headers : z.object({
			authorization : z.string()
		})
  },
});

Authenticating the entire contract

Since we wanted to authenticate this endpoint as well, we have included the authorization header again. Instead of repeating ourselves, we can remove the individual header definitions, and define it once on the contract as a whole. This means that any endpoints within this contract must be authenticated.

const PostContract = contract.router({ 
  createPost: {/*Implemented above*/},
 
	getPost: {/*Implemented above*/},
}, {
	baseHeaders : z.object({
    authorization : z.string()
  })
});

We have successfully implemented all endpoints in our two contracts. Now we will go ahead and implement the actual logic also called the controllers.