diff --git a/src/index.ts b/src/index.ts index 3e2092c56..5183a4e99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,7 @@ import type { MergeSchema, RouteSchema, UnwrapRoute, + UnwrapRouteInput, InternalRoute, HTTPMethod, SchemaValidator, @@ -138,6 +139,7 @@ import type { MergeElysiaInstances, Macro, MacroToContext, + MacroToContextInput, StandaloneValidator, GuardSchemaType, Or, @@ -5707,6 +5709,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -5768,7 +5786,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -5816,6 +5842,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -5877,7 +5919,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -5925,6 +5975,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -5986,7 +6052,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6034,6 +6108,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -6093,7 +6183,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6141,6 +6239,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -6200,7 +6314,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6248,6 +6370,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -6307,7 +6445,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6355,6 +6501,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -6414,7 +6576,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6462,6 +6632,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -6521,7 +6707,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6569,6 +6763,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -6628,7 +6838,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6677,6 +6895,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const Decorator extends Singleton & { derive: Ephemeral['derive'] & Volatile['derive'] resolve: Ephemeral['resolve'] & Volatile['resolve'] @@ -6742,7 +6976,15 @@ export default class Elysia< > > > - > + >, + SchemaInput, + {} extends Metadata['macroFn'] + ? {} + : MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] + > > } >, @@ -6791,6 +7033,22 @@ export default class Elysia< Ephemeral['standaloneSchema'] & Volatile['standaloneSchema'] >, + const SchemaInput extends IntersectIfObjectSchema< + MergeSchema< + UnwrapRouteInput< + Input, + Definitions['typebox'], + JoinPath + >, + MergeSchema< + Volatile['schema'], + MergeSchema + > + >, + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, const MacroContext extends MacroToContext< Metadata['macroFn'], Omit @@ -6843,6 +7101,12 @@ export default class Elysia< > > > + >, + SchemaInput, + MacroToContextInput< + Metadata['macroFn'], + Omit, + Definitions['typebox'] > > } @@ -8252,6 +8516,7 @@ export type { MergeSchema, RouteSchema, UnwrapRoute, + UnwrapRouteInput, InternalRoute, HTTPMethod, SchemaValidator, @@ -8266,6 +8531,7 @@ export type { LifeCycleType, MaybePromise, UnwrapSchema, + UnwrapSchemaInput, Checksum, DocumentDecoration, InferContext, @@ -8275,11 +8541,13 @@ export type { BaseMacro, MacroManager, MacroToProperty, + MacroToContextInput, MergeElysiaInstances, MaybeArray, ModelValidator, MetadataBase, UnwrapBodySchema, + UnwrapBodySchemaInput, UnwrapGroupGuardRoute, ModelValidatorError, ExcludeElysiaResponse, diff --git a/src/types.ts b/src/types.ts index 30e3b35fa..d915960dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -494,6 +494,55 @@ export type UnwrapSchema< : unknown : unknown +/** + * Resolves a schema to its **input** (pre-transform) TypeScript type. + * + * For Standard Schema (Zod, Valibot, etc.) this uses `['input']` instead of + * `['output']`, giving the raw shape *before* any `.transform()` / coercion. + * + * TypeBox schemas resolve identically to `UnwrapSchema` because Elysia casts + * transform types (e.g. `t.Numeric()`) back to their base TypeBox type. + */ +export type UnwrapSchemaInput< + Schema extends AnySchema | string | undefined, + Definitions extends DefinitionBase['typebox'] = {} +> = Schema extends undefined + ? unknown + : Schema extends TSchema + ? Schema extends OptionalField + ? Partial< + TImport< + // @ts-expect-error Internal typebox already filter for TSchema + Definitions & { + readonly __elysia: Schema + }, + '__elysia' + >['static'] + > + : TImport< + // @ts-expect-error Internal typebox already filter for TSchema + Definitions & { + readonly __elysia: Schema + }, + '__elysia' + >['static'] + : Schema extends FastStandardSchemaV1Like + ? // @ts-ignore Schema is StandardSchemaV1Like + NonNullable['input'] + : Schema extends string + ? Schema extends keyof Definitions + ? Definitions[Schema] extends TAnySchema + ? TImport< + // @ts-expect-error Internal typebox already filter for TSchema + Definitions, + Schema + >['static'] + : NonNullable< + Definitions[Schema]['~standard']['types'] + >['input'] + : unknown + : unknown + export type UnwrapBodySchema< Schema extends AnySchema | string | undefined, Definitions extends DefinitionBase['typebox'] = {} @@ -535,6 +584,50 @@ export type UnwrapBodySchema< : unknown : unknown +/** + * Like `UnwrapBodySchema` but resolves to the **input** (pre-transform) type. + */ +export type UnwrapBodySchemaInput< + Schema extends AnySchema | string | undefined, + Definitions extends DefinitionBase['typebox'] = {} +> = undefined extends Schema + ? unknown + : Schema extends TSchema + ? Schema extends OptionalField + ? Partial< + TImport< + // @ts-expect-error Internal typebox already filter for TSchema + Definitions & { + readonly __elysia: Schema + }, + '__elysia' + >['static'] + > | null + : TImport< + // @ts-expect-error Internal typebox already filter for TSchema + Definitions & { + readonly __elysia: Schema + }, + '__elysia' + >['static'] + : Schema extends FastStandardSchemaV1Like + ? // @ts-ignore Schema is StandardSchemaV1Like + NonNullable['input'] + : Schema extends string + ? Schema extends keyof Definitions + ? Definitions[Schema] extends TAnySchema + ? TImport< + // @ts-expect-error Internal typebox already filter for TSchema + Definitions, + Schema + >['static'] + : // @ts-ignore Schema is StandardSchemaV1Like + NonNullable< + Definitions[Schema]['~standard']['types'] + >['input'] + : unknown + : unknown + export interface UnwrapRoute< in out Schema extends InputSchema, in out Definitions extends DefinitionBase['typebox'] = {}, @@ -576,6 +669,56 @@ export interface UnwrapRoute< : unknown | void } +/** + * Like `UnwrapRoute` but resolves body, headers, query, and params to their + * **input** (pre-transform) types. Response stays as output because the + * response type describes what the server *sends back*, not what the client + * submits. + * + * Used by `CreateEdenResponse` to expose input types for Eden Treaty so + * clients can construct request payloads with the correct pre-transform shape. + */ +export interface UnwrapRouteInput< + in out Schema extends InputSchema, + in out Definitions extends DefinitionBase['typebox'] = {}, + in out Path extends string = '' +> { + body: UnwrapBodySchemaInput + headers: UnwrapSchemaInput + query: UnwrapSchemaInput + params: {} extends Schema['params'] + ? ResolvePath + : {} extends Schema + ? ResolvePath + : UnwrapSchemaInput + cookie: UnwrapSchemaInput + response: Schema['response'] extends FastAnySchema | string + ? { + 200: UnwrapSchema< + Schema['response'], + Definitions + > extends infer A + ? A extends File + ? File | ElysiaFile + : A + : unknown + } + : Schema['response'] extends { + [status in number]: FastAnySchema | string + } + ? { + [k in keyof Schema['response']]: UnwrapSchema< + Schema['response'][k], + Definitions + > extends infer A + ? A extends File + ? File | ElysiaFile + : A + : unknown + } + : unknown | void +} + export interface UnwrapGroupGuardRoute< in out Schema extends InputSchema, in out Definitions extends DefinitionBase['typebox'] = {}, @@ -1109,6 +1252,103 @@ type UnwrapMacroSchema< Definitions > +/** + * Like `UnwrapMacroSchema` but resolves to **input** (pre-transform) types. + */ +type UnwrapMacroSchemaInput< + T extends Partial>, + Definitions extends DefinitionBase['typebox'] = {} +> = UnwrapRouteInput< + { + body: 'body' extends keyof T ? T['body'] : undefined + headers: 'headers' extends keyof T ? T['headers'] : undefined + query: 'query' extends keyof T ? T['query'] : undefined + params: 'params' extends keyof T ? T['params'] : undefined + cookie: 'cookie' extends keyof T ? T['cookie'] : undefined + response: 'response' extends keyof T ? T['response'] : undefined + }, + Definitions +> + +/** + * Like `MacroToContext` but resolves schema fields to their **input** + * (pre-transform) types via `UnwrapMacroSchemaInput`. + * + * Used to correctly expose macro-contributed schemas in + * `CreateEdenResponse['input']` so Eden Treaty clients see the + * pre-transform shape. + */ +export type MacroToContextInput< + in out MacroFn extends Macro = {}, + in out SelectedMacro extends BaseMacro = {}, + in out Definitions extends DefinitionBase['typebox'] = {}, + in out R extends 1[] = [] +> = Prettify< + {} extends SelectedMacro + ? {} + : R['length'] extends 15 + ? {} + : UnionToIntersect< + { + [key in keyof SelectedMacro]: ReturnTypeIfPossible< + MacroFn[key], + SelectedMacro[key] + > extends infer Value + ? { + resolve: ExtractResolveFromMacro< + Extract< + Exclude< + FunctionArrayReturnType< + // @ts-ignore Trust me bro + Value['resolve'] + >, + AnyElysiaCustomStatusResponse + >, + Record + > + > + } & UnwrapMacroSchemaInput< + // @ts-ignore Trust me bro + Value, + Definitions + > & + ExtractAllResponseFromMacro< + FunctionArrayReturnTypeNonNullable< + // @ts-expect-error type is checked in key mapping + Value['beforeHandle'] + > + > & + ExtractAllResponseFromMacro< + FunctionArrayReturnTypeNonNullable< + // @ts-expect-error type is checked in key mapping + Value['afterHandle'] + > + > & + ExtractAllResponseFromMacro< + // @ts-expect-error type is checked in key mapping + FunctionArrayReturnType + > & + ExtractOnlyResponseFromMacro< + FunctionArrayReturnTypeNonNullable< + // @ts-expect-error type is checked in key mapping + Value['resolve'] + > + > & + MacroToContextInput< + MacroFn, + // @ts-ignore trust me bro + Pick< + Value, + Extract + >, + Definitions, + [...R, 1] + > + : {} + }[keyof SelectedMacro] + > +> + export type SimplifyToSchema> = IsUnknown extends false ? _SimplifyToSchema @@ -2626,7 +2866,29 @@ export type CreateEdenResponse< Schema extends RouteSchema, MacroContext extends RouteSchema, // This should be handled by ComposeElysiaResponse - Res extends PossibleResponse + Res extends PossibleResponse, + /** + * Pre-transform (input) schema types. + * + * When a Standard Schema (Zod / Valibot / …) defines `.transform()`, + * the **input** type differs from the **output** type. + * Eden Treaty can read `input.body`, `input.query`, etc. to know the + * shape a client should *send*, while existing `body`, `query`, etc. + * remain the post-transform shape the handler receives. + * + * Defaults to `Schema` for backward compatibility (input === output + * when no transforms are involved or TypeBox is used). + */ + SchemaInput extends RouteSchema = Schema, + /** + * Pre-transform (input) macro context types. + * + * When a macro contributes a Standard Schema with `.transform()`, + * `MacroContextInput` preserves the pre-transform shape for Eden + * Treaty clients. Defaults to `MacroContext` for backward + * compatibility. + */ + MacroContextInput extends RouteSchema = MacroContext > = RouteSchema extends MacroContext ? { body: Schema['body'] @@ -2636,6 +2898,14 @@ export type CreateEdenResponse< query: Schema['query'] headers: Schema['headers'] response: Prettify + input: { + body: SchemaInput['body'] + params: IsNever extends true + ? ResolvePath + : SchemaInput['params'] + query: SchemaInput['query'] + headers: SchemaInput['headers'] + } } : { body: Prettify @@ -2647,6 +2917,16 @@ export type CreateEdenResponse< query: Prettify headers: Prettify response: Prettify + input: { + body: Prettify + params: IsNever< + keyof (SchemaInput['params'] & MacroContextInput['params']) + > extends true + ? ResolvePath + : Prettify + query: Prettify + headers: Prettify + } } export interface Router { diff --git a/test/core/input-output-runtime.test.ts b/test/core/input-output-runtime.test.ts new file mode 100644 index 000000000..6a692b53d --- /dev/null +++ b/test/core/input-output-runtime.test.ts @@ -0,0 +1,258 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * Runtime verification that type-level input/output separation + * matches actual runtime parsing behavior. + * + * These tests start a real Elysia server and verify that: + * 1. Transformed schemas parse correctly at runtime + * 2. The handler receives the transformed (output) values + * 3. The client sends the raw (input) values + * + * This ensures our type-level changes are not just cosmetic + * but reflect real Elysia behavior. + */ + +import { describe, it, expect } from 'bun:test' +import { Elysia, t } from '../../src' +import z from 'zod' + +describe('Input/Output type separation - Runtime verification', () => { + it('Zod transform: handler receives transformed body', async () => { + const app = new Elysia().post('/transform', ({ body }) => { + // At runtime, body.createdAt should be a Date + return { + nameType: typeof body.name, + createdAtType: body.createdAt instanceof Date ? 'Date' : typeof body.createdAt, + createdAtValue: body.createdAt instanceof Date ? body.createdAt.toISOString() : String(body.createdAt) + } + }, { + body: z.object({ + name: z.string(), + createdAt: z.string().transform((s) => new Date(s)) + }) + }) + + const response = await app.handle( + new Request('http://localhost/transform', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'test', + createdAt: '2024-01-15T00:00:00.000Z' + }) + }) + ) + + const result = await response.json() + expect(result.nameType).toBe('string') + expect(result.createdAtType).toBe('Date') + expect(result.createdAtValue).toBe('2024-01-15T00:00:00.000Z') + }) + + it('Zod transform: handler receives transformed query', async () => { + const app = new Elysia().get('/query-transform', ({ query }) => { + return { + pageType: typeof query.page, + pageValue: query.page, + activeType: typeof query.active, + activeValue: query.active + } + }, { + query: z.object({ + page: z.string().transform(Number), + active: z.string().transform((v) => v === 'true') + }) + }) + + const response = await app.handle( + new Request('http://localhost/query-transform?page=5&active=true') + ) + + const result = await response.json() + expect(result.pageType).toBe('number') + expect(result.pageValue).toBe(5) + expect(result.activeType).toBe('boolean') + expect(result.activeValue).toBe(true) + }) + + it('Zod coerce: handler receives coerced values', async () => { + const app = new Elysia().post('/coerce', ({ body }) => { + return { + countType: typeof body.count, + countValue: body.count + } + }, { + body: z.object({ + count: z.coerce.number() + }) + }) + + const response = await app.handle( + new Request('http://localhost/coerce', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count: '42' }) + }) + ) + + const result = await response.json() + expect(result.countType).toBe('number') + expect(result.countValue).toBe(42) + }) + + it('Zod default: handler receives default value when omitted', async () => { + const app = new Elysia().post('/default', ({ body }) => { + return { + nameValue: body.name, + countValue: body.count + } + }, { + body: z.object({ + name: z.string().default('anonymous'), + count: z.number().default(0) + }) + }) + + const response = await app.handle( + new Request('http://localhost/default', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }) + ) + + const result = await response.json() + expect(result.nameValue).toBe('anonymous') + expect(result.countValue).toBe(0) + }) + + it('TypeBox t.Numeric: handler receives number from string input', async () => { + const app = new Elysia().post('/numeric', ({ body }) => { + return { + ageType: typeof body.age, + ageValue: body.age + } + }, { + body: t.Object({ + age: t.Numeric() + }) + }) + + const response = await app.handle( + new Request('http://localhost/numeric', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ age: '25' }) + }) + ) + + const result = await response.json() + expect(result.ageType).toBe('number') + expect(result.ageValue).toBe(25) + }) + + it('Nested Zod transforms: handler receives deeply transformed values', async () => { + const app = new Elysia().post('/nested', ({ body }) => { + return { + userName: body.user.name, + birthDateType: body.user.birthDate instanceof Date ? 'Date' : typeof body.user.birthDate, + countType: typeof body.metadata.count + } + }, { + body: z.object({ + user: z.object({ + name: z.string(), + birthDate: z.string().transform((s) => new Date(s)) + }), + metadata: z.object({ + count: z.string().transform(Number) + }) + }) + }) + + const response = await app.handle( + new Request('http://localhost/nested', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user: { name: 'Alice', birthDate: '1990-01-01T00:00:00.000Z' }, + metadata: { count: '10' } + }) + }) + ) + + const result = await response.json() + expect(result.userName).toBe('Alice') + expect(result.birthDateType).toBe('Date') + expect(result.countType).toBe('number') + }) + + it('Plugin composition preserves runtime transform behavior', async () => { + const plugin = new Elysia().post('/plugin-route', ({ body }) => { + return { + timestampType: body.timestamp instanceof Date ? 'Date' : typeof body.timestamp + } + }, { + body: z.object({ + timestamp: z.string().transform((s) => new Date(s)) + }) + }) + + const app = new Elysia().use(plugin) + + const response = await app.handle( + new Request('http://localhost/plugin-route', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + timestamp: '2024-06-01T12:00:00.000Z' + }) + }) + ) + + const result = await response.json() + expect(result.timestampType).toBe('Date') + }) + + it('Chained transforms: handler receives final transformed value', async () => { + const app = new Elysia().post('/chained', ({ body }) => { + return { + valueType: typeof body.value, + valueValue: body.value + } + }, { + body: z.object({ + value: z.string().transform(Number).transform((n) => n > 0) + }) + }) + + const response = await app.handle( + new Request('http://localhost/chained', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: '42' }) + }) + ) + + const result = await response.json() + expect(result.valueType).toBe('boolean') + expect(result.valueValue).toBe(true) + }) + + it('Routes metadata exposes ~Routes with input property', () => { + const app = new Elysia().post('/test', () => 'ok', { + body: z.object({ + data: z.string().transform(Number) + }) + }) + + // Verify the ~Routes brand field exists and has the expected structure + type Routes = (typeof app)['~Routes'] + type Route = Routes['test']['post'] + + // Verify input exists as a type-level property + type HasInput = 'input' extends keyof Route ? true : false + const check: HasInput = true + expect(check).toBe(true) + }) +}) diff --git a/test/types/index.ts b/test/types/index.ts index f66167b95..ccd0cb2c2 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -672,6 +672,16 @@ app.use(plugin).group( expected?: string } } + input: { + body: string + headers: { + authorization: string + } + query: { + name: string + } + params: Record + } }>() } @@ -699,6 +709,12 @@ app.use(plugin).group( response: { 200: number } + input: { + body: unknown + params: {} + query: unknown + headers: unknown + } }>() } @@ -751,6 +767,16 @@ app.use(plugin).group( expected?: string } } + input: { + body: string + params: {} + query: { + name: string + } + headers: { + authorization: string + } + } }>() } @@ -769,6 +795,12 @@ app.use(plugin).group( response: { 200: string } + input: { + body: unknown + params: {} + query: unknown + headers: unknown + } }>() } @@ -977,6 +1009,12 @@ app.group( response: { 200: string } + input: { + body: unknown + params: {} + query: unknown + headers: unknown + } }>() } @@ -1004,6 +1042,12 @@ app.group( response: { 200: string } + input: { + body: unknown + params: {} + query: unknown + headers: unknown + } } } } @@ -1238,6 +1282,12 @@ const a = app couldBeError: boolean } } + input: { + body: unknown + params: {} + query: unknown + headers: unknown + } } } } @@ -1252,6 +1302,12 @@ const a = app query: unknown headers: unknown response: {} + input: { + body: unknown + params: {} + query: unknown + headers: unknown + } } } } diff --git a/test/types/lifecycle/soundness.ts b/test/types/lifecycle/soundness.ts index 56853eb4b..698943d44 100644 --- a/test/types/lifecycle/soundness.ts +++ b/test/types/lifecycle/soundness.ts @@ -1790,6 +1790,20 @@ import { Prettify } from '../../../src/types' expected?: string } } + input: { + body: { + name: 'lilith' + } + params: { + name: 'lilith' + } + query: { + name: 'lilith' + } + headers: { + name: 'lilith' + } + } }>() } @@ -1854,6 +1868,15 @@ import { Prettify } from '../../../src/types' expected?: string } } + input: { + body: { + name: 'Lilith' + friends: ['Sartre', 'Fouco'] + } + params: {} + query: {} + headers: {} + } }>() } diff --git a/test/types/standard-schema/input-output-deep.ts b/test/types/standard-schema/input-output-deep.ts new file mode 100644 index 000000000..a7c401a7b --- /dev/null +++ b/test/types/standard-schema/input-output-deep.ts @@ -0,0 +1,1065 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * Deep type-level tests for separate input/output types on Eden routes. + * + * These tests go beyond basic transform scenarios and cover: + * - z.coerce (input is unknown) + * - z.default (input has | undefined) + * - z.optional().transform() + * - Chained transforms + * - Nested objects with transforms at different levels + * - Arrays of transformed items + * - .model() with Zod string references + * - Guard + route schema merging + * - .all() and .route() methods + * - Plugin nested composition + * - Mixed TypeBox + Zod + * - Eden contract consumption simulation + */ + +import { Elysia, t } from '../../../src' +import z from 'zod' +import { expectTypeOf } from 'expect-type' + +// ========================================================================= +// 12. z.coerce types: input is unknown +// ========================================================================= +{ + const app = new Elysia().post('/coerce', () => 'ok', { + body: z.object({ + count: z.coerce.number(), + active: z.coerce.boolean(), + label: z.coerce.string() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['coerce']['post'] + + // Output: all coerced to final types + expectTypeOf().toEqualTypeOf<{ + count: number + active: boolean + label: string + }>() + + // Input: coerce types accept unknown + expectTypeOf().toEqualTypeOf<{ + count: unknown + active: unknown + label: unknown + }>() +} + +// ========================================================================= +// 13. z.default: input is T | undefined, output is T +// ========================================================================= +{ + const app = new Elysia().post('/defaults', () => 'ok', { + body: z.object({ + name: z.string().default('anonymous'), + count: z.number().default(0) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['defaults']['post'] + + // Output: defaults resolved, types are clean + expectTypeOf().toEqualTypeOf<{ + name: string + count: number + }>() + + // Input: fields with defaults become optional properties + expectTypeOf().toEqualTypeOf<{ + name?: string | undefined + count?: number | undefined + }>() +} + +// ========================================================================= +// 14. z.optional + transform +// ========================================================================= +{ + const app = new Elysia().post('/optional-transform', () => 'ok', { + body: z.object({ + // optional -> if provided string, transform to number + score: z.string().transform(Number).optional() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['optional-transform']['post'] + + // Output: optional number + expectTypeOf().toEqualTypeOf<{ + score?: number | undefined + }>() + + // Input: optional string + expectTypeOf().toEqualTypeOf<{ + score?: string | undefined + }>() +} + +// ========================================================================= +// 15. Chained transforms: string -> number -> boolean +// ========================================================================= +{ + const app = new Elysia().post('/chained', () => 'ok', { + body: z.object({ + value: z + .string() + .transform(Number) + .transform((n) => n > 0) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['chained']['post'] + + // Output: boolean (final transform) + expectTypeOf().toEqualTypeOf<{ value: boolean }>() + + // Input: string (original input before any transforms) + expectTypeOf().toEqualTypeOf<{ + value: string + }>() +} + +// ========================================================================= +// 16. Nested objects with transforms at different levels +// ========================================================================= +{ + const app = new Elysia().post('/nested', () => 'ok', { + body: z.object({ + user: z.object({ + name: z.string(), + birthDate: z.string().transform((s) => new Date(s)) + }), + metadata: z.object({ + count: z.string().transform(Number), + tags: z.array(z.string()) + }) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['nested']['post'] + + // Output: transforms applied at nested levels + expectTypeOf().toEqualTypeOf<{ + user: { name: string; birthDate: Date } + metadata: { count: number; tags: string[] } + }>() + + // Input: raw types before transforms + expectTypeOf().toEqualTypeOf<{ + user: { name: string; birthDate: string } + metadata: { count: string; tags: string[] } + }>() +} + +// ========================================================================= +// 17. Arrays of transformed items +// ========================================================================= +{ + const app = new Elysia().post('/array-transform', () => 'ok', { + body: z.object({ + items: z.array( + z.object({ + id: z.string().transform(Number), + label: z.string() + }) + ) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['array-transform']['post'] + + // Output: array items have transformed types + expectTypeOf().toEqualTypeOf<{ + items: Array<{ id: number; label: string }> + }>() + + // Input: array items have pre-transform types + expectTypeOf().toEqualTypeOf<{ + items: Array<{ id: string; label: string }> + }>() +} + +// ========================================================================= +// 18. Guard + route: route overrides guard schema correctly +// ========================================================================= +{ + const app = new Elysia() + .guard({ + body: z.object({ + guardField: z.string().transform(Number) + }) + }) + .post('/guarded-override', () => 'ok', { + // Route overrides body - should use route's input type + body: z.object({ + routeField: z.string().transform((s) => new Date(s)) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['guarded-override']['post'] + + // Output: route's body takes precedence (MergeSchema A wins when defined) + expectTypeOf().toEqualTypeOf<{ + routeField: Date + }>() + + // Input: route's input type takes precedence + expectTypeOf().toEqualTypeOf<{ + routeField: string + }>() +} + +// ========================================================================= +// 19. Guard + route: guard provides schema, route doesn't override +// NOTE: Guard schemas are resolved as output types. When a route +// doesn't override the guard's schema, input === output for that +// field (they both come from the guard's resolved output type). +// This is a known limitation documented here. +// ========================================================================= +{ + const app = new Elysia() + .guard({ + query: z.object({ + page: z.string().transform(Number) + }) + }) + .get('/guarded-inherit', () => 'ok') + + type Routes = (typeof app)['~Routes'] + type Route = Routes['guarded-inherit']['get'] + + // Output: guard's output type (number after transform) + expectTypeOf().toEqualTypeOf<{ page: number }>() + + // Input: ALSO the guard's output type because guard schemas are stored + // as output types in Volatile['schema']. This is a known limitation. + // When guard schemas need input type separation, it would require + // storing input schemas separately on the guard (a more invasive change). + expectTypeOf().toEqualTypeOf<{ + page: number + }>() +} + +// ========================================================================= +// 20. .all() method with transforms +// ========================================================================= +{ + const app = new Elysia().all('/all-route', () => 'ok', { + body: z.object({ + data: z.string().transform(Number) + }) + }) + + type Routes = (typeof app)['~Routes'] + // .all() registers under all HTTP methods + type Route = Routes['all-route']['post'] + + expectTypeOf().toEqualTypeOf<{ data: number }>() + expectTypeOf().toEqualTypeOf<{ + data: string + }>() +} + +// ========================================================================= +// 21. .route() method with explicit method +// ========================================================================= +{ + const app = new Elysia().route('PATCH', '/custom-route', () => 'ok', { + body: z.object({ + amount: z.string().transform(Number) + }) + }) + + type Routes = (typeof app)['~Routes'] + // .route() stores method in UPPERCASE + type Route = Routes['custom-route']['PATCH'] + + expectTypeOf().toEqualTypeOf<{ amount: number }>() + expectTypeOf().toEqualTypeOf<{ + amount: string + }>() +} + +// ========================================================================= +// 22. Plugin nested composition (plugin in plugin) +// ========================================================================= +{ + const innerPlugin = new Elysia().post('/inner', () => 'ok', { + body: z.object({ + innerVal: z.string().transform(Number) + }) + }) + + const outerPlugin = new Elysia().use(innerPlugin).post('/outer', () => 'ok', { + body: z.object({ + outerVal: z.string().transform((s) => new Date(s)) + }) + }) + + const app = new Elysia().use(outerPlugin) + + type Routes = (typeof app)['~Routes'] + + // Inner route types + type InnerRoute = Routes['inner']['post'] + expectTypeOf().toEqualTypeOf<{ innerVal: number }>() + expectTypeOf().toEqualTypeOf<{ + innerVal: string + }>() + + // Outer route types + type OuterRoute = Routes['outer']['post'] + expectTypeOf().toEqualTypeOf<{ outerVal: Date }>() + expectTypeOf().toEqualTypeOf<{ + outerVal: string + }>() +} + +// ========================================================================= +// 23. Mixed TypeBox + Zod schemas on same app +// ========================================================================= +{ + const app = new Elysia() + .post('/typebox-route', () => 'ok', { + body: t.Object({ + tValue: t.String() + }) + }) + .post('/zod-route', () => 'ok', { + body: z.object({ + zValue: z.string().transform(Number) + }) + }) + + type Routes = (typeof app)['~Routes'] + + // TypeBox route: input === output + type TbRoute = Routes['typebox-route']['post'] + expectTypeOf().toEqualTypeOf<{ tValue: string }>() + expectTypeOf().toEqualTypeOf<{ + tValue: string + }>() + + // Zod route: input differs from output + type ZodRoute = Routes['zod-route']['post'] + expectTypeOf().toEqualTypeOf<{ zValue: number }>() + expectTypeOf().toEqualTypeOf<{ + zValue: string + }>() +} + +// ========================================================================= +// 24. Eden contract consumption simulation +// This simulates how Eden Treaty would read the app's routes. +// ========================================================================= +{ + const app = new Elysia() + .post('/api/users', () => 'ok', { + body: z.object({ + name: z.string(), + age: z.string().transform(Number) + }), + query: z.object({ + format: z.string().transform((s) => s === 'json') + }), + headers: z.object({ + authorization: z.string(), + 'x-trace-id': z.string().transform(Number) + }) + }) + + // This is exactly how Eden Treaty reads types + type App = typeof app + type Routes = App['~Routes'] + type UserRoute = Routes['api']['users']['post'] + + // What the handler receives (output types) + type HandlerBody = UserRoute['body'] + type HandlerQuery = UserRoute['query'] + type HandlerHeaders = UserRoute['headers'] + + // What the client should send (input types) + type ClientBody = UserRoute['input']['body'] + type ClientQuery = UserRoute['input']['query'] + type ClientHeaders = UserRoute['input']['headers'] + + // Handler gets transformed types + expectTypeOf().toEqualTypeOf<{ + name: string + age: number + }>() + expectTypeOf().toEqualTypeOf<{ + format: boolean + }>() + expectTypeOf().toEqualTypeOf<{ + authorization: string + 'x-trace-id': number + }>() + + // Client sends raw types + expectTypeOf().toEqualTypeOf<{ + name: string + age: string + }>() + expectTypeOf().toEqualTypeOf<{ + format: string + }>() + expectTypeOf().toEqualTypeOf<{ + authorization: string + 'x-trace-id': string + }>() + + // Verify they're actually different + expectTypeOf().not.toEqualTypeOf() + expectTypeOf().not.toEqualTypeOf() + expectTypeOf().not.toEqualTypeOf() +} + +// ========================================================================= +// 25. Body-less GET: input.body should be unknown +// ========================================================================= +{ + const app = new Elysia().get('/no-body', () => 'ok', { + query: z.object({ + search: z.string().transform((s) => s.toLowerCase()) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['no-body']['get'] + + // GET has no body + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + // But query should be properly separated + expectTypeOf().toEqualTypeOf<{ search: string }>() + expectTypeOf().toEqualTypeOf<{ + search: string + }>() +} + +// ========================================================================= +// 26. Transform on query + body simultaneously +// ========================================================================= +{ + const app = new Elysia().post('/multi-schema-transform', () => 'ok', { + body: z.object({ + payload: z.string().transform((s) => JSON.parse(s)) + }), + query: z.object({ + limit: z.string().transform(Number) + }), + headers: z.object({ + 'x-version': z.string().transform(Number) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['multi-schema-transform']['post'] + + // All output types are transformed + expectTypeOf().toEqualTypeOf<{ payload: any }>() + expectTypeOf().toEqualTypeOf<{ limit: number }>() + expectTypeOf().toEqualTypeOf<{ + 'x-version': number + }>() + + // All input types are pre-transform + expectTypeOf().toEqualTypeOf<{ + payload: string + }>() + expectTypeOf().toEqualTypeOf<{ + limit: string + }>() + expectTypeOf().toEqualTypeOf<{ + 'x-version': string + }>() +} + +// ========================================================================= +// 27. Discriminated union with transforms +// ========================================================================= +{ + const schema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text'), + content: z.string() + }), + z.object({ + type: z.literal('number'), + content: z.string().transform(Number) + }) + ]) + + const app = new Elysia().post('/union', () => 'ok', { + body: schema + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['union']['post'] + + // Output: union of transformed types + type ExpectedOutput = + | { type: 'text'; content: string } + | { type: 'number'; content: number } + + // Input: union of pre-transform types + type ExpectedInput = + | { type: 'text'; content: string } + | { type: 'number'; content: string } + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() +} + +// ========================================================================= +// 28. z.nullable + transform +// ========================================================================= +{ + const app = new Elysia().post('/nullable', () => 'ok', { + body: z.object({ + value: z.string().transform(Number).nullable() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['nullable']['post'] + + // Output: number | null + expectTypeOf().toEqualTypeOf<{ + value: number | null + }>() + + // Input: string | null + expectTypeOf().toEqualTypeOf<{ + value: string | null + }>() +} + +// ========================================================================= +// 29. z.preprocess +// ========================================================================= +{ + const app = new Elysia().post('/preprocess', () => 'ok', { + body: z.object({ + amount: z.preprocess((val) => Number(val), z.number()) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['preprocess']['post'] + + // Output: number + expectTypeOf().toEqualTypeOf<{ amount: number }>() + + // Input: preprocess accepts unknown + expectTypeOf().toEqualTypeOf<{ + amount: unknown + }>() +} + +// ========================================================================= +// 30. Multiple routes with prefix +// ========================================================================= +{ + const api = new Elysia({ prefix: '/api' }).post( + '/submit', + () => 'ok', + { + body: z.object({ + data: z.string().transform(Number) + }) + } + ) + + const app = new Elysia().use(api) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['api']['submit']['post'] + + expectTypeOf().toEqualTypeOf<{ data: number }>() + expectTypeOf().toEqualTypeOf<{ + data: string + }>() +} + +// ========================================================================= +// 31. Complex object: mixed coerce, transform, default, optional +// ========================================================================= +{ + const app = new Elysia().post('/complex', () => 'ok', { + body: z.object({ + required: z.string(), + transformed: z.string().transform(Number), + withDefault: z.string().default('hello'), + optional: z.string().optional(), + coerced: z.coerce.number(), + nullableTransform: z.string().transform(Number).nullable() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['complex']['post'] + + // Output types + expectTypeOf().toEqualTypeOf<{ + required: string + transformed: number + withDefault: string + optional?: string | undefined + coerced: number + nullableTransform: number | null + }>() + + // Input types + expectTypeOf().toEqualTypeOf<{ + required: string + transformed: string + withDefault?: string | undefined + optional?: string | undefined + coerced: unknown + nullableTransform: string | null + }>() + + // The input and output should differ + expectTypeOf().not.toEqualTypeOf() +} + +// ========================================================================= +// 32. head and connect methods +// ========================================================================= +{ + const app = new Elysia() + .head('/head-route', () => 'ok', { + headers: z.object({ + 'x-token': z.string().transform(Number) + }) + }) + .connect('/connect-route', () => 'ok', { + headers: z.object({ + upgrade: z.string().transform((s) => s.toUpperCase()) + }) + }) + + type Routes = (typeof app)['~Routes'] + + // HEAD + type HeadRoute = Routes['head-route']['head'] + expectTypeOf().toEqualTypeOf<{ + 'x-token': number + }>() + expectTypeOf().toEqualTypeOf<{ + 'x-token': string + }>() + + // CONNECT + type ConnectRoute = Routes['connect-route']['connect'] + expectTypeOf().toEqualTypeOf<{ + upgrade: string + }>() + expectTypeOf().toEqualTypeOf<{ + upgrade: string + }>() +} + +// ========================================================================= +// 33. Guard with scoped schema + route with own transform +// Verifies MergeSchema precedence: route input wins when defined +// ========================================================================= +{ + const app = new Elysia() + .guard({ + query: z.object({ + page: z.string().transform(Number) + }) + }) + .post('/guard-with-body-transform', () => 'ok', { + body: z.object({ + data: z.string().transform(Number) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['guard-with-body-transform']['post'] + + // Route's body is correctly separated (route-level schema) + expectTypeOf().toEqualTypeOf<{ data: number }>() + expectTypeOf().toEqualTypeOf<{ + data: string + }>() + + // Guard's query is output type (guard limitation) + expectTypeOf().toEqualTypeOf<{ page: number }>() +} + +// ========================================================================= +// 34. Input property structure matches expected Eden contract shape +// ========================================================================= +{ + const app = new Elysia().post( + '/users/:id', + () => 'ok', + { + body: z.object({ + name: z.string().transform((s) => s.trim()) + }), + params: t.Object({ id: t.String() }), + query: z.object({ + include: z.string().transform((s) => s.split(',')) + }) + } + ) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['users'][':id']['post'] + + // Verify input has exactly body, params, query, headers + type InputKeys = keyof Route['input'] + expectTypeOf().toEqualTypeOf< + 'body' | 'params' | 'query' | 'headers' + >() + + // Each input field has the correct pre-transform type + expectTypeOf().toEqualTypeOf<{ name: string }>() + expectTypeOf().toEqualTypeOf<{ id: string }>() + expectTypeOf().toEqualTypeOf<{ + include: string + }>() +} + +// ========================================================================= +// 35. Backward compat: response is NOT in input (only body/params/query/headers) +// ========================================================================= +{ + const app = new Elysia().post('/response-not-in-input', () => 'ok', { + body: z.object({ name: z.string() }), + response: z.string() + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['response-not-in-input']['post'] + + // Response is on the route, not in input + expectTypeOf().toHaveProperty(200) + + // Input does not have 'response' + type InputHasResponse = 'response' extends keyof Route['input'] + ? true + : false + expectTypeOf().toEqualTypeOf() +} + +// ========================================================================= +// 36. TypeBox t.Numeric — transform type cast as TNumber +// Input should equal output since TypeBox types are cast back to base +// ========================================================================= +{ + const app = new Elysia().post('/typebox-numeric', () => 'ok', { + body: t.Object({ + age: t.Numeric(), + score: t.Number() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['typebox-numeric']['post'] + + // Both output and input are number (Elysia casts t.Numeric() as TNumber) + expectTypeOf().toEqualTypeOf<{ + age: number + score: number + }>() + expectTypeOf().toEqualTypeOf<{ + age: number + score: number + }>() +} + +// ========================================================================= +// 37. TypeBox t.BooleanString — transform cast as TBoolean +// ========================================================================= +{ + const app = new Elysia().get('/typebox-boolean', () => 'ok', { + query: t.Object({ + active: t.BooleanString() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['typebox-boolean']['get'] + + // Input === output for TypeBox transform types + expectTypeOf().toEqualTypeOf<{ active: boolean }>() + expectTypeOf().toEqualTypeOf<{ + active: boolean + }>() +} + +// ========================================================================= +// 38. TypeBox t.ObjectString — transform cast, input should match output +// ========================================================================= +{ + const app = new Elysia().get('/typebox-objectstring', () => 'ok', { + query: t.Object({ + filter: t.ObjectString({ + name: t.String(), + limit: t.Number() + }) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['typebox-objectstring']['get'] + + // Both resolve to the object type + expectTypeOf().toEqualTypeOf<{ + filter: { name: string; limit: number } + }>() + expectTypeOf().toEqualTypeOf<{ + filter: { name: string; limit: number } + }>() +} + +// ========================================================================= +// 39. Mixed TypeBox transform types + Zod transforms on same route +// ========================================================================= +{ + const app = new Elysia().post('/mixed-transforms', () => 'ok', { + body: z.object({ + data: z.string().transform(Number) + }), + query: t.Object({ + page: t.Numeric() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['mixed-transforms']['post'] + + // Body: Zod transform — output differs from input + expectTypeOf().toEqualTypeOf<{ data: number }>() + expectTypeOf().toEqualTypeOf<{ + data: string + }>() + + // Query: TypeBox Numeric — input === output + expectTypeOf().toEqualTypeOf<{ page: number }>() + expectTypeOf().toEqualTypeOf<{ + page: number + }>() +} + +// ========================================================================= +// 40. Macro with Standard Schema transforms: input vs output separation +// (CodeRabbit review: MacroContext used to leak output types into input) +// ========================================================================= +{ + const app = new Elysia() + .macro({ + auth: (enabled: boolean) => ({ + headers: z.object({ + 'x-token': z.string().transform(Number) + }), + resolve: () => ({ + user: 'saltyaom' + }) + }) + }) + .post('/macro-transform', ({ user }) => user, { + auth: true, + body: z.object({ + name: z.string().transform((s) => s.toUpperCase()) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['macro-transform']['post'] + + // Output: handler receives post-transform types + expectTypeOf().toEqualTypeOf<{ name: string }>() + expectTypeOf().toEqualTypeOf<{ + 'x-token': number + }>() + + // Input: client sends pre-transform types + expectTypeOf().toEqualTypeOf<{ + name: string + }>() + expectTypeOf().toEqualTypeOf<{ + 'x-token': string + }>() +} + +// ========================================================================= +// 41. Macro with z.coerce in schema: input is unknown +// ========================================================================= +{ + const app = new Elysia() + .macro({ + validate: (enabled: boolean) => ({ + query: z.object({ + page: z.coerce.number(), + active: z.coerce.boolean() + }) + }) + }) + .get('/macro-coerce', () => 'ok', { + validate: true + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['macro-coerce']['get'] + + // Output: coerced to final types + expectTypeOf().toEqualTypeOf<{ + page: number + active: boolean + }>() + + // Input: coerce accepts unknown inputs + expectTypeOf().toEqualTypeOf<{ + page: unknown + active: unknown + }>() +} + +// ========================================================================= +// 42. Macro schema merged with route schema, both with transforms +// ========================================================================= +{ + const app = new Elysia() + .macro({ + auth: (enabled: boolean) => ({ + headers: z.object({ + authorization: z.string().transform((s) => s.split(' ')[1]) + }), + resolve: () => ({ + userId: '123' + }) + }) + }) + .post( + '/macro-plus-route', + ({ userId }) => userId, + { + auth: true, + body: z.object({ + score: z.string().transform(Number) + }) + } + ) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['macro-plus-route']['post'] + + // Output: both macro and route schemas post-transform + expectTypeOf().toEqualTypeOf<{ score: number }>() + expectTypeOf().toEqualTypeOf<{ + authorization: string + }>() + + // Input: both macro and route schemas pre-transform + expectTypeOf().toEqualTypeOf<{ + score: string + }>() + expectTypeOf().toEqualTypeOf<{ + authorization: string + }>() +} + +// ========================================================================= +// 43. Macro with TypeBox schema (input === output, no transforms) +// ========================================================================= +{ + const app = new Elysia() + .macro({ + validated: (enabled: boolean) => ({ + body: t.Object({ + name: t.String(), + age: t.Number() + }) + }) + }) + .post('/macro-typebox', () => 'ok', { + validated: true + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['macro-typebox']['post'] + + // TypeBox: input === output (no transforms) + expectTypeOf().toEqualTypeOf<{ + name: string + age: number + }>() + expectTypeOf().toEqualTypeOf<{ + name: string + age: number + }>() +} + +// ========================================================================= +// 44. Macro with z.default: input has optional properties +// ========================================================================= +{ + const app = new Elysia() + .macro({ + withDefaults: (enabled: boolean) => ({ + body: z.object({ + role: z.string().default('user'), + active: z.boolean().default(true) + }) + }) + }) + .post('/macro-defaults', () => 'ok', { + withDefaults: true + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['macro-defaults']['post'] + + // Output: defaults applied — all required + expectTypeOf().toEqualTypeOf<{ + role: string + active: boolean + }>() + + // Input: with defaults, properties are optional + expectTypeOf().toEqualTypeOf<{ + role?: string | undefined + active?: boolean | undefined + }>() +} + +// ========================================================================= +// 45. No macro: CreateEdenResponse MacroContextInput defaults to MacroContext +// ========================================================================= +{ + const app = new Elysia().post('/no-macro', () => 'ok', { + body: z.object({ + value: z.string().transform(Number) + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['no-macro']['post'] + + // Without macros, input/output separation still works normally + expectTypeOf().toEqualTypeOf<{ value: number }>() + expectTypeOf().toEqualTypeOf<{ + value: string + }>() +} diff --git a/test/types/standard-schema/input-output.ts b/test/types/standard-schema/input-output.ts new file mode 100644 index 000000000..da08f3c10 --- /dev/null +++ b/test/types/standard-schema/input-output.ts @@ -0,0 +1,323 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * Type-level tests for separate input/output types on Eden routes. + * + * Validates that `CreateEdenResponse` exposes both: + * - `body` / `query` / `headers` / `params` – the **output** (post-transform) types + * - `input.body` / `input.query` / `input.headers` / `input.params` – the **input** (pre-transform) types + * + * When no transforms are present, input === output. + * When Zod transforms are used, input reflects the raw shape the client sends. + */ + +import { Elysia, t } from '../../../src' +import z from 'zod' +import { expectTypeOf } from 'expect-type' + +// -------------------------------------------------------------------------- +// 1. No transforms: input types === output types (TypeBox) +// -------------------------------------------------------------------------- +{ + const app = new Elysia().post('/no-transform', () => 'ok', { + body: t.Object({ + name: t.String(), + age: t.Number() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['no-transform']['post'] + + // Output types (existing behavior) + expectTypeOf().toEqualTypeOf<{ name: string; age: number }>() + + // Input types (should be same as output when no transforms) + expectTypeOf().toEqualTypeOf<{ + name: string + age: number + }>() +} + +// -------------------------------------------------------------------------- +// 2. No transforms: input types === output types (Zod, no transform) +// -------------------------------------------------------------------------- +{ + const app = new Elysia().post('/zod-plain', () => 'ok', { + body: z.object({ + name: z.string(), + count: z.number() + }) + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['zod-plain']['post'] + + // Output types + expectTypeOf().toEqualTypeOf<{ + name: string + count: number + }>() + + // Input types (same as output, no transforms) + expectTypeOf().toEqualTypeOf<{ + name: string + count: number + }>() +} + +// -------------------------------------------------------------------------- +// 3. Zod transform: input differs from output +// -------------------------------------------------------------------------- +{ + const dateSchema = z.object({ + name: z.string(), + createdAt: z.string().transform((s) => new Date(s)) + }) + + const app = new Elysia().post('/zod-transform', () => 'ok', { + body: dateSchema + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['zod-transform']['post'] + + // Output type: createdAt is Date (after transform) + expectTypeOf().toEqualTypeOf<{ + name: string + createdAt: Date + }>() + + // Input type: createdAt is string (before transform) + expectTypeOf().toEqualTypeOf<{ + name: string + createdAt: string + }>() +} + +// -------------------------------------------------------------------------- +// 4. Zod transform on query +// -------------------------------------------------------------------------- +{ + const querySchema = z.object({ + page: z.string().transform(Number), + active: z.string().transform((v) => v === 'true') + }) + + const app = new Elysia().get('/query-transform', () => 'ok', { + query: querySchema + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['query-transform']['get'] + + // Output: transformed types + expectTypeOf().toEqualTypeOf<{ + page: number + active: boolean + }>() + + // Input: raw string types + expectTypeOf().toEqualTypeOf<{ + page: string + active: string + }>() +} + +// -------------------------------------------------------------------------- +// 5. Mixed: some fields transformed, some not +// -------------------------------------------------------------------------- +{ + const bodySchema = z.object({ + username: z.string(), + score: z.string().transform(Number), + tags: z.array(z.string()) + }) + + const app = new Elysia().put('/mixed', () => 'ok', { + body: bodySchema + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['mixed']['put'] + + // Output + expectTypeOf().toEqualTypeOf<{ + username: string + score: number + tags: string[] + }>() + + // Input + expectTypeOf().toEqualTypeOf<{ + username: string + score: string + tags: string[] + }>() +} + +// -------------------------------------------------------------------------- +// 6. Multiple HTTP methods share the same pattern +// -------------------------------------------------------------------------- +{ + const schema = z.object({ + value: z.string().transform(Number) + }) + + const app = new Elysia() + .get('/multi', () => 'ok', { query: schema }) + .post('/multi', () => 'ok', { body: schema }) + .patch('/multi', () => 'ok', { body: schema }) + .delete('/multi', () => 'ok', { body: schema }) + + type Routes = (typeof app)['~Routes'] + + // GET query + expectTypeOf().toEqualTypeOf<{ + value: string + }>() + expectTypeOf().toEqualTypeOf<{ + value: number + }>() + + // POST body + expectTypeOf().toEqualTypeOf<{ + value: string + }>() + expectTypeOf().toEqualTypeOf<{ + value: number + }>() + + // PATCH body + expectTypeOf().toEqualTypeOf<{ + value: string + }>() + expectTypeOf().toEqualTypeOf<{ + value: number + }>() + + // DELETE body + expectTypeOf().toEqualTypeOf<{ + value: string + }>() + expectTypeOf().toEqualTypeOf<{ + value: number + }>() +} + +// -------------------------------------------------------------------------- +// 7. Params with explicit schema stay consistent +// -------------------------------------------------------------------------- +{ + const app = new Elysia().get( + '/users/:id', + ({ params }) => { + expectTypeOf().toEqualTypeOf<{ id: string }>() + return 'ok' + }, + { + params: t.Object({ id: t.String() }) + } + ) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['users'][':id']['get'] + + // Both output and input should be the same for non-transform params + expectTypeOf().toEqualTypeOf<{ id: string }>() + expectTypeOf().toEqualTypeOf<{ id: string }>() +} + +// -------------------------------------------------------------------------- +// 8. Response types are unaffected (always output) +// -------------------------------------------------------------------------- +{ + const app = new Elysia().get('/response', () => 'test' as const, { + response: z.literal('test') + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['response']['get'] + + // Response should include the declared type + expectTypeOf().toEqualTypeOf<'test'>() +} + +// -------------------------------------------------------------------------- +// 9. Input types exposed for headers with transforms +// -------------------------------------------------------------------------- +{ + const headerSchema = z.object({ + authorization: z.string(), + 'x-request-id': z.string().transform(Number) + }) + + const app = new Elysia().get('/headers', () => 'ok', { + headers: headerSchema + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['headers']['get'] + + // Output: x-request-id is number + expectTypeOf().toEqualTypeOf<{ + authorization: string + 'x-request-id': number + }>() + + // Input: x-request-id is string + expectTypeOf().toEqualTypeOf<{ + authorization: string + 'x-request-id': string + }>() +} + +// -------------------------------------------------------------------------- +// 10. Plugin composition preserves input types +// -------------------------------------------------------------------------- +{ + const schema = z.object({ + timestamp: z.string().transform((s) => new Date(s)) + }) + + const plugin = new Elysia().post('/plugin-route', () => 'ok', { + body: schema + }) + + const app = new Elysia().use(plugin) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['plugin-route']['post'] + + // Output + expectTypeOf().toEqualTypeOf<{ timestamp: Date }>() + + // Input + expectTypeOf().toEqualTypeOf<{ + timestamp: string + }>() +} + +// -------------------------------------------------------------------------- +// 11. Backward compatibility: existing body/query/headers remain output types +// -------------------------------------------------------------------------- +{ + const bodySchema = z.object({ + amount: z.string().transform(Number) + }) + + const app = new Elysia().post('/compat', () => 'ok', { + body: bodySchema + }) + + type Routes = (typeof app)['~Routes'] + type Route = Routes['compat']['post'] + + // The top-level `body` should be the OUTPUT type (backward compat) + expectTypeOf().toEqualTypeOf<{ amount: number }>() + + // The `input.body` should be the INPUT type + expectTypeOf().toEqualTypeOf<{ amount: string }>() + + // They should NOT be equal when transforms are involved + expectTypeOf().not.toEqualTypeOf() +}