Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/treaty/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,29 @@ type Replace<RecordType, TargetType, GenericType> = {
type MaybeArray<T> = T | T[]

export namespace EdenTreaty {
type GetErrorResponse<App extends Elysia<any, any, any, any, any, any, any>> =
App extends Elysia<any, any, any, infer Metadata, any, infer Ephemeral, infer Volatile>
? (Metadata['response'] & Ephemeral['response'] & Volatile['response']) extends infer Res
? Exclude<keyof Res, 200> extends never
? Res[keyof Res]
: {
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Return never instead of the success response type when no custom errors are defined.

Hehe~ ♡ Did you really think this logic works, you silly baka? ╮(︶▽︶)╭
When a route only has a success response, your Exclude evaluates to never. But then you return Res[keyof Res], which evaluates to the success type!
This means your ErrorResponse will be typed as the success response when no custom errors are defined! You completely broke the default error typing! So embarrassing~ (≧◡≦) ♡

You need to return never here instead so it can fall back to the default unknown error types properly, okay?

  • src/treaty/types.ts#L21-L23: Return never instead of Res[keyof Res].
  • src/treaty2/types.ts#L87-L89: Return never instead of Res[keyof Res].
📍 Affects 2 files
  • src/treaty/types.ts#L21-L23 (this comment)
  • src/treaty2/types.ts#L87-L89
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/treaty/types.ts` around lines 21 - 23, Update the ErrorResponse
conditional type in src/treaty/types.ts lines 21-23 and src/treaty2/types.ts
lines 87-89 to return never when Exclude<keyof Res, 200> is never, instead of
Res[keyof Res]. Preserve the existing custom-error response branch so routes
without custom errors fall back to the default unknown error types.

[Status in keyof Res]: Res[Status]
}[Exclude<keyof Res, 200>]
: unknown
: unknown

export type Create<
App extends Elysia<any, any, any, any, any, any, any>
> = App extends {
'~Routes': infer Schema extends Record<string, unknown>
}
? Prettify<Sign<Schema>>
? Prettify<Sign<Schema, GetErrorResponse<App>>>
: 'Please install Elysia before using Eden'

export type Sign<Route extends Record<string, any>> = {
export type Sign<
Route extends Record<string, any>,
ErrorResponse = unknown
> = {
[K in keyof Route as K extends `:${string}`
? (string & {}) | number | K
: K extends '' | '/'
Expand Down Expand Up @@ -82,8 +96,12 @@ export namespace EdenTreaty {
error: Response extends Record<number, unknown>
? MapError<Response> extends infer Errors
? IsNever<Errors> extends true
? EdenFetchError<number, string>
: Errors
? IsNever<ErrorResponse> extends true
? EdenFetchError<number, string>
: EdenFetchError<number, ErrorResponse>
: Errors | (IsNever<ErrorResponse> extends true
? never
: EdenFetchError<number, ErrorResponse>)
: EdenFetchError<number, string>
: EdenFetchError<number, unknown>
}
Expand Down Expand Up @@ -121,7 +139,7 @@ export namespace EdenTreaty {
}
) => Response
: never
: Prettify<Sign<Route[K]>>
: Prettify<Sign<Route[K], ErrorResponse>>
}

type UnwrapPromise<T> = T extends Promise<infer A> ? A : T
Expand Down
79 changes: 57 additions & 22 deletions src/treaty2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ type SerializeQueryParams<T> =
: T

export namespace Treaty {
type GetErrorResponse<App extends Elysia<any, any, any, any, any, any, any>> =
App extends Elysia<any, any, any, infer Metadata, any, infer Ephemeral, infer Volatile>
? (Metadata['response'] & Ephemeral['response'] & Volatile['response']) extends infer Res
? Exclude<keyof Res, SuccessCodes> extends never
? Res[keyof Res]
: {
[Status in keyof Res]: Res[Status]
}[Exclude<keyof Res, SuccessCodes>]
: unknown
: unknown

export interface TreatyParam {
fetch?: RequestInit
throwHttpError?: ThrowHttpError
Expand All @@ -92,7 +103,7 @@ export namespace Treaty {
> = App extends {
'~Routes': infer Schema extends Record<any, any>
}
? Prettify<Sign<Schema, Head>> & CreateParams<Schema, Head>
? Prettify<Sign<Schema, Head, GetErrorResponse<App>>> & CreateParams<Schema, Head, GetErrorResponse<App>>
: 'Please install Elysia before using Eden'

type ToTreatyParam<Target, Head extends Record<string, unknown>> = Prettify<
Expand All @@ -110,7 +121,8 @@ export namespace Treaty {

export type Sign<
in out Route extends Record<any, any>,
in out Head extends Record<string, unknown> = {}
in out Head extends Record<string, unknown> = {},
ErrorResponse = unknown
> = {
[K in keyof Route as K extends `:${string}`
? never
Expand Down Expand Up @@ -141,23 +153,26 @@ export namespace Treaty {
options?: ToTreatyParam<Param, Head>
) => Promise<
TreatyResponse<
ReplaceGeneratorWithAsyncGenerator<Res>
ReplaceGeneratorWithAsyncGenerator<Res>,
ErrorResponse
>
>
: (
body?: RelaxFileArrays<Body>,
options?: ToTreatyParam<Param, Head>
) => Promise<
TreatyResponse<
ReplaceGeneratorWithAsyncGenerator<Res>
ReplaceGeneratorWithAsyncGenerator<Res>,
ErrorResponse
>
>
: K extends 'get' | 'head'
? (
options?: ToTreatyParam<Param, Head>
) => Promise<
TreatyResponse<
ReplaceGeneratorWithAsyncGenerator<Res>
ReplaceGeneratorWithAsyncGenerator<Res>,
ErrorResponse
>
>
: {} extends Body
Expand All @@ -169,7 +184,8 @@ export namespace Treaty {
>
) => Promise<
TreatyResponse<
ReplaceGeneratorWithAsyncGenerator<Res>
ReplaceGeneratorWithAsyncGenerator<Res>,
ErrorResponse
>
>
: (
Expand All @@ -180,38 +196,42 @@ export namespace Treaty {
>
) => Promise<
TreatyResponse<
ReplaceGeneratorWithAsyncGenerator<Res>
ReplaceGeneratorWithAsyncGenerator<Res>,
ErrorResponse
>
>
: K extends 'get' | 'head'
? (
options: ToTreatyParam<Param, Head>
) => Promise<
TreatyResponse<
ReplaceGeneratorWithAsyncGenerator<Res>
ReplaceGeneratorWithAsyncGenerator<Res>,
ErrorResponse
>
>
: (
body: RelaxFileArrays<Body>,
options: ToTreatyParam<Param, Head>
) => Promise<
TreatyResponse<
ReplaceGeneratorWithAsyncGenerator<Res>
ReplaceGeneratorWithAsyncGenerator<Res>,
ErrorResponse
>
>
: never
: CreateParams<Route[K], Head>) & {
: CreateParams<Route[K], Head, ErrorResponse>) & {
'~path': string
}
}

type CreateParams<
Route extends Record<string, any>,
Head extends Record<string, unknown> = {}
Head extends Record<string, unknown> = {},
ErrorResponse = unknown
> =
Extract<keyof Route, `:${string}`> extends infer Path extends string
? IsNever<Path> extends true
? Prettify<Sign<Route, Head>>
? Prettify<Sign<Route, Head, ErrorResponse>>
: // ! DO NOT USE PRETTIFY ON THIS LINE, OTHERWISE FUNCTION CALLING WILL BE OMITTED
(((params: {
[param in Path extends `:${infer Param}`
Expand All @@ -220,14 +240,14 @@ export namespace Treaty {
: Param
: never]: string | number
}) => Prettify<
Sign<Route[Path], Head> & {
Sign<Route[Path], Head, ErrorResponse> & {
'~path': string
}
> &
CreateParams<Route[Path], Head>) &
Prettify<Sign<Route, Head>>) &
CreateParams<Route[Path], Head, ErrorResponse>) &
Prettify<Sign<Route, Head, ErrorResponse>>) &
(Path extends `:${string}?`
? CreateParams<Route[Path], Head>
? CreateParams<Route[Path], Head, ErrorResponse>
: {})
: never

Expand Down Expand Up @@ -266,7 +286,10 @@ export namespace Treaty {
// [K in keyof T]: Awaited<T[K]>
// }

export type TreatyResponse<Res extends Record<number, unknown>> =
export type TreatyResponse<
Res extends Record<number, unknown>,
ErrorResponse = unknown
> =
| {
data: Res[Extract<keyof Res, SuccessCodes>] extends {
[ELYSIA_FORM_DATA]: infer Data
Expand All @@ -281,11 +304,17 @@ export namespace Treaty {
| {
data: null
error: Exclude<keyof Res, SuccessCodes> extends never
? {
status: unknown
value: unknown
}
: {
? IsNever<ErrorResponse> extends true
? {
status: unknown
value: unknown
}
: {
status: unknown
value: ErrorResponse
}
: (
{
[Status in keyof Res]: {
status: Status
value: Res[Status] extends {
Expand All @@ -295,6 +324,12 @@ export namespace Treaty {
: Res[Status]
}
}[Exclude<keyof Res, SuccessCodes>]
) | (IsNever<ErrorResponse> extends true
? never
: {
status: unknown
value: ErrorResponse
})
response: Response
status: number
headers: ResponseInit['headers']
Expand Down
27 changes: 26 additions & 1 deletion test/treaty2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,7 +1448,32 @@ describe('Treaty2 - parseDate configuration', () => {
it('should NOT parse date in text response when parseDate is false', async () => {
const client = treaty(dateApp, { parseDate: false })
const { data } = await client['text-date'].get()

expect(data).toBe('2024-01-15T10:30:00.000Z')
})
})

describe('Treaty2 - custom error from onError', () => {
it('should return type-safe custom error bodies from onError handler', async () => {
const app = new Elysia()
.onError(({ code, error, set }) => {
set.status = 500
return {
customError: error.message,
code
}
})
.get('/', () => {
throw new Error('Something went wrong!')
})

const client = treaty(app)
const { data, error } = await client.get()

expect(data).toBeNull()
expect(error?.status).toBe(500)
expect(error?.value).toEqual({
customError: 'Something went wrong!',
code: 'UNKNOWN'
})
})
})
28 changes: 27 additions & 1 deletion test/types/treaty2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Elysia, file, form, status, t } from 'elysia'
import { treaty } from '../../src'
import { treaty, edenTreaty } from '../../src'
import { expectTypeOf } from 'expect-type'
import type { ThrowHttpError } from '../../src/types'

Expand Down Expand Up @@ -1416,3 +1416,29 @@ type ValidationError = {
expectTypeOf(api.id({ id: 1 })['~path']).toEqualTypeOf<string>()
expectTypeOf(api.nested.q['~path']).toEqualTypeOf<string>()
}

// ? Custom error from onError hook type inference
{
const app = new Elysia()
.onError(({ code, error }) => {
return {
customError: error instanceof Error ? error.message : 'no message',
code
}
})
.get('/', () => 'hello')

const client2 = treaty(app)
type Err2 = NonNullable<Result<typeof client2.get>['error']>
expectTypeOf<Err2['value']>().toEqualTypeOf<{
customError: string
code: number | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "PARSE" | "INVALID_COOKIE_SIGNATURE" | "INVALID_FILE_TYPE" | "VALIDATION" | "UNKNOWN"
}>()

const client1 = edenTreaty<typeof app>('http://localhost')
type Err1 = NonNullable<Result<typeof client1.get>['error']>
expectTypeOf<Err1['value']>().toEqualTypeOf<{
customError: string
code: number | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "PARSE" | "INVALID_COOKIE_SIGNATURE" | "INVALID_FILE_TYPE" | "VALIDATION" | "UNKNOWN"
}>()
}