diff --git a/src/schema.ts b/src/schema.ts index 82904d30..32657ea1 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -375,6 +375,41 @@ 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() + + // 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') { + 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 +653,24 @@ export const getSchemaValidator = < references: '', checkFunc: () => {}, code: '', - // @ts-ignore - Check, - // @ts-ignore - Errors: (value: unknown) => Check(value)?.then?.((x) => x?.issues), + // 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 - 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, - // @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 +875,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 +904,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 +988,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 +998,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 +1027,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 00000000..ed3a131c --- /dev/null +++ b/test/standard-schema/date-serialization.test.ts @@ -0,0 +1,247 @@ +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']) + }) + + 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', () => { + // Mock Standard Schema interface + const createMockDateSchema = () => ({ + '~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 + 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', + types: undefined as unknown as { input: unknown; output: { name: string; createdAt: Date | string } }, + 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' + }) + }) + }) +})