From aa26bf9895e4f642f9f0a552d11b2b2430d9554a Mon Sep 17 00:00:00 2001 From: Rene Date: Wed, 15 Jul 2026 21:03:00 +0200 Subject: [PATCH] fix(eden): preserve and infer custom JSON error shape in Eden Treaty --- src/treaty/types.ts | 28 ++++++++++++--- src/treaty2/types.ts | 79 +++++++++++++++++++++++++++++++------------ test/treaty2.test.ts | 27 ++++++++++++++- test/types/treaty2.ts | 28 ++++++++++++++- 4 files changed, 133 insertions(+), 29 deletions(-) diff --git a/src/treaty/types.ts b/src/treaty/types.ts index 678c3c3..893ab7d 100644 --- a/src/treaty/types.ts +++ b/src/treaty/types.ts @@ -15,15 +15,29 @@ type Replace = { type MaybeArray = T | T[] export namespace EdenTreaty { + type GetErrorResponse> = + App extends Elysia + ? (Metadata['response'] & Ephemeral['response'] & Volatile['response']) extends infer Res + ? Exclude extends never + ? Res[keyof Res] + : { + [Status in keyof Res]: Res[Status] + }[Exclude] + : unknown + : unknown + export type Create< App extends Elysia > = App extends { '~Routes': infer Schema extends Record } - ? Prettify> + ? Prettify>> : 'Please install Elysia before using Eden' - export type Sign> = { + export type Sign< + Route extends Record, + ErrorResponse = unknown + > = { [K in keyof Route as K extends `:${string}` ? (string & {}) | number | K : K extends '' | '/' @@ -82,8 +96,12 @@ export namespace EdenTreaty { error: Response extends Record ? MapError extends infer Errors ? IsNever extends true - ? EdenFetchError - : Errors + ? IsNever extends true + ? EdenFetchError + : EdenFetchError + : Errors | (IsNever extends true + ? never + : EdenFetchError) : EdenFetchError : EdenFetchError } @@ -121,7 +139,7 @@ export namespace EdenTreaty { } ) => Response : never - : Prettify> + : Prettify> } type UnwrapPromise = T extends Promise ? A : T diff --git a/src/treaty2/types.ts b/src/treaty2/types.ts index b7fe1a6..8a13b11 100644 --- a/src/treaty2/types.ts +++ b/src/treaty2/types.ts @@ -81,6 +81,17 @@ type SerializeQueryParams = : T export namespace Treaty { + type GetErrorResponse> = + App extends Elysia + ? (Metadata['response'] & Ephemeral['response'] & Volatile['response']) extends infer Res + ? Exclude extends never + ? Res[keyof Res] + : { + [Status in keyof Res]: Res[Status] + }[Exclude] + : unknown + : unknown + export interface TreatyParam { fetch?: RequestInit throwHttpError?: ThrowHttpError @@ -92,7 +103,7 @@ export namespace Treaty { > = App extends { '~Routes': infer Schema extends Record } - ? Prettify> & CreateParams + ? Prettify>> & CreateParams> : 'Please install Elysia before using Eden' type ToTreatyParam> = Prettify< @@ -110,7 +121,8 @@ export namespace Treaty { export type Sign< in out Route extends Record, - in out Head extends Record = {} + in out Head extends Record = {}, + ErrorResponse = unknown > = { [K in keyof Route as K extends `:${string}` ? never @@ -141,7 +153,8 @@ export namespace Treaty { options?: ToTreatyParam ) => Promise< TreatyResponse< - ReplaceGeneratorWithAsyncGenerator + ReplaceGeneratorWithAsyncGenerator, + ErrorResponse > > : ( @@ -149,7 +162,8 @@ export namespace Treaty { options?: ToTreatyParam ) => Promise< TreatyResponse< - ReplaceGeneratorWithAsyncGenerator + ReplaceGeneratorWithAsyncGenerator, + ErrorResponse > > : K extends 'get' | 'head' @@ -157,7 +171,8 @@ export namespace Treaty { options?: ToTreatyParam ) => Promise< TreatyResponse< - ReplaceGeneratorWithAsyncGenerator + ReplaceGeneratorWithAsyncGenerator, + ErrorResponse > > : {} extends Body @@ -169,7 +184,8 @@ export namespace Treaty { > ) => Promise< TreatyResponse< - ReplaceGeneratorWithAsyncGenerator + ReplaceGeneratorWithAsyncGenerator, + ErrorResponse > > : ( @@ -180,7 +196,8 @@ export namespace Treaty { > ) => Promise< TreatyResponse< - ReplaceGeneratorWithAsyncGenerator + ReplaceGeneratorWithAsyncGenerator, + ErrorResponse > > : K extends 'get' | 'head' @@ -188,7 +205,8 @@ export namespace Treaty { options: ToTreatyParam ) => Promise< TreatyResponse< - ReplaceGeneratorWithAsyncGenerator + ReplaceGeneratorWithAsyncGenerator, + ErrorResponse > > : ( @@ -196,22 +214,24 @@ export namespace Treaty { options: ToTreatyParam ) => Promise< TreatyResponse< - ReplaceGeneratorWithAsyncGenerator + ReplaceGeneratorWithAsyncGenerator, + ErrorResponse > > : never - : CreateParams) & { + : CreateParams) & { '~path': string } } type CreateParams< Route extends Record, - Head extends Record = {} + Head extends Record = {}, + ErrorResponse = unknown > = Extract extends infer Path extends string ? IsNever extends true - ? Prettify> + ? Prettify> : // ! DO NOT USE PRETTIFY ON THIS LINE, OTHERWISE FUNCTION CALLING WILL BE OMITTED (((params: { [param in Path extends `:${infer Param}` @@ -220,14 +240,14 @@ export namespace Treaty { : Param : never]: string | number }) => Prettify< - Sign & { + Sign & { '~path': string } > & - CreateParams) & - Prettify>) & + CreateParams) & + Prettify>) & (Path extends `:${string}?` - ? CreateParams + ? CreateParams : {}) : never @@ -266,7 +286,10 @@ export namespace Treaty { // [K in keyof T]: Awaited // } - export type TreatyResponse> = + export type TreatyResponse< + Res extends Record, + ErrorResponse = unknown + > = | { data: Res[Extract] extends { [ELYSIA_FORM_DATA]: infer Data @@ -281,11 +304,17 @@ export namespace Treaty { | { data: null error: Exclude extends never - ? { - status: unknown - value: unknown - } - : { + ? IsNever extends true + ? { + status: unknown + value: unknown + } + : { + status: unknown + value: ErrorResponse + } + : ( + { [Status in keyof Res]: { status: Status value: Res[Status] extends { @@ -295,6 +324,12 @@ export namespace Treaty { : Res[Status] } }[Exclude] + ) | (IsNever extends true + ? never + : { + status: unknown + value: ErrorResponse + }) response: Response status: number headers: ResponseInit['headers'] diff --git a/test/treaty2.test.ts b/test/treaty2.test.ts index ed4503a..65f012f 100644 --- a/test/treaty2.test.ts +++ b/test/treaty2.test.ts @@ -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' + }) + }) +}) diff --git a/test/types/treaty2.ts b/test/types/treaty2.ts index 52d890e..1d0cf4f 100644 --- a/test/types/treaty2.ts +++ b/test/types/treaty2.ts @@ -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' @@ -1416,3 +1416,29 @@ type ValidationError = { expectTypeOf(api.id({ id: 1 })['~path']).toEqualTypeOf() expectTypeOf(api.nested.q['~path']).toEqualTypeOf() } + +// ? 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['error']> + expectTypeOf().toEqualTypeOf<{ + customError: string + code: number | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "PARSE" | "INVALID_COOKIE_SIGNATURE" | "INVALID_FILE_TYPE" | "VALIDATION" | "UNKNOWN" + }>() + + const client1 = edenTreaty('http://localhost') + type Err1 = NonNullable['error']> + expectTypeOf().toEqualTypeOf<{ + customError: string + code: number | "INTERNAL_SERVER_ERROR" | "NOT_FOUND" | "PARSE" | "INVALID_COOKIE_SIGNATURE" | "INVALID_FILE_TYPE" | "VALIDATION" | "UNKNOWN" + }>() +}