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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 67 additions & 17 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = serializeDates((value as Record<string, unknown>)[key])
}
}
return result
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return value
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const createCleaner = (schema: TAnySchema) => (value: unknown) => {
if (typeof value === 'object')
try {
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
hasAdditionalProperties: false,
hasDefault: false,
isOptional: false,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand Down
247 changes: 247 additions & 0 deletions test/standard-schema/date-serialization.test.ts
Original file line number Diff line number Diff line change
@@ -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'
})
})
})
})
Loading