Skip to content

Commit afa5066

Browse files
authored
fix(core): broken c.responses() type and optional URL paths types without pathParams (#556)
1 parent 2d83d33 commit afa5066

8 files changed

Lines changed: 143 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/core': patch
3+
---
4+
5+
Fix incorrect type for URL `params` when using optional params without defining `pathParams`

.changeset/proud-bobcats-scream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/core': patch
3+
---
4+
5+
Fix broken types for `c.responses()`

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,39 @@ describe('contract', () => {
468468
>;
469469
});
470470

471+
it('should be typed correctly with separate responses with spread', () => {
472+
const responses = c.responses({
473+
200: c.type<{ id: number }>(),
474+
});
475+
476+
const contract = c.router({
477+
getPost: {
478+
method: 'GET',
479+
path: '/posts/:id',
480+
responses: {
481+
...responses,
482+
},
483+
},
484+
});
485+
486+
type ContractShape = Expect<
487+
Equal<
488+
typeof contract,
489+
{
490+
getPost: {
491+
method: 'GET';
492+
path: '/posts/:id';
493+
responses: {
494+
200: ContractPlainType<{
495+
id: number;
496+
}>;
497+
};
498+
};
499+
}
500+
>
501+
>;
502+
});
503+
471504
it('should add strictStatusCodes=true option to routes', () => {
472505
const contract = c.router(
473506
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ type ContractInstance = {
216216
ContractAnyType | ContractOtherResponse<ContractAnyType>
217217
>,
218218
>(
219-
responses: NarrowObject<TResponses>,
219+
responses: TResponses,
220220
) => TResponses;
221221
/**
222222
* @deprecated Please use type() instead.

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,27 @@ it('type inference helpers', () => {
434434
>
435435
>;
436436

437+
const commonErrors = c.responses({
438+
400: c.type<{ message: string }>(),
439+
});
440+
441+
const contractWithCommonErrors = c.router({
442+
get: {
443+
method: 'GET',
444+
path: '/',
445+
responses: {
446+
...commonErrors,
447+
},
448+
},
449+
});
450+
451+
type ClientInferResponseBodyCommonResponsesTest = Expect<
452+
Equal<
453+
ClientInferResponseBody<typeof contractWithCommonErrors.get, 400>,
454+
{ message: string }
455+
>
456+
>;
457+
437458
type ServerInferRequestTest = Expect<
438459
Equal<
439460
ServerInferRequest<typeof contract>,

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,26 @@ expectType<{ id: string; commentId: string; commentId2: string }>(
2424

2525
const urlOptional = '/post/:id?';
2626
expectType<{
27-
id: string;
27+
id?: string;
2828
}>(type<ParamsFromUrl<typeof urlOptional>>());
2929

3030
const urlManyOptional = '/post/:id?/comments/:commentId?';
31+
expectType<{
32+
id?: string;
33+
commentId?: string;
34+
}>(type<ParamsFromUrl<typeof urlManyOptional>>());
35+
36+
const urlMixedOptional = '/post/:id/comments/:commentId?';
3137
expectType<{
3238
id: string;
39+
commentId?: string;
40+
}>(type<ParamsFromUrl<typeof urlMixedOptional>>());
41+
42+
const urlMixedOptional2 = '/post/:id?/comments/:commentId';
43+
expectType<{
44+
id?: string;
3345
commentId: string;
34-
}>(type<ParamsFromUrl<typeof urlManyOptional>>());
46+
}>(type<ParamsFromUrl<typeof urlMixedOptional2>>());
3547

3648
describe('insertParamsIntoPath', () => {
3749
it('should insert params into path', () => {

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
type StripOptional<T> = T extends `${infer U}?` ? U : T;
1+
type ResolveOptionalPathParam<T extends string> =
2+
T extends `${infer PathParam}?`
3+
? {
4+
[key in PathParam]?: string | undefined;
5+
}
6+
: {
7+
[key in T]: string;
8+
};
29

310
/**
411
* @params T - The URL e.g. /posts/:id
@@ -8,21 +15,19 @@ type RecursivelyExtractPathParams<
815
T extends string,
916
TAcc extends null | Record<string, string>,
1017
> = T extends `/:${infer PathParam}/${infer Right}`
11-
? {
12-
[key in StripOptional<PathParam>]: string;
13-
} & RecursivelyExtractPathParams<Right, TAcc>
18+
? ResolveOptionalPathParam<PathParam> &
19+
RecursivelyExtractPathParams<Right, TAcc>
1420
: T extends `/:${infer PathParam}`
15-
? { [key in StripOptional<PathParam>]: string }
21+
? ResolveOptionalPathParam<PathParam>
1622
: T extends `/${string}/${infer Right}`
1723
? RecursivelyExtractPathParams<Right, TAcc>
1824
: T extends `/${string}`
1925
? TAcc
2026
: T extends `:${infer PathParam}/${infer Right}`
21-
? {
22-
[key in StripOptional<PathParam>]: string;
23-
} & RecursivelyExtractPathParams<Right, TAcc>
27+
? ResolveOptionalPathParam<PathParam> &
28+
RecursivelyExtractPathParams<Right, TAcc>
2429
: T extends `:${infer PathParam}`
25-
? TAcc & { [key in StripOptional<PathParam>]: string }
30+
? TAcc & ResolveOptionalPathParam<PathParam>
2631
: T extends `${string}/${infer Right}`
2732
? RecursivelyExtractPathParams<Right, TAcc>
2833
: TAcc;

libs/ts-rest/express/src/lib/ts-rest-express.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,56 @@ describe('ts-rest-express', () => {
234234
expect(res.header['content-length']).toBeUndefined();
235235
});
236236
});
237+
238+
it('should handle optional url params', async () => {
239+
const c = initContract();
240+
241+
const contract = c.router({
242+
getPosts: {
243+
method: 'GET',
244+
path: '/posts/:id?',
245+
pathParams: z.object({
246+
id: z.string().optional(),
247+
}),
248+
responses: {
249+
200: z.object({
250+
id: z.string().optional(),
251+
}),
252+
},
253+
},
254+
});
255+
256+
const server = initServer();
257+
const router = server.router(contract, {
258+
getPosts: async ({ params }) => {
259+
return {
260+
status: 200,
261+
body: {
262+
id: params.id,
263+
},
264+
};
265+
},
266+
});
267+
268+
const app = express();
269+
app.use(express.json());
270+
app.use(express.urlencoded({ extended: true }));
271+
createExpressEndpoints(contract, router, app);
272+
273+
await supertest(app)
274+
.get('/posts')
275+
.expect((res) => {
276+
expect(res.status).toEqual(200);
277+
expect(res.body).toEqual({});
278+
});
279+
280+
await supertest(app)
281+
.get('/posts/10')
282+
.expect((res) => {
283+
expect(res.status).toEqual(200);
284+
expect(res.body).toEqual({ id: '10' });
285+
});
286+
});
237287
});
238288

239289
describe('download', () => {

0 commit comments

Comments
 (0)