Skip to content

Commit 74bb4a8

Browse files
Implement strict mode (#233)
* Implement strict mode * Add test for nest package * Ensure strict mode works for express * Ensure strict mode works for next * Improve variable name * Add changeset * Only throw on unknown status * Rename strict mode * Undo breaking change to ApiRouteResponse * Fix types after merge * Address PR feedback * Update changeset * chore: add tests to dsl.spec.ts * more tests and some simplification of types * fix missing change for inference helpers * docs * fix tests after merge --------- Co-authored-by: Youssef Gaber <1728215+Gabrola@users.noreply.github.com>
1 parent 4dd92ee commit 74bb4a8

14 files changed

Lines changed: 417 additions & 40 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@ts-rest/core": minor
3+
"@ts-rest/express": minor
4+
"@ts-rest/nest": minor
5+
"@ts-rest/next": minor
6+
---
7+
8+
Implement strict mode at a contract level. Strict mode ensures that only known responses are allowed by the type system. This applies both on the server and client side. Enable this with `strictStatusCodes: true` when defining a contract.
9+
10+
If you would like to have the vanilla client throw an error when the response status is not known then you will need to use `throwOnUnknownStatus` when initializing the client.

apps/docs/docs/core/core.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,35 @@ export const contract = c.router({
175175
},
176176
});
177177
```
178+
179+
## Strict Response Status Codes
180+
181+
To help with incremental adoption, ts-rest, by default, will allow any response status code to be returned from the server
182+
even if it is not defined in the contract.
183+
184+
As a result, the response types on the client will include all possible HTTP status codes, even ones that are not defined
185+
in the contract with those mapping to a body type of `unknown`.
186+
187+
If you would like to disable this functionality and only allow the response status codes defined in the contract, you can
188+
set the `strictStatusCodes` option to `true` when initializing the contract.
189+
190+
```typescript
191+
const c = initContract();
192+
export const contract = c.router({
193+
// ...endpoints
194+
}, {
195+
strictStatusCodes: true,
196+
});
197+
```
198+
199+
You can also set this option on a per-route basis which will also override the global option.
200+
201+
```typescript
202+
const c = initContract();
203+
export const contract = c.router({
204+
getPosts: {
205+
...,
206+
strictStatusCodes: true,
207+
}
208+
});
209+
```

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as fetchMock from 'fetch-mock-jest';
2-
import { initContract } from '..';
2+
import { HTTPStatusCode, initContract } from '..';
33
import { ApiFetcherArgs, initClient } from './client';
44
import { Equal, Expect } from './test-helpers';
55
import { z } from 'zod';
@@ -134,13 +134,24 @@ export const router = c.router(
134134
}
135135
);
136136

137+
const routerStrict = c.router(router, {
138+
strictStatusCodes: true,
139+
});
140+
137141
const client = initClient(router, {
138142
baseUrl: 'https://api.com',
139143
baseHeaders: {
140144
'X-Api-Key': 'foo',
141145
},
142146
});
143147

148+
const clientStrict = initClient(routerStrict, {
149+
baseUrl: 'https://api.com',
150+
baseHeaders: {
151+
'X-Api-Key': 'foo',
152+
},
153+
});
154+
144155
type ClientGetPostsType = Expect<
145156
Equal<
146157
Parameters<typeof client.posts.getPosts>[0],
@@ -187,6 +198,19 @@ type ClientGetPostType = Expect<
187198
}
188199
>
189200
>;
201+
type RouterHealthStrict = Expect<
202+
Equal<typeof routerStrict.health['strictStatusCodes'], true>
203+
>;
204+
type RouterGetPostStrict = Expect<
205+
Equal<typeof routerStrict.posts.getPost['strictStatusCodes'], true>
206+
>;
207+
type HealthReturnType = Awaited<ReturnType<typeof clientStrict.health>>;
208+
type ClientGetPostResponseType = Expect<
209+
Equal<
210+
HealthReturnType,
211+
{ status: 200; body: { message: string }; headers: Headers }
212+
>
213+
>;
190214

191215
describe('client', () => {
192216
beforeEach(() => {
@@ -654,6 +678,7 @@ type CustomClientGetPostType = Expect<
654678
describe('custom api', () => {
655679
beforeEach(() => {
656680
argsCalledMock.mockReset();
681+
fetchMock.mockReset();
657682
});
658683

659684
it('should allow a uploadProgress attribute on the api call', async () => {
@@ -738,7 +763,7 @@ describe('custom api', () => {
738763
);
739764
});
740765

741-
it('has correct types when throwOnUnknownStatus is configured', async () => {
766+
it('has correct types when throwOnUnknownStatus only is configured', async () => {
742767
const client = initClient(router, {
743768
baseUrl: 'https://api.com',
744769
baseHeaders: {
@@ -751,6 +776,16 @@ describe('custom api', () => {
751776

752777
const result = await client.posts.getPosts({});
753778

779+
type ClientGetPostsResponseStatusType = Expect<
780+
Equal<typeof result.status, HTTPStatusCode>
781+
>;
782+
});
783+
784+
it('has correct types when strictStatusCode is configured', async () => {
785+
fetchMock.getOnce({ url: 'https://api.com/posts' }, { status: 200 });
786+
787+
const result = await clientStrict.posts.getPosts({});
788+
754789
type ClientGetPostsResponseStatusType = Expect<
755790
Equal<typeof result.status, 200>
756791
>;

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

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import { AppRoute, AppRouteMutation, AppRouter, isAppRoute } from './dsl';
1+
import {
2+
AppRoute,
3+
AppRouteMutation,
4+
AppRouter,
5+
AppRouteStrictStatusCodes,
6+
isAppRoute,
7+
} from './dsl';
28
import { insertParamsIntoPath, ParamsFromUrl } from './paths';
39
import { convertQueryParamsToUrlString } from './query';
410
import { HTTPStatusCode } from './status-codes';
511
import {
612
AreAllPropertiesOptional,
13+
Extends,
714
LowercaseKeys,
815
Merge,
916
OptionalIfAllOptional,
@@ -97,28 +104,28 @@ type DataReturnArgs<
97104
Without<DataReturnArgsBase<TRoute, TClientArgs>, never>
98105
>;
99106

100-
export type ApiRouteResponseNoUnknownStatus<T> =
107+
export type ApiRouteResponse<T, TStrictStatusCodes = false> =
101108
| {
102109
[K in keyof T]: {
103110
status: K;
104111
body: ZodInferOrType<T[K]>;
105112
headers: Headers;
106113
};
107-
}[keyof T];
108-
109-
export type ApiRouteResponse<T> =
110-
| ApiRouteResponseNoUnknownStatus<T>
111-
| {
112-
status: Exclude<HTTPStatusCode, keyof T>;
113-
body: unknown;
114-
headers: Headers;
115-
};
114+
}[keyof T]
115+
| (TStrictStatusCodes extends true
116+
? never
117+
: {
118+
status: Exclude<HTTPStatusCode, keyof T>;
119+
body: unknown;
120+
headers: Headers;
121+
});
116122

117123
/**
118124
* @deprecated Only safe to use on the client-side. Use `ServerInferResponses`/`ClientInferResponses` instead.
119125
*/
120126
export type ApiResponseForRoute<T extends AppRoute> = ApiRouteResponse<
121-
T['responses']
127+
T['responses'],
128+
Extends<T, AppRouteStrictStatusCodes>
122129
>;
123130

124131
/**
@@ -132,12 +139,10 @@ export function getRouteResponses<T extends AppRouter>(router: T) {
132139
};
133140
}
134141

135-
type AppRouteFunctionReturn<
136-
TRoute extends AppRoute,
137-
TClientArgs extends ClientArgs
138-
> = TClientArgs extends { throwOnUnknownStatus: true }
139-
? ApiRouteResponseNoUnknownStatus<TRoute['responses']>
140-
: ApiRouteResponse<TRoute['responses']>;
142+
type AppRouteFunctionReturn<TRoute extends AppRoute> = ApiRouteResponse<
143+
TRoute['responses'],
144+
Extends<TRoute, AppRouteStrictStatusCodes>
145+
>;
141146

142147
/**
143148
* Returned from a mutation or query call
@@ -148,10 +153,10 @@ export type AppRouteFunction<
148153
> = AreAllPropertiesOptional<DataReturnArgs<TRoute, TClientArgs>> extends true
149154
? (
150155
args?: Prettify<DataReturnArgs<TRoute, TClientArgs>>
151-
) => Promise<Prettify<AppRouteFunctionReturn<TRoute, TClientArgs>>>
156+
) => Promise<Prettify<AppRouteFunctionReturn<TRoute>>>
152157
: (
153158
args: Prettify<DataReturnArgs<TRoute, TClientArgs>>
154-
) => Promise<Prettify<AppRouteFunctionReturn<TRoute, TClientArgs>>>;
159+
) => Promise<Prettify<AppRouteFunctionReturn<TRoute>>>;
155160

156161
export interface ClientArgs {
157162
baseUrl: string;
@@ -210,19 +215,21 @@ export const tsRestFetchApi: ApiFetcher = async ({
210215
body: await result.json(),
211216
headers: result.headers,
212217
};
213-
} else if (contentType?.includes('text/plain')) {
218+
}
219+
220+
if (contentType?.includes('text/plain')) {
214221
return {
215222
status: result.status,
216223
body: await result.text(),
217224
headers: result.headers,
218225
};
219-
} else {
220-
return {
221-
status: result.status,
222-
body: await result.blob(),
223-
headers: result.headers,
224-
};
225226
}
227+
228+
return {
229+
status: result.status,
230+
body: await result.blob(),
231+
headers: result.headers,
232+
};
226233
};
227234

228235
const createFormData = (body: unknown) => {

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,4 +363,118 @@ describe('contract', () => {
363363
>
364364
>;
365365
});
366+
367+
it('should add strictStatusCodes=true option to routes', () => {
368+
const contract = c.router(
369+
{
370+
getPost: {
371+
method: 'GET',
372+
path: '/posts/:id',
373+
responses: {
374+
200: c.body<{ id: number }>(),
375+
},
376+
},
377+
},
378+
{
379+
strictStatusCodes: true,
380+
}
381+
);
382+
383+
expect(contract.getPost.strictStatusCodes).toStrictEqual(true);
384+
385+
type ContractShape = Expect<
386+
Equal<
387+
Pick<typeof contract.getPost, 'strictStatusCodes'>,
388+
{
389+
strictStatusCodes: true;
390+
}
391+
>
392+
>;
393+
});
394+
395+
it('should add strictStatusCodes=false option to routes', () => {
396+
const contract = c.router(
397+
{
398+
getPost: {
399+
method: 'GET',
400+
path: '/posts/:id',
401+
responses: {
402+
200: c.body<{ id: number }>(),
403+
},
404+
},
405+
},
406+
{
407+
strictStatusCodes: false,
408+
}
409+
);
410+
411+
expect(contract.getPost.strictStatusCodes).toStrictEqual(false);
412+
413+
type ContractShape = Expect<
414+
Equal<
415+
Pick<typeof contract.getPost, 'strictStatusCodes'>,
416+
{
417+
strictStatusCodes: false;
418+
}
419+
>
420+
>;
421+
});
422+
423+
it('should merge strictStatusCodes options correctly is route is true', () => {
424+
const contract = c.router(
425+
{
426+
getPost: {
427+
method: 'GET',
428+
path: '/posts/:id',
429+
responses: {
430+
200: c.body<{ id: number }>(),
431+
},
432+
strictStatusCodes: true,
433+
},
434+
},
435+
{
436+
strictStatusCodes: false,
437+
}
438+
);
439+
440+
expect(contract.getPost.strictStatusCodes).toStrictEqual(true);
441+
442+
type ContractShape = Expect<
443+
Equal<
444+
Pick<typeof contract.getPost, 'strictStatusCodes'>,
445+
{
446+
strictStatusCodes: true;
447+
}
448+
>
449+
>;
450+
});
451+
452+
it('should merge strictStatusCodes options correctly if route is false', () => {
453+
const contract = c.router(
454+
{
455+
getPost: {
456+
method: 'GET',
457+
path: '/posts/:id',
458+
responses: {
459+
200: c.body<{ id: number }>(),
460+
},
461+
strictStatusCodes: false,
462+
},
463+
},
464+
{
465+
strictStatusCodes: true,
466+
}
467+
);
468+
469+
expect(contract.getPost.strictStatusCodes).toStrictEqual(false);
470+
471+
type ContractShape = Expect<
472+
Equal<
473+
Pick<typeof contract.getPost, 'strictStatusCodes'>,
474+
{
475+
strictStatusCodes: false;
476+
}
477+
>
478+
>;
479+
});
366480
});

0 commit comments

Comments
 (0)