Skip to content

Commit 13ca71b

Browse files
authored
feat: enable nested routers by introducing RecursivelyProcessAppRouter (#138)
* feat: enable nested routers by introducing RecursivelyProcessAppRouter * refactor: move getValue to express lib * test: move getValue tests to express lib
1 parent ec4a10e commit 13ca71b

6 files changed

Lines changed: 78 additions & 78 deletions

File tree

‎.changeset/popular-bikes-greet.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+
Enable nested routers without declaring new constants

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { Narrow } from './type-utils';
2-
31
/**
42
* The path with colon-prefixed parameters
53
* e.g. "/posts/:id".
@@ -37,6 +35,19 @@ export type AppRouteMutation = {
3735
responses: Record<number, unknown>;
3836
};
3937

38+
/**
39+
* Recursively process a router, allowing for you to define nested routers.
40+
*
41+
* The main purpose of this is to convert all path strings into string constants so we can infer the path
42+
*/
43+
type RecursivelyProcessAppRouter<T extends AppRouter> = {
44+
[K in keyof T]: T[K] extends AppRoute
45+
? T[K]
46+
: T[K] extends AppRouter
47+
? RecursivelyProcessAppRouter<T[K]>
48+
: T[K];
49+
};
50+
4051
/**
4152
* A union of all possible endpoint types.
4253
*/
@@ -67,17 +78,17 @@ type ContractInstance = {
6778
/**
6879
* A collection of routes or routers
6980
*/
70-
router: <T extends AppRouter>(endpoints: Narrow<T>) => T;
81+
router: <T extends AppRouter>(endpoints: RecursivelyProcessAppRouter<T>) => T;
7182
/**
7283
* A single query route, should exist within
7384
* a {@link AppRouter}
7485
*/
75-
query: <T extends AppRouteQuery>(query: Narrow<T>) => T;
86+
query: <T extends AppRouteQuery>(query: T) => T;
7687
/**
7788
* A single mutation route, should exist within
7889
* a {@link AppRouter}
7990
*/
80-
mutation: <T extends AppRouteMutation>(mutation: Narrow<T>) => T;
91+
mutation: <T extends AppRouteMutation>(mutation: T) => T;
8192
/**
8293
* Exists to allow storing a Type in the contract (at compile time only)
8394
*/
@@ -103,9 +114,7 @@ export const initContract = (): ContractInstance => {
103114
return {
104115
// @ts-expect-error - this is a type error, but it's not clear how to fix it
105116
router: (args) => args,
106-
// @ts-expect-error - this is a type error, but it's not clear how to fix it
107117
query: (args) => args,
108-
// @ts-expect-error - this is a type error, but it's not clear how to fix it
109118
mutation: (args) => args,
110119
response: <T>() => undefined as unknown as T,
111120
body: <T>() => undefined as unknown as T,
Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-unused-vars */
22
import { z } from 'zod';
3-
import { getValue, ZodInferOrType } from './type-utils';
3+
import { ZodInferOrType } from './type-utils';
44

55
const zodObject = z.object({ title: z.string() });
66
type Test1 = ZodInferOrType<typeof zodObject>;
@@ -11,37 +11,3 @@ type Test2 = ZodInferOrType<typeof zodObject>;
1111
type Test3 = ZodInferOrType<{ title: string }>;
1212

1313
it.todo('should infer type');
14-
15-
describe('getValue', () => {
16-
it('should get one level deep', () => {
17-
const obj = {
18-
title: 'Title',
19-
};
20-
21-
const value = getValue(obj, 'title');
22-
23-
expect(value).toBe('Title');
24-
});
25-
26-
it('should get one level deep with nullable', () => {
27-
const obj = {
28-
title: 'Title',
29-
};
30-
31-
const value = getValue(obj, 'test', null);
32-
33-
expect(value).toBe(null);
34-
});
35-
36-
it('should get two levels deep', () => {
37-
const obj = {
38-
sub: {
39-
text: 'text',
40-
},
41-
};
42-
43-
const value = getValue(obj, 'sub.text');
44-
45-
expect(value).toBe('text');
46-
});
47-
});

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

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,6 @@ export type GetFieldType<T, P> = P extends `${infer Left}.${infer Right}`
3737
: undefined
3838
: undefined;
3939

40-
export function getValue<
41-
TData,
42-
TPath extends string,
43-
TDefault = GetFieldType<TData, TPath>
44-
>(
45-
data: TData,
46-
path: TPath,
47-
defaultValue?: TDefault
48-
): GetFieldType<TData, TPath> | TDefault {
49-
const value = path
50-
.split(/[.[\]]/)
51-
.filter(Boolean)
52-
.reduce<GetFieldType<TData, TPath>>(
53-
(value, key) => (value as any)?.[key],
54-
data as any
55-
);
56-
57-
return value !== undefined ? value : (defaultValue as TDefault);
58-
}
59-
6040
// https://stackoverflow.com/questions/63447660/typescript-remove-all-properties-with-particular-type
6141
// Nested solution also available ^
6242
type ExcludeKeysWithTypeOf<T, V> = {
@@ -74,21 +54,6 @@ export type ZodInferOrType<T> = T extends ZodTypeAny ? z.infer<T> : T;
7454

7555
export type Merge<T, U> = Omit<T, keyof U> & U;
7656

77-
type Try<A, B, C> = A extends B ? A : C;
78-
79-
type NarrowRaw<T> =
80-
// eslint-disable-next-line @typescript-eslint/ban-types
81-
| (T extends Function ? T : never)
82-
| (T extends string | number | bigint | boolean ? T : never)
83-
| (T extends [] ? [] : never)
84-
| {
85-
[K in keyof T]: K extends 'description' ? T[K] : NarrowNotZod<T[K]>;
86-
};
87-
88-
type NarrowNotZod<T> = Try<T, ZodType, NarrowRaw<T>>;
89-
90-
export type Narrow<T> = Try<T, [], NarrowNotZod<T>>;
91-
9257
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
9358

9459
// https://github.com/ts-essentials/ts-essentials/blob/4c451652ba7c20b0e0b965e0b7755fd4d7844127/lib/types.ts#L228
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { getValue } from './ts-rest-express';
2+
3+
describe('getValue', () => {
4+
it('should get one level deep', () => {
5+
const obj = {
6+
title: 'Title',
7+
};
8+
9+
const value = getValue(obj, 'title');
10+
11+
expect(value).toBe('Title');
12+
});
13+
14+
it('should get one level deep with nullable', () => {
15+
const obj = {
16+
title: 'Title',
17+
};
18+
19+
const value = getValue(obj, 'test', null);
20+
21+
expect(value).toBe(null);
22+
});
23+
24+
it('should get two levels deep', () => {
25+
const obj = {
26+
sub: {
27+
text: 'text',
28+
},
29+
};
30+
31+
const value = getValue(obj, 'sub.text');
32+
33+
expect(value).toBe('text');
34+
});
35+
});

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,34 @@ import {
66
AppRouteQuery,
77
AppRouter,
88
checkZodSchema,
9-
getValue,
9+
GetFieldType,
1010
isAppRoute,
1111
parseJsonQueryObject,
1212
PathParamsWithCustomValidators,
1313
Without,
1414
ZodInferOrType,
1515
} from '@ts-rest/core';
1616

17+
export function getValue<
18+
TData,
19+
TPath extends string,
20+
TDefault = GetFieldType<TData, TPath>
21+
>(
22+
data: TData,
23+
path: TPath,
24+
defaultValue?: TDefault
25+
): GetFieldType<TData, TPath> | TDefault {
26+
const value = path
27+
.split(/[.[\]]/)
28+
.filter(Boolean)
29+
.reduce<GetFieldType<TData, TPath>>(
30+
(value, key) => (value as any)?.[key],
31+
data as any
32+
);
33+
34+
return value !== undefined ? value : (defaultValue as TDefault);
35+
}
36+
1737
export type ApiRouteResponse<T> = {
1838
[K in keyof T]: {
1939
status: K;

0 commit comments

Comments
 (0)