diff --git a/src/treaty2/types.ts b/src/treaty2/types.ts index 0307f76..9e7766b 100644 --- a/src/treaty2/types.ts +++ b/src/treaty2/types.ts @@ -2,7 +2,7 @@ import type { Elysia, ELYSIA_FORM_DATA } from 'elysia' import { EdenWS } from './ws' -import type { IsNever, Not, Prettify } from '../types' +import type { IsNever, MaybeEmptyObject, Not, Prettify } from '../types' // type Files = File | FileList @@ -67,45 +67,19 @@ export namespace Treaty { [K in keyof Route as K extends `:${string}` ? never : K]: K extends 'subscribe' // ? Websocket route - ? ({} extends Route['subscribe']['headers'] - ? { headers?: Record } - : undefined extends Route['subscribe']['headers'] - ? { headers?: Record } - : { - headers: Route['subscribe']['headers'] - }) & - ({} extends Route['subscribe']['query'] - ? { query?: Record } - : undefined extends Route['subscribe']['query'] - ? { query?: Record } - : { - query: Route['subscribe']['query'] - }) extends infer Param + ? MaybeEmptyObject & + MaybeEmptyObject extends infer Param ? (options?: Param) => EdenWS : never : Route[K] extends { - body: infer Body - headers: infer Headers - params: any - query: infer Query - response: infer Res extends Record - } - ? ({} extends Headers - ? { - headers?: Record - } - : undefined extends Headers - ? { headers?: Record } - : { - headers: Headers - }) & - ({} extends Query - ? { - query?: Record - } - : undefined extends Query - ? { query?: Record } - : { query: Query }) extends infer Param + body: infer Body + headers: infer Headers + params: any + query: infer Query + response: infer Res extends Record + } + ? MaybeEmptyObject & + MaybeEmptyObject extends infer Param ? {} extends Param ? undefined extends Body ? K extends 'get' | 'head' diff --git a/src/types.ts b/src/types.ts index 377a6fa..2011a9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,36 @@ export type IsUnknown = IsAny extends true ? true : false +type IsExactlyUnknown = [T] extends [unknown] + ? [unknown] extends [T] + ? true + : false + : false; + +type IsUndefined = [T] extends [undefined] ? true : false + +type IsMatchingEmptyObject = [T] extends [{}] + ? [{}] extends [T] + ? true + : false + : false + +export type MaybeEmptyObject< + TObj, + TKey extends PropertyKey, + TFallback = Record +> = IsUndefined extends true + ? { [K in TKey]?: TFallback } + : IsExactlyUnknown extends true + ? { [K in TKey]?: TFallback } + : IsMatchingEmptyObject extends true + ? { [K in TKey]?: TObj } + : undefined extends TObj + ? { [K in TKey]?: TObj } + : null extends TObj + ? { [K in TKey]?: TObj } + : { [K in TKey]: TObj } + export type AnyTypedRoute = { body?: unknown headers?: unknown diff --git a/test/treaty2.test.ts b/test/treaty2.test.ts index dfef509..4475c6b 100644 --- a/test/treaty2.test.ts +++ b/test/treaty2.test.ts @@ -1,7 +1,7 @@ import { Elysia, form, sse, t } from 'elysia' import { Treaty, treaty } from '../src' -import { describe, expect, it, beforeAll, afterAll, mock } from 'bun:test' +import { describe, expect, it, beforeAll, afterAll, mock, test } from 'bun:test' const randomObject = { a: 'a', @@ -92,6 +92,44 @@ const app = new Elysia() alias: t.Literal('Kristen') }) }) + .group('/empty-test', (g) => g + .get('/with-maybe-empty', ({ query, headers }) => ({ query, headers }), { + query: t.MaybeEmpty(t.Object({ alias: t.String() })), + headers: t.MaybeEmpty(t.Object({ username: t.String() })) + }) + .get('/with-unknown', ({ query, headers }) => ({ query, headers }), { + query: t.Unknown(), + headers: t.Unknown(), + }) + .get('/with-empty-record', ({ query, headers }) => ({ query, headers }), { + query: t.Record(t.String(), t.Never()), + headers: t.Record(t.String(), t.Never()), + }) + .get('/with-empty-obj', ({ query, headers }) => ({ query, headers }), { + query: t.Object({}), + headers: t.Object({}), + }) + .get('/with-partial', ({ query, headers }) => ({ query, headers }), { + query: t.Partial(t.Object({ alias: t.String() })), + headers: t.Partial(t.Object({ username: t.String() })), + }) + .get('/with-optional', ({ query, headers }) => ({ query, headers }), { + query: t.Optional(t.Object({ alias: t.String() })), + headers: t.Optional(t.Object({ username: t.String() })), + }) + .get('/with-union-undefined', ({ query, headers }) => ({ query, headers }), { + query: t.Union([t.Object({ alias: t.String() }), t.Undefined()]), + headers: t.Union([t.Object({ username: t.String() }), t.Undefined()]) + }) + .get('/with-union-empty-obj', ({ query, headers }) => ({ query, headers }), { + query: t.Union([t.Object({ alias: t.String() }), t.Object({})]), + headers: t.Union([t.Object({ username: t.String() }), t.Object({})]), + }) + .get('/with-union-empty-record', ({ query, headers }) => ({ query, headers }), { + query: t.Union([t.Object({ alias: t.String() }), t.Record(t.String(), t.Never())]), + headers: t.Union([t.Object({ username: t.String() }), t.Record(t.String(), t.Never())]), + }) + ) .post('/queries', ({ query }) => query, { query: t.Object({ username: t.String(), @@ -354,6 +392,22 @@ describe('Treaty2', () => { expect(error?.value.type).toBe('validation') }) + test.each([ + 'with-empty-obj', + 'with-partial', + 'with-unknown', + 'with-empty-record', + 'with-union-empty-obj', + 'with-union-empty-record', + // 'with-maybe-empty', + // 'with-optional', + // 'with-union-undefined', + ] as const)('type test for case: %s', async (caseName) => { + const { data, error } = await client['empty-test'][caseName].get() + expect(error, JSON.stringify(error, null, 2)).toBeNull() + expect(data).toEqual({ query: {}, headers: {} }) + }) + it('post queries', async () => { const query = { username: 'A', alias: 'Kristen' } as const diff --git a/test/types/treaty2.ts b/test/types/treaty2.ts index a3d331e..b834111 100644 --- a/test/types/treaty2.ts +++ b/test/types/treaty2.ts @@ -131,6 +131,18 @@ const app = new Elysia() return 'Hifumi' }) + .get('/maybe-empty', () => 'test', { + query: t.MaybeEmpty(t.Object({ alias: t.String() })), + headers: t.MaybeEmpty(t.Object({ username: t.String() })) + }) + .get('/unknown-or-obj', () => 'test', { + query: t.Unknown(), + headers: t.Object({}), + }) + .get('/partial-or-optional', () => 'test', { + query: t.Partial(t.Object({ alias: t.String() })), + headers: t.Optional(t.Object({ username: t.String() })), + }) .use(plugin) const api = treaty(app) @@ -1158,4 +1170,55 @@ type ValidationError = { expectTypeOf(data).toEqualTypeOf() expectTypeOf(error).toEqualTypeOf<{ status: 300; value: "yay"; } | null>() -} \ No newline at end of file +} + +// Handle maybe empty query and headers +{ + type Route = api['maybe-empty']['get'] + type RouteOptions = Parameters[0] + + expectTypeOf().toBeNullable() + + type Query = NonNullable['query'] + type Headers = NonNullable['headers'] + + expectTypeOf().toBeNullable() + expectTypeOf().toBeNullable() + + expectTypeOf>().toEqualTypeOf<{ alias: string }>() + expectTypeOf>().toEqualTypeOf<{ username: string }>() +} + +// Handle unknown and empty object query and headers +{ + type Route = api['unknown-or-obj']['get'] + type RouteOptions = Parameters[0] + + expectTypeOf().toBeNullable() + + type Query = NonNullable['query'] + type Headers = NonNullable['headers'] + + expectTypeOf().toBeNullable() + expectTypeOf().toBeNullable() + + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf<{}>() +} + +// Handle partial and optional query and headers +{ + type Route = api['partial-or-optional']['get'] + type RouteOptions = Parameters[0] + + expectTypeOf().toBeNullable() + + type Query = NonNullable['query'] + type Headers = NonNullable['headers'] + + expectTypeOf().toBeNullable() + expectTypeOf().toBeNullable() + + expectTypeOf>().toEqualTypeOf<{ alias?: string }>() + expectTypeOf>().toEqualTypeOf<{ username?: string }>() +}