Skip to content

Commit fcf877d

Browse files
authored
feat: allow defining non-json responses in the contract (#263)
1 parent 268419b commit fcf877d

24 files changed

Lines changed: 1129 additions & 529 deletions

.changeset/eight-masks-nail.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/express': minor
4+
'@ts-rest/fastify': minor
5+
'@ts-rest/nest': minor
6+
'@ts-rest/next': minor
7+
---
8+
9+
Allow defining non-json response types in the contract

libs/ts-rest/core/src/lib/client.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -220,17 +220,14 @@ export const getCompleteUrl = (
220220
return `${baseUrl}${path}${queryComponent}`;
221221
};
222222

223-
type FullClientInferRequest = ClientInferRequest<
224-
AppRouteMutation & { path: '/:placeholder' },
225-
ClientArgs
226-
>;
227-
228223
export const getRouteQuery = <TAppRoute extends AppRoute>(
229224
route: TAppRoute,
230225
clientArgs: InitClientArgs
231226
) => {
232227
const knownResponseStatuses = Object.keys(route.responses);
233-
return async (inputArgs?: FullClientInferRequest) => {
228+
return async (
229+
inputArgs?: ClientInferRequest<AppRouteMutation, ClientArgs>
230+
) => {
234231
const { query, params, body, headers, extraHeaders, ...extraInputArgs } =
235232
inputArgs || {};
236233

libs/ts-rest/core/src/lib/dsl.spec.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
/* eslint-disable @typescript-eslint/no-unused-vars */
22
import { z } from 'zod';
3-
import { initContract } from './dsl';
3+
import {
4+
initContract,
5+
ContractOtherResponse,
6+
ContractPlainType,
7+
ContractPlainTypeRuntimeSymbol,
8+
} from './dsl';
49
import type { Equal, Expect } from './test-helpers';
10+
511
const c = initContract();
612

713
describe('contract', () => {
@@ -354,9 +360,9 @@ describe('contract', () => {
354360
method: 'GET';
355361
path: '/posts/:id';
356362
responses: {
357-
200: {
363+
200: ContractPlainType<{
358364
id: number;
359-
};
365+
}>;
360366
};
361367
};
362368
}
@@ -506,7 +512,7 @@ describe('contract', () => {
506512
getPost: {
507513
path: '/posts/:id';
508514
method: 'GET';
509-
responses: { 200: { id: string } };
515+
responses: { 200: ContractPlainType<{ id: string }> };
510516
};
511517
}
512518
>
@@ -520,12 +526,39 @@ describe('contract', () => {
520526
getPost: {
521527
path: '/v1/posts/:id';
522528
method: 'GET';
523-
responses: { 200: { id: string } };
529+
responses: { 200: ContractPlainType<{ id: string }> };
524530
};
525531
};
526532
}
527533
>
528534
>;
529535
});
530536
});
537+
538+
it('should set type correctly for non-json response', () => {
539+
const contract = c.router({
540+
getCss: {
541+
method: 'GET',
542+
path: '/style.css',
543+
responses: {
544+
200: c.otherResponse({
545+
contentType: 'text/css',
546+
body: c.response<string>(),
547+
}),
548+
},
549+
},
550+
});
551+
552+
expect(contract.getCss.responses['200']).toEqual({
553+
contentType: 'text/css',
554+
body: ContractPlainTypeRuntimeSymbol,
555+
});
556+
557+
type ResponseType = Expect<
558+
Equal<
559+
typeof contract.getCss.responses['200'],
560+
ContractOtherResponse<ContractPlainType<string>>
561+
>
562+
>;
563+
});
531564
});

libs/ts-rest/core/src/lib/dsl.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,32 @@ type MixedZodError<A, B> = Opaque<{ a: A; b: B }, 'MixedZodError'>;
1010
*/
1111
type Path = string;
1212

13+
declare const NullSymbol: unique symbol;
14+
15+
export type ContractPlainType<T> = Opaque<T, 'ContractPlainType'>;
16+
export type ContractNullType = Opaque<typeof NullSymbol, 'ContractNullType'>;
17+
export type ContractAnyType =
18+
| z.ZodTypeAny
19+
| ContractPlainType<unknown>
20+
| ContractNullType
21+
| null;
22+
export type ContractOtherResponse<T extends ContractAnyType> = Opaque<
23+
{ contentType: string; body: T },
24+
'ContractOtherResponse'
25+
>;
26+
1327
type AppRouteCommon = {
1428
path: Path;
15-
pathParams?: unknown;
16-
query?: unknown;
17-
headers?: unknown;
29+
pathParams?: ContractAnyType;
30+
query?: ContractAnyType;
31+
headers?: ContractAnyType;
1832
summary?: string;
1933
description?: string;
2034
deprecated?: boolean;
21-
responses: Record<number, unknown>;
35+
responses: Record<
36+
number,
37+
ContractAnyType | ContractOtherResponse<ContractAnyType>
38+
>;
2239
strictStatusCodes?: boolean;
2340
metadata?: unknown;
2441
};
@@ -37,7 +54,7 @@ export type AppRouteQuery = AppRouteCommon & {
3754
export type AppRouteMutation = AppRouteCommon & {
3855
method: 'POST' | 'DELETE' | 'PUT' | 'PATCH';
3956
contentType?: 'application/json' | 'multipart/form-data';
40-
body: unknown;
57+
body: ContractAnyType;
4158
};
4259

4360
type ValidatedHeaders<
@@ -173,13 +190,27 @@ type ContractInstance = {
173190
*/
174191
mutation: <T extends AppRouteMutation>(mutation: T) => T;
175192
/**
176-
* Exists to allow storing a Type in the contract (at compile time only)
193+
* @deprecated Please use type() instead.
194+
*/
195+
response: <T>() => T extends null ? ContractNullType : ContractPlainType<T>;
196+
/**
197+
* @deprecated Please use type() instead.
177198
*/
178-
response: <T>() => T;
199+
body: <T>() => T extends null ? ContractNullType : ContractPlainType<T>;
179200
/**
180201
* Exists to allow storing a Type in the contract (at compile time only)
181202
*/
182-
body: <T>() => T;
203+
type: <T>() => T extends null ? ContractNullType : ContractPlainType<T>;
204+
/**
205+
* Define a custom response type
206+
*/
207+
otherResponse: <T extends ContractAnyType>({
208+
contentType,
209+
body,
210+
}: {
211+
contentType: string;
212+
body: T;
213+
}) => ContractOtherResponse<T>;
183214
};
184215

185216
/**
@@ -214,6 +245,10 @@ const recursivelyApplyOptions = <T extends AppRouter>(
214245
);
215246
};
216247

248+
export const ContractPlainTypeRuntimeSymbol = Symbol(
249+
'ContractPlainType'
250+
) as any;
251+
217252
/**
218253
* Instantiate a ts-rest client, primarily to access `router`, `response`, and `body`
219254
*
@@ -225,7 +260,19 @@ export const initContract = (): ContractInstance => {
225260
router: (endpoints, options) => recursivelyApplyOptions(endpoints, options),
226261
query: (args) => args,
227262
mutation: (args) => args,
228-
response: <T>() => undefined as unknown as T,
229-
body: <T>() => undefined as unknown as T,
263+
response: () => ContractPlainTypeRuntimeSymbol,
264+
body: () => ContractPlainTypeRuntimeSymbol,
265+
type: () => ContractPlainTypeRuntimeSymbol,
266+
otherResponse: <T extends ContractAnyType>({
267+
contentType,
268+
body,
269+
}: {
270+
contentType: string;
271+
body: T;
272+
}) =>
273+
({
274+
contentType,
275+
body,
276+
} as ContractOtherResponse<T>),
230277
};
231278
};

libs/ts-rest/core/src/lib/infer-types.spec.ts

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,13 @@ const contract = c.router(
6060
contentType: 'multipart/form-data',
6161
body: c.body<{ image: File }>(),
6262
responses: {
63-
201: z.object({
64-
id: z.number(),
65-
url: z.string(),
63+
201: c.otherResponse({
64+
contentType: 'text/plain',
65+
body: c.type<'Image uploaded successfully'>(),
66+
}),
67+
500: c.otherResponse({
68+
contentType: 'text/plain',
69+
body: z.literal('Image upload failed'),
6670
}),
6771
},
6872
},
@@ -85,9 +89,7 @@ const contract = c.router(
8589
})
8690
),
8791
}),
88-
404: z.object({
89-
message: z.string(),
90-
}),
92+
404: c.type<null>(),
9193
},
9294
},
9395
},
@@ -125,16 +127,20 @@ it('type inference helpers', () => {
125127
uploadImage:
126128
| {
127129
status: 201;
128-
body: { id: number; url: string };
130+
body: 'Image uploaded successfully';
129131
}
130-
| { status: Exclude<HTTPStatusCode, 201>; body: unknown };
132+
| {
133+
status: 500;
134+
body: 'Image upload failed';
135+
}
136+
| { status: Exclude<HTTPStatusCode, 201 | 500>; body: unknown };
131137
nested: {
132138
getComments:
133139
| {
134140
status: 200;
135141
body: { comments: { id: number; content: string }[] };
136142
}
137-
| { status: 404; body: { message: string } }
143+
| { status: 404; body: null }
138144
| { status: Exclude<HTTPStatusCode, 200 | 404>; body: unknown };
139145
};
140146
}
@@ -155,17 +161,22 @@ it('type inference helpers', () => {
155161
status: 201;
156162
body: { id: number; title: string; content: string };
157163
};
158-
uploadImage: {
159-
status: 201;
160-
body: { id: number; url: string };
161-
};
164+
uploadImage:
165+
| {
166+
status: 201;
167+
body: 'Image uploaded successfully';
168+
}
169+
| {
170+
status: 500;
171+
body: 'Image upload failed';
172+
};
162173
nested: {
163174
getComments:
164175
| {
165176
status: 200;
166177
body: { comments: { id: number; content: string }[] };
167178
}
168-
| { status: 404; body: { message: string } };
179+
| { status: 404; body: null };
169180
};
170181
}
171182
>
@@ -191,16 +202,20 @@ it('type inference helpers', () => {
191202
uploadImage:
192203
| {
193204
status: 201;
194-
body: { id: number; url: string };
205+
body: 'Image uploaded successfully';
195206
}
196-
| { status: Exclude<HTTPStatusCode, 201>; body: unknown };
207+
| {
208+
status: 500;
209+
body: 'Image upload failed';
210+
}
211+
| { status: Exclude<HTTPStatusCode, 201 | 500>; body: unknown };
197212
nested: {
198213
getComments:
199214
| {
200215
status: 200;
201216
body: { comments: { id: number; content: string }[] };
202217
}
203-
| { status: 404; body: { message: string } }
218+
| { status: 404; body: null }
204219
| { status: Exclude<HTTPStatusCode, 200 | 404>; body: unknown };
205220
};
206221
}
@@ -271,10 +286,15 @@ it('type inference helpers', () => {
271286
| { status: 404; body: { message: string } }
272287
| { status: Exclude<ErrorHttpStatusCode, 404>; body: unknown };
273288
createPost: { status: ErrorHttpStatusCode; body: unknown };
274-
uploadImage: { status: ErrorHttpStatusCode; body: unknown };
289+
uploadImage:
290+
| {
291+
status: 500;
292+
body: 'Image upload failed';
293+
}
294+
| { status: Exclude<ErrorHttpStatusCode, 500>; body: unknown };
275295
nested: {
276296
getComments:
277-
| { status: 404; body: { message: string } }
297+
| { status: 404; body: null }
278298
| { status: Exclude<ErrorHttpStatusCode, 404>; body: unknown };
279299
};
280300
}
@@ -295,7 +315,7 @@ it('type inference helpers', () => {
295315
};
296316
uploadImage: {
297317
status: 201;
298-
body: { id: number; url: string };
318+
body: 'Image uploaded successfully';
299319
};
300320
nested: {
301321
getComments: {
@@ -341,11 +361,16 @@ it('type inference helpers', () => {
341361
uploadImage:
342362
| {
343363
status: 201;
344-
body: { id: number; url: string };
364+
body: 'Image uploaded successfully';
345365
headers: Headers;
346366
}
347367
| {
348-
status: Exclude<HTTPStatusCode, 201>;
368+
status: 500;
369+
body: 'Image upload failed';
370+
headers: Headers;
371+
}
372+
| {
373+
status: Exclude<HTTPStatusCode, 201 | 500>;
349374
body: unknown;
350375
headers: Headers;
351376
};
@@ -358,7 +383,7 @@ it('type inference helpers', () => {
358383
}
359384
| {
360385
status: 404;
361-
body: { message: string };
386+
body: null;
362387
headers: Headers;
363388
}
364389
| {

0 commit comments

Comments
 (0)