From 2741a9937c7a2b25a4a32273b45c6d56a785a071 Mon Sep 17 00:00:00 2001 From: RAUNAK <122172696+aunak@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:41:52 +0530 Subject: [PATCH 1/4] fix: serialize Date objects before Standard Schema validation This commit fixes an issue where response validation fails when using Standard Schema validators (Zod, Effect, etc.) with Date types. ## Problem When returning Date objects from handlers with Standard Schema response validation, the validation fails because: 1. Response validation (Check) happens BEFORE encoding 2. The schema expects a string (JSON representation) 3. But the Date object hasn't been serialized yet 4. JSON.stringify would convert Date to ISO string, but validation failed first ## Solution Added `serializeDates` helper function that recursively converts Date objects to ISO strings (matching JSON.stringify behavior). This function is now called BEFORE validation in all three Standard Schema validator code paths: - Dynamic async validators - Non-async validators (with sub-validators) - Non-async validators (without sub-validators) The Encode function also uses serializeDates to ensure proper transformation. Fixes #1670 --- src/schema.ts | 73 +++++-- .../date-serialization.test.ts | 200 ++++++++++++++++++ 2 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 test/standard-schema/date-serialization.test.ts diff --git a/src/schema.ts b/src/schema.ts index 82904d308..e4fa39ceb 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -375,6 +375,36 @@ export const hasTransform = (schema: TAnySchema): boolean => { return TransformKind in schema } +/** + * Recursively serialize Date objects to ISO strings. + * + * This mimics JSON.stringify's behavior for Date objects, + * ensuring that response validation works correctly when + * using Standard Schema validators (Zod, Effect, etc.) with dates. + * + * @see https://github.com/elysiajs/elysia/issues/1670 + */ +export const serializeDates = (value: unknown): unknown => { + if (value === null || value === undefined) return value + + if (value instanceof Date) + return Number.isNaN(value.getTime()) ? null : value.toISOString() + + if (Array.isArray(value)) return value.map(serializeDates) + + if (typeof value === 'object') { + const result: Record = {} + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + result[key] = serializeDates((value as Record)[key]) + } + } + return result + } + + return value +} + const createCleaner = (schema: TAnySchema) => (value: unknown) => { if (typeof value === 'object') try { @@ -618,15 +648,20 @@ export const getSchemaValidator = < references: '', checkFunc: () => {}, code: '', + // Wrap Check to serialize dates before validation + // This ensures Date objects are converted to ISO strings + // before the schema validates them, matching JSON.stringify behavior + // @see https://github.com/elysiajs/elysia/issues/1670 + // @ts-ignore - type predicate signature mismatch is intentional for Standard Schema + Check: (value: unknown) => Check(serializeDates(value)), // @ts-ignore - Check, - // @ts-ignore - Errors: (value: unknown) => Check(value)?.then?.((x) => x?.issues), + Errors: (value: unknown) => Check(serializeDates(value))?.then?.((x) => x?.issues), Code: () => '', // @ts-ignore Decode: Check, - // @ts-ignore - Encode: (value: unknown) => value, + // Serialize Date objects to ISO strings for JSON compatibility + // @ts-ignore - return type mismatch is intentional for Standard Schema + Encode: serializeDates, hasAdditionalProperties: false, hasDefault: false, isOptional: false, @@ -831,12 +866,14 @@ export const getSchemaValidator = < references: '', checkFunc: () => {}, code: '', - // @ts-ignore - Check: (v) => schema['~standard'].validate(v), + // Serialize dates before validation to match JSON.stringify behavior + // @see https://github.com/elysiajs/elysia/issues/1670 + // @ts-ignore - type predicate signature mismatch is intentional for Standard Schema + Check: (v) => schema['~standard'].validate(serializeDates(v)), // @ts-ignore Errors(value: unknown) { // @ts-ignore - const response = schema['~standard'].validate(value) + const response = schema['~standard'].validate(serializeDates(value)) if (response instanceof Promise) throw Error( @@ -858,8 +895,9 @@ export const getSchemaValidator = < return response }, - // @ts-ignore - Encode: (value: unknown) => value, + // Serialize Date objects to ISO strings for JSON compatibility + // @ts-ignore - return type mismatch is intentional for Standard Schema + Encode: serializeDates, hasAdditionalProperties: false, hasDefault: false, isOptional: false, @@ -941,7 +979,7 @@ export const getSchemaValidator = < references: '', checkFunc(value: unknown) { // @ts-ignore - const response = schema['~standard'].validate(value) + const response = schema['~standard'].validate(serializeDates(value)) if (response instanceof Promise) throw Error( @@ -951,12 +989,14 @@ export const getSchemaValidator = < return response }, code: '', - // @ts-ignore - Check: (v) => schema['~standard'].validate(v), + // Serialize dates before validation to match JSON.stringify behavior + // @see https://github.com/elysiajs/elysia/issues/1670 + // @ts-ignore - type predicate signature mismatch is intentional for Standard Schema + Check: (v) => schema['~standard'].validate(serializeDates(v)), // @ts-ignore Errors(value: unknown) { // @ts-ignore - const response = schema['~standard'].validate(value) + const response = schema['~standard'].validate(serializeDates(value)) if (response instanceof Promise) throw Error( @@ -978,8 +1018,9 @@ export const getSchemaValidator = < return response }, - // @ts-ignore - Encode: (value: unknown) => value, + // Serialize Date objects to ISO strings for JSON compatibility + // @ts-ignore - return type mismatch is intentional for Standard Schema + Encode: serializeDates, hasAdditionalProperties: false, hasDefault: false, isOptional: false, diff --git a/test/standard-schema/date-serialization.test.ts b/test/standard-schema/date-serialization.test.ts new file mode 100644 index 000000000..6e74eed5b --- /dev/null +++ b/test/standard-schema/date-serialization.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'bun:test' +import { Elysia, t } from '../../src' +import { serializeDates } from '../../src/schema' + +/** + * Tests for Date serialization in Standard Schema validators. + * + * When using Standard Schema validators (Zod, Effect, etc.) with dates, + * Elysia should automatically serialize Date objects to ISO strings + * before response validation, matching JSON.stringify behavior. + * + * @see https://github.com/elysiajs/elysia/issues/1670 + */ +describe('Date Serialization', () => { + describe('serializeDates helper', () => { + it('should convert Date to ISO string', () => { + const date = new Date('2026-01-20T12:00:00.000Z') + expect(serializeDates(date)).toBe('2026-01-20T12:00:00.000Z') + }) + + it('should handle null and undefined', () => { + expect(serializeDates(null)).toBe(null) + expect(serializeDates(undefined)).toBe(undefined) + }) + + it('should pass through primitives unchanged', () => { + expect(serializeDates('hello')).toBe('hello') + expect(serializeDates(42)).toBe(42) + expect(serializeDates(true)).toBe(true) + }) + + it('should serialize nested Date in object', () => { + const date = new Date('2026-01-20T12:00:00.000Z') + const result = serializeDates({ + name: 'test', + createdAt: date + }) + expect(result).toEqual({ + name: 'test', + createdAt: '2026-01-20T12:00:00.000Z' + }) + }) + + it('should serialize Date in array', () => { + const date = new Date('2026-01-20T12:00:00.000Z') + const result = serializeDates([date, 'other']) + expect(result).toEqual(['2026-01-20T12:00:00.000Z', 'other']) + }) + + it('should handle deeply nested objects with dates', () => { + const date = new Date('2026-01-20T12:00:00.000Z') + const result = serializeDates({ + user: { + name: 'Alice', + profile: { + createdAt: date, + updatedAt: date + } + }, + timestamps: [date, date] + }) + expect(result).toEqual({ + user: { + name: 'Alice', + profile: { + createdAt: '2026-01-20T12:00:00.000Z', + updatedAt: '2026-01-20T12:00:00.000Z' + } + }, + timestamps: [ + '2026-01-20T12:00:00.000Z', + '2026-01-20T12:00:00.000Z' + ] + }) + }) + + it('should return null for invalid Date (matching JSON.stringify)', () => { + const invalidDate = new Date('invalid') + expect(serializeDates(invalidDate)).toBe(null) + // Verify it matches JSON.stringify behavior + expect(JSON.parse(JSON.stringify(invalidDate))).toBe(null) + }) + + it('should return null for invalid Date in nested object', () => { + const invalidDate = new Date('not-a-date') + const result = serializeDates({ + name: 'test', + createdAt: invalidDate + }) + expect(result).toEqual({ + name: 'test', + createdAt: null + }) + }) + + it('should return null for invalid Date in array', () => { + const invalidDate = new Date('invalid') + const validDate = new Date('2026-01-20T12:00:00.000Z') + const result = serializeDates([invalidDate, validDate]) + expect(result).toEqual([null, '2026-01-20T12:00:00.000Z']) + }) + }) + + describe('Standard Schema with Date response', () => { + // Mock Standard Schema interface + const createMockDateSchema = () => ({ + '~standard': { + version: 1, + vendor: 'mock', + validate: (value: unknown) => { + if (typeof value === 'string') { + // Check if valid ISO date string + const date = new Date(value) + if (!isNaN(date.getTime())) { + return { value } + } + } + return { + issues: [{ + message: `Expected ISO date string, got ${typeof value}: ${value}`, + path: [] + }] + } + } + } + }) + + it('should serialize Date to ISO string before validation', async () => { + const dateSchema = createMockDateSchema() + + const app = new Elysia().get( + '/date', + () => new Date('2026-01-20T12:00:00.000Z'), + { + response: { + 200: dateSchema + } + } + ) + + const response = await app.handle( + new Request('http://localhost/date') + ) + + expect(response.status).toBe(200) + const body = await response.text() + // JSON.stringify wraps strings in quotes + expect(body).toBe('2026-01-20T12:00:00.000Z') + }) + + it('should serialize Date in object response', async () => { + const objectWithDateSchema = { + '~standard': { + version: 1, + vendor: 'mock', + validate: (value: unknown) => { + if ( + typeof value === 'object' && + value !== null && + 'createdAt' in value && + typeof (value as any).createdAt === 'string' + ) { + return { value } + } + return { + issues: [{ + message: `Expected object with ISO date string createdAt`, + path: [] + }] + } + } + } + } + + const app = new Elysia().get( + '/object', + () => ({ + name: 'test', + createdAt: new Date('2026-01-20T12:00:00.000Z') + }), + { + response: { + 200: objectWithDateSchema + } + } + ) + + const response = await app.handle( + new Request('http://localhost/object') + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ + name: 'test', + createdAt: '2026-01-20T12:00:00.000Z' + }) + }) + }) +}) From ca6b74d86ac5d9ec902b9240bdef43065f2b0a4d Mon Sep 17 00:00:00 2001 From: RAUNAK <122172696+aunak@users.noreply.github.com> Date: Wed, 21 Jan 2026 03:33:24 +0530 Subject: [PATCH 2/4] fix: handle toJSON method in serializeDates for JSON.stringify semantics The serializeDates function now checks for toJSON method on objects before array/object traversal. This ensures proper JSON.stringify semantics where custom toJSON methods are called and their results are recursively processed. This fixes cases where objects with custom toJSON methods would be traversed as regular objects instead of having their toJSON result serialized. Changes: - Added toJSON check in serializeDates after Date handling - toJSON result is recursively passed through serializeDates - Added tests for toJSON handling with various return types --- src/schema.ts | 5 +++ .../date-serialization.test.ts | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/schema.ts b/src/schema.ts index e4fa39ceb..6265fc548 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -390,6 +390,11 @@ export const serializeDates = (value: unknown): unknown => { if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString() + // Handle objects with toJSON method to maintain JSON.stringify semantics + // This must come before array/object traversal to properly handle custom serialization + if (value && typeof (value as any).toJSON === 'function') + return serializeDates((value as any).toJSON()) + if (Array.isArray(value)) return value.map(serializeDates) if (typeof value === 'object') { diff --git a/test/standard-schema/date-serialization.test.ts b/test/standard-schema/date-serialization.test.ts index 6e74eed5b..45db0aaf1 100644 --- a/test/standard-schema/date-serialization.test.ts +++ b/test/standard-schema/date-serialization.test.ts @@ -99,6 +99,51 @@ describe('Date Serialization', () => { const result = serializeDates([invalidDate, validDate]) expect(result).toEqual([null, '2026-01-20T12:00:00.000Z']) }) + + it('should call toJSON method on objects (matching JSON.stringify)', () => { + const customObj = { + value: 42, + toJSON() { + return { serialized: this.value } + } + } + const result = serializeDates(customObj) + expect(result).toEqual({ serialized: 42 }) + // Verify it matches JSON.stringify behavior + expect(result).toEqual(JSON.parse(JSON.stringify(customObj))) + }) + + it('should handle nested toJSON with Date inside', () => { + const date = new Date('2026-01-20T12:00:00.000Z') + const customObj = { + toJSON() { + return { timestamp: date } + } + } + const result = serializeDates(customObj) + expect(result).toEqual({ timestamp: '2026-01-20T12:00:00.000Z' }) + }) + + it('should handle toJSON returning primitive', () => { + const customObj = { + toJSON() { + return 'custom-string' + } + } + const result = serializeDates(customObj) + expect(result).toBe('custom-string') + }) + + it('should handle toJSON returning array with dates', () => { + const date = new Date('2026-01-20T12:00:00.000Z') + const customObj = { + toJSON() { + return [date, 'other'] + } + } + const result = serializeDates(customObj) + expect(result).toEqual(['2026-01-20T12:00:00.000Z', 'other']) + }) }) describe('Standard Schema with Date response', () => { From a5d27a4d45a08bcb8f1ecd0d6961d65541394c15 Mon Sep 17 00:00:00 2001 From: RAUNAK <122172696+aunak@users.noreply.github.com> Date: Wed, 21 Jan 2026 03:38:35 +0530 Subject: [PATCH 3/4] fix: add types property to mock schemas for StandardSchemaV1Like compatibility --- test/standard-schema/date-serialization.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/standard-schema/date-serialization.test.ts b/test/standard-schema/date-serialization.test.ts index 45db0aaf1..ed3a131ca 100644 --- a/test/standard-schema/date-serialization.test.ts +++ b/test/standard-schema/date-serialization.test.ts @@ -152,6 +152,7 @@ describe('Date Serialization', () => { '~standard': { version: 1, vendor: 'mock', + types: undefined as unknown as { input: unknown; output: Date | string }, validate: (value: unknown) => { if (typeof value === 'string') { // Check if valid ISO date string @@ -198,6 +199,7 @@ describe('Date Serialization', () => { '~standard': { version: 1, vendor: 'mock', + types: undefined as unknown as { input: unknown; output: { name: string; createdAt: Date | string } }, validate: (value: unknown) => { if ( typeof value === 'object' && From 378cc130391fa9d74f9c530593ab35024bf076e4 Mon Sep 17 00:00:00 2001 From: RAUNAK <122172696+aunak@users.noreply.github.com> Date: Wed, 21 Jan 2026 03:40:27 +0530 Subject: [PATCH 4/4] fix: make Errors return iterable synchronously for spread/First() usage --- src/schema.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 6265fc548..32657ea1d 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -659,8 +659,12 @@ export const getSchemaValidator = < // @see https://github.com/elysiajs/elysia/issues/1670 // @ts-ignore - type predicate signature mismatch is intentional for Standard Schema Check: (value: unknown) => Check(serializeDates(value)), - // @ts-ignore - Errors: (value: unknown) => Check(serializeDates(value))?.then?.((x) => x?.issues), + // @ts-ignore - returns iterable synchronously for spread/First() usage + Errors: (value: unknown) => { + const res = Check(serializeDates(value)) + if (res && typeof (res as any).then === 'function') return [] + return (res as { issues?: unknown[] })?.issues ?? [] + }, Code: () => '', // @ts-ignore Decode: Check,