Skip to content

client object endpoint is missing all fields if contract for that endpoint does not require a body. #841

@StoicDeveloper

Description

@StoicDeveloper

Describe the bug

If a body requirement is omitted in a mutation endpoint in the contract, then the client object field for that endpoint will have no fields, because the corresponding AppRoute will not match either AppRouteMutation or AppRouteQuery. Thus, it is valid to define body-less mutation endpoints, but the resulting client object field cannot be used.

How to reproduce

Create a mutation contract endpoint that has no body, attempt to use the useMutation(), or any other field, on the resulting client object endpoint.

Expected behavior

Body should not be required on deletion endpoints, as per the HTTP standard.

Code reproduction

I have these entries in my contract:

createJobPost: {
    method: 'POST',
    path: '/employer/post',
    body: createJobPostBodySchema,
    responses: {
      201: z.object({
        id: z.string(),
        positionTitle: z.string(),
        description: z.string().optional(),
      }),
      404: z.null(),
    },
  },
deleteJobPost: {
    method: 'DELETE',
    path: '/employer/post/:id',
    // body: z.optional(z.null()),
    pathParams: idSchema,
    responses: {
      204: z.null(),
      404: z.null(),
      400: z.null(),
    },
  },

The resulting client code looks like this:

export const client = initQueryClient(contract, {
  baseUrl: 'http://localhost:3001',
  baseHeaders: {},
})
client.createJobPost.useMutation() // compiles
client.deleteJobPost.useMutation() // Does not compile, deleteJobPost has no fields!

I noted this bug while using react-query v4 client, but the offending code seems to be part of core, in dsl.d.ts:

/**
 * A mutation endpoint. In REST terms, one using POST, PUT,
 * PATCH, or DELETE.
 */
export type AppRouteMutation = AppRouteCommon & {
    method: 'POST' | 'DELETE' | 'PUT' | 'PATCH';
    contentType?: 'application/json' | 'multipart/form-data' | 'application/x-www-form-urlencoded';
    body: ContractAnyType | ContractNoBodyType;
};

The react-query client uses extends AppRouteMutation and extends AppRouteQuery to check if a contract endpoint is a query or a mutation, but although the HTTP standard does not require DELETE requests to have a request body, this library apparently does. The workaround is to add a null body to every mutation that would otherwise not have a body.

// contract code:
{deleteJobPost: {
    method: 'DELETE',
    path: '/employer/post/:id',
    // body: z.optional(z.null()),
    pathParams: idSchema,
    responses: {
      204: z.null(),
      404: z.null(),
      400: z.null(),
    },
  },}

// mutation code:
client.deleteJobPost.useMutation() // Does not compile, deleteJobPost has no fields!
let mut = client.deleteJobPost.useMutation()
mut.mutate({body: null, params: {id: 0}}) // body field is required to compile. This is very annoying!

ts-rest version

'@ts-rest/core@3.52.1(@types/node@24.3.0)(zod@3.25.76)':

'@ts-rest/react-query@3.52.1(@tanstack/react-query@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions