Skip to content

Commit 8dbbcab

Browse files
authored
Merge pull request #221 from KurtGokhan/empty-types-fix
fix: better handling for empty header and query types
2 parents 72ca074 + b3b249b commit 8dbbcab

File tree

4 files changed

+160
-39
lines changed

4 files changed

+160
-39
lines changed

src/treaty2/types.ts

Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { Elysia, ELYSIA_FORM_DATA } from 'elysia'
33

44
import { EdenWS } from './ws'
5-
import type { IsNever, Not, Prettify } from '../types'
5+
import type { IsNever, MaybeEmptyObject, Not, Prettify } from '../types'
66

77
// type Files = File | FileList
88

@@ -67,45 +67,19 @@ export namespace Treaty {
6767
[K in keyof Route as K extends `:${string}`
6868
? never
6969
: K]: K extends 'subscribe' // ? Websocket route
70-
? ({} extends Route['subscribe']['headers']
71-
? { headers?: Record<string, unknown> }
72-
: undefined extends Route['subscribe']['headers']
73-
? { headers?: Record<string, unknown> }
74-
: {
75-
headers: Route['subscribe']['headers']
76-
}) &
77-
({} extends Route['subscribe']['query']
78-
? { query?: Record<string, unknown> }
79-
: undefined extends Route['subscribe']['query']
80-
? { query?: Record<string, unknown> }
81-
: {
82-
query: Route['subscribe']['query']
83-
}) extends infer Param
70+
? MaybeEmptyObject<Route['subscribe']['headers'], 'headers'> &
71+
MaybeEmptyObject<Route['subscribe']['query'], 'query'> extends infer Param
8472
? (options?: Param) => EdenWS<Route['subscribe']>
8573
: never
8674
: Route[K] extends {
87-
body: infer Body
88-
headers: infer Headers
89-
params: any
90-
query: infer Query
91-
response: infer Res extends Record<number, unknown>
92-
}
93-
? ({} extends Headers
94-
? {
95-
headers?: Record<string, unknown>
96-
}
97-
: undefined extends Headers
98-
? { headers?: Record<string, unknown> }
99-
: {
100-
headers: Headers
101-
}) &
102-
({} extends Query
103-
? {
104-
query?: Record<string, unknown>
105-
}
106-
: undefined extends Query
107-
? { query?: Record<string, unknown> }
108-
: { query: Query }) extends infer Param
75+
body: infer Body
76+
headers: infer Headers
77+
params: any
78+
query: infer Query
79+
response: infer Res extends Record<number, unknown>
80+
}
81+
? MaybeEmptyObject<Headers, 'headers'> &
82+
MaybeEmptyObject<Query, 'query'> extends infer Param
10983
? {} extends Param
11084
? undefined extends Body
11185
? K extends 'get' | 'head'

src/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,36 @@ export type IsUnknown<T> = IsAny<T> extends true
4747
? true
4848
: false
4949

50+
type IsExactlyUnknown<T> = [T] extends [unknown]
51+
? [unknown] extends [T]
52+
? true
53+
: false
54+
: false;
55+
56+
type IsUndefined<T> = [T] extends [undefined] ? true : false
57+
58+
type IsMatchingEmptyObject<T> = [T] extends [{}]
59+
? [{}] extends [T]
60+
? true
61+
: false
62+
: false
63+
64+
export type MaybeEmptyObject<
65+
TObj,
66+
TKey extends PropertyKey,
67+
TFallback = Record<string, unknown>
68+
> = IsUndefined<TObj> extends true
69+
? { [K in TKey]?: TFallback }
70+
: IsExactlyUnknown<TObj> extends true
71+
? { [K in TKey]?: TFallback }
72+
: IsMatchingEmptyObject<TObj> extends true
73+
? { [K in TKey]?: TObj }
74+
: undefined extends TObj
75+
? { [K in TKey]?: TObj }
76+
: null extends TObj
77+
? { [K in TKey]?: TObj }
78+
: { [K in TKey]: TObj }
79+
5080
export type AnyTypedRoute = {
5181
body?: unknown
5282
headers?: unknown

test/treaty2.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Elysia, form, sse, t } from 'elysia'
22
import { Treaty, treaty } from '../src'
33

4-
import { describe, expect, it, beforeAll, afterAll, mock } from 'bun:test'
4+
import { describe, expect, it, beforeAll, afterAll, mock, test } from 'bun:test'
55

66
const randomObject = {
77
a: 'a',
@@ -92,6 +92,44 @@ const app = new Elysia()
9292
alias: t.Literal('Kristen')
9393
})
9494
})
95+
.group('/empty-test', (g) => g
96+
.get('/with-maybe-empty', ({ query, headers }) => ({ query, headers }), {
97+
query: t.MaybeEmpty(t.Object({ alias: t.String() })),
98+
headers: t.MaybeEmpty(t.Object({ username: t.String() }))
99+
})
100+
.get('/with-unknown', ({ query, headers }) => ({ query, headers }), {
101+
query: t.Unknown(),
102+
headers: t.Unknown(),
103+
})
104+
.get('/with-empty-record', ({ query, headers }) => ({ query, headers }), {
105+
query: t.Record(t.String(), t.Never()),
106+
headers: t.Record(t.String(), t.Never()),
107+
})
108+
.get('/with-empty-obj', ({ query, headers }) => ({ query, headers }), {
109+
query: t.Object({}),
110+
headers: t.Object({}),
111+
})
112+
.get('/with-partial', ({ query, headers }) => ({ query, headers }), {
113+
query: t.Partial(t.Object({ alias: t.String() })),
114+
headers: t.Partial(t.Object({ username: t.String() })),
115+
})
116+
.get('/with-optional', ({ query, headers }) => ({ query, headers }), {
117+
query: t.Optional(t.Object({ alias: t.String() })),
118+
headers: t.Optional(t.Object({ username: t.String() })),
119+
})
120+
.get('/with-union-undefined', ({ query, headers }) => ({ query, headers }), {
121+
query: t.Union([t.Object({ alias: t.String() }), t.Undefined()]),
122+
headers: t.Union([t.Object({ username: t.String() }), t.Undefined()])
123+
})
124+
.get('/with-union-empty-obj', ({ query, headers }) => ({ query, headers }), {
125+
query: t.Union([t.Object({ alias: t.String() }), t.Object({})]),
126+
headers: t.Union([t.Object({ username: t.String() }), t.Object({})]),
127+
})
128+
.get('/with-union-empty-record', ({ query, headers }) => ({ query, headers }), {
129+
query: t.Union([t.Object({ alias: t.String() }), t.Record(t.String(), t.Never())]),
130+
headers: t.Union([t.Object({ username: t.String() }), t.Record(t.String(), t.Never())]),
131+
})
132+
)
95133
.post('/queries', ({ query }) => query, {
96134
query: t.Object({
97135
username: t.String(),
@@ -354,6 +392,22 @@ describe('Treaty2', () => {
354392
expect(error?.value.type).toBe('validation')
355393
})
356394

395+
test.each([
396+
'with-empty-obj',
397+
'with-partial',
398+
'with-unknown',
399+
'with-empty-record',
400+
'with-union-empty-obj',
401+
'with-union-empty-record',
402+
// 'with-maybe-empty',
403+
// 'with-optional',
404+
// 'with-union-undefined',
405+
] as const)('type test for case: %s', async (caseName) => {
406+
const { data, error } = await client['empty-test'][caseName].get()
407+
expect(error, JSON.stringify(error, null, 2)).toBeNull()
408+
expect(data).toEqual({ query: {}, headers: {} })
409+
})
410+
357411
it('post queries', async () => {
358412
const query = { username: 'A', alias: 'Kristen' } as const
359413

test/types/treaty2.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ const app = new Elysia()
131131

132132
return 'Hifumi'
133133
})
134+
.get('/maybe-empty', () => 'test', {
135+
query: t.MaybeEmpty(t.Object({ alias: t.String() })),
136+
headers: t.MaybeEmpty(t.Object({ username: t.String() }))
137+
})
138+
.get('/unknown-or-obj', () => 'test', {
139+
query: t.Unknown(),
140+
headers: t.Object({}),
141+
})
142+
.get('/partial-or-optional', () => 'test', {
143+
query: t.Partial(t.Object({ alias: t.String() })),
144+
headers: t.Optional(t.Object({ username: t.String() })),
145+
})
134146
.use(plugin)
135147

136148
const api = treaty(app)
@@ -1158,4 +1170,55 @@ type ValidationError = {
11581170

11591171
expectTypeOf(data).toEqualTypeOf<unknown>()
11601172
expectTypeOf(error).toEqualTypeOf<{ status: 300; value: "yay"; } | null>()
1161-
}
1173+
}
1174+
1175+
// Handle maybe empty query and headers
1176+
{
1177+
type Route = api['maybe-empty']['get']
1178+
type RouteOptions = Parameters<Route>[0]
1179+
1180+
expectTypeOf<RouteOptions>().toBeNullable()
1181+
1182+
type Query = NonNullable<RouteOptions>['query']
1183+
type Headers = NonNullable<RouteOptions>['headers']
1184+
1185+
expectTypeOf<Query>().toBeNullable()
1186+
expectTypeOf<Headers>().toBeNullable()
1187+
1188+
expectTypeOf<NonNullable<Query>>().toEqualTypeOf<{ alias: string }>()
1189+
expectTypeOf<NonNullable<Headers>>().toEqualTypeOf<{ username: string }>()
1190+
}
1191+
1192+
// Handle unknown and empty object query and headers
1193+
{
1194+
type Route = api['unknown-or-obj']['get']
1195+
type RouteOptions = Parameters<Route>[0]
1196+
1197+
expectTypeOf<RouteOptions>().toBeNullable()
1198+
1199+
type Query = NonNullable<RouteOptions>['query']
1200+
type Headers = NonNullable<RouteOptions>['headers']
1201+
1202+
expectTypeOf<Query>().toBeNullable()
1203+
expectTypeOf<Headers>().toBeNullable()
1204+
1205+
expectTypeOf<NonNullable<Query>>().toEqualTypeOf<Record<string, unknown>>()
1206+
expectTypeOf<NonNullable<Headers>>().toEqualTypeOf<{}>()
1207+
}
1208+
1209+
// Handle partial and optional query and headers
1210+
{
1211+
type Route = api['partial-or-optional']['get']
1212+
type RouteOptions = Parameters<Route>[0]
1213+
1214+
expectTypeOf<RouteOptions>().toBeNullable()
1215+
1216+
type Query = NonNullable<RouteOptions>['query']
1217+
type Headers = NonNullable<RouteOptions>['headers']
1218+
1219+
expectTypeOf<Query>().toBeNullable()
1220+
expectTypeOf<Headers>().toBeNullable()
1221+
1222+
expectTypeOf<NonNullable<Query>>().toEqualTypeOf<{ alias?: string }>()
1223+
expectTypeOf<NonNullable<Headers>>().toEqualTypeOf<{ username?: string }>()
1224+
}

0 commit comments

Comments
 (0)