Skip to content

Commit 60f90fa

Browse files
authored
feat(core): allow entire contract to be passed to TsRestResponseError (#579)
1 parent cab2948 commit 60f90fa

5 files changed

Lines changed: 138 additions & 5 deletions

File tree

.changeset/proud-moons-flow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/core': minor
3+
---
4+
5+
Allow entire contract to be passed to `TsRestResponseError` so common responses can be thrown easily

apps/docs/docs/core/server-errors.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ throw new TsRestResponseError(contract.getPost, {
1414
});
1515
```
1616

17+
You can also pass an entire contract to the `TsRestResponseError` constructor, and it will allow you to pass responses that are common to all endpoints.
18+
19+
```typescript
20+
throw new TsRestResponseError(contract, {
21+
status: 404,
22+
body: { message: 'Not Found' },
23+
});
24+
```
25+
1726
:::caution
1827

1928
Any thrown `TsRestResponseError` will NOT be caught by any error handlers, and will be served as a response straight away.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Equal, Expect } from './test-helpers';
2+
import { initContract } from './dsl';
3+
import { z } from 'zod';
4+
import { TsRestResponseError } from './response-error';
5+
import { HTTPStatusCode } from './status-codes';
6+
7+
const c = initContract();
8+
9+
const contract = c.router(
10+
{
11+
posts: {
12+
getPost: {
13+
method: 'GET',
14+
path: '/posts/:id',
15+
responses: {
16+
200: z.object({
17+
id: z.number(),
18+
content: z.string(),
19+
}),
20+
},
21+
},
22+
},
23+
users: {
24+
getUser: {
25+
method: 'GET',
26+
path: '/users/:id',
27+
responses: {
28+
200: z.object({
29+
id: z.number(),
30+
name: z.string(),
31+
}),
32+
},
33+
},
34+
},
35+
},
36+
{
37+
commonResponses: {
38+
404: z.object({
39+
message: z.string(),
40+
}),
41+
},
42+
},
43+
);
44+
45+
describe('TsRestResponseError', () => {
46+
it('correctly sets response type for single endpoint', () => {
47+
type ResponseType = Expect<
48+
Equal<
49+
ConstructorParameters<
50+
typeof TsRestResponseError<typeof contract.posts.getPost>
51+
>[1],
52+
| { status: 200; body: { id: number; content: string } }
53+
| { status: 404; body: { message: string } }
54+
| { status: Exclude<HTTPStatusCode, 200 | 404>; body: unknown }
55+
>
56+
>;
57+
});
58+
59+
it('correctly sets response type for entire contract', () => {
60+
type ResponseType = Expect<
61+
Equal<
62+
ConstructorParameters<typeof TsRestResponseError<typeof contract>>[1],
63+
{ status: 404; body: { message: string } }
64+
>
65+
>;
66+
});
67+
});

libs/ts-rest/core/src/lib/response-error.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import { AppRoute } from './dsl';
2-
import { ServerInferResponses } from './infer-types';
1+
import { AppRoute, AppRouter, AppRouteResponse } from './dsl';
2+
import { ResolveResponseType, ServerInferResponses } from './infer-types';
33
import { HTTPStatusCode } from './status-codes';
4+
import { CommonAndEqual, ZodInputOrType } from './type-utils';
45

5-
export class TsRestResponseError<T extends AppRoute> extends Error {
6+
export class TsRestResponseError<T extends AppRoute | AppRouter> extends Error {
67
public statusCode: HTTPStatusCode;
78
public body: any;
89

9-
constructor(route: T, response: ServerInferResponses<T>) {
10+
constructor(
11+
route: T,
12+
response: T extends AppRouter
13+
? ServerCommonResponses<T>
14+
: ServerInferResponses<T>,
15+
) {
1016
super();
1117

12-
this.statusCode = response.status;
18+
this.statusCode = response.status as HTTPStatusCode;
1319
this.body = response.body;
1420
this.name = this.constructor.name;
1521

@@ -27,3 +33,29 @@ export class TsRestResponseError<T extends AppRoute> extends Error {
2733
}
2834
}
2935
}
36+
37+
type FlattenAppRouter<T extends AppRouter | AppRoute> = T extends AppRoute
38+
? T
39+
: {
40+
[TKey in keyof T]: T[TKey] extends AppRoute
41+
? T[TKey]
42+
: T[TKey] extends AppRouter
43+
? FlattenAppRouter<T[TKey]>
44+
: never;
45+
}[keyof T];
46+
47+
type AppRouterCommonResponses<T extends AppRouter> = CommonAndEqual<
48+
FlattenAppRouter<T>['responses']
49+
>;
50+
51+
type ServerCommonResponses<
52+
T extends AppRouter,
53+
TResponses = AppRouterCommonResponses<T>,
54+
> = {
55+
[K in keyof TResponses]: {
56+
status: K;
57+
body: TResponses[K] extends AppRouteResponse
58+
? ZodInputOrType<ResolveResponseType<TResponses[K]>>
59+
: never;
60+
};
61+
}[keyof TResponses];

libs/ts-rest/core/src/lib/type-utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,23 @@ export type Not<B extends boolean> = {
180180
false: true;
181181
true: false;
182182
}[`${B}`];
183+
184+
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
185+
k: infer I,
186+
) => void
187+
? I
188+
: never;
189+
190+
type CommonKeys<T, R = {}> = R extends T
191+
? keyof T & CommonKeys<Exclude<T, R>>
192+
: keyof T;
193+
194+
type Common<T> = Pick<T, CommonKeys<T>>;
195+
196+
type RemoveUnionProperties<T> = {
197+
[TKey in keyof T as [T[TKey]] extends [UnionToIntersection<T[TKey]>]
198+
? TKey
199+
: never]: T[TKey];
200+
};
201+
202+
export type CommonAndEqual<T> = RemoveUnionProperties<Common<T>>;

0 commit comments

Comments
 (0)