Skip to content

Commit f2ff1be

Browse files
committed
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
1 parent 36bc9b8 commit f2ff1be

File tree

2 files changed

+230
-16
lines changed

2 files changed

+230
-16
lines changed

src/schema.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,35 @@ export const hasTransform = (schema: TAnySchema): boolean => {
375375
return TransformKind in schema
376376
}
377377

378+
/**
379+
* Recursively serialize Date objects to ISO strings.
380+
*
381+
* This mimics JSON.stringify's behavior for Date objects,
382+
* ensuring that response validation works correctly when
383+
* using Standard Schema validators (Zod, Effect, etc.) with dates.
384+
*
385+
* @see https://github.com/elysiajs/elysia/issues/1670
386+
*/
387+
export const serializeDates = (value: unknown): unknown => {
388+
if (value === null || value === undefined) return value
389+
390+
if (value instanceof Date) return value.toISOString()
391+
392+
if (Array.isArray(value)) return value.map(serializeDates)
393+
394+
if (typeof value === 'object') {
395+
const result: Record<string, unknown> = {}
396+
for (const key in value) {
397+
if (Object.prototype.hasOwnProperty.call(value, key)) {
398+
result[key] = serializeDates((value as Record<string, unknown>)[key])
399+
}
400+
}
401+
return result
402+
}
403+
404+
return value
405+
}
406+
378407
const createCleaner = (schema: TAnySchema) => (value: unknown) => {
379408
if (typeof value === 'object')
380409
try {
@@ -618,15 +647,20 @@ export const getSchemaValidator = <
618647
references: '',
619648
checkFunc: () => {},
620649
code: '',
650+
// Wrap Check to serialize dates before validation
651+
// This ensures Date objects are converted to ISO strings
652+
// before the schema validates them, matching JSON.stringify behavior
653+
// @see https://github.com/elysiajs/elysia/issues/1670
654+
// @ts-ignore - type predicate signature mismatch is intentional for Standard Schema
655+
Check: (value: unknown) => Check(serializeDates(value)),
621656
// @ts-ignore
622-
Check,
623-
// @ts-ignore
624-
Errors: (value: unknown) => Check(value)?.then?.((x) => x?.issues),
657+
Errors: (value: unknown) => Check(serializeDates(value))?.then?.((x) => x?.issues),
625658
Code: () => '',
626659
// @ts-ignore
627660
Decode: Check,
628-
// @ts-ignore
629-
Encode: (value: unknown) => value,
661+
// Serialize Date objects to ISO strings for JSON compatibility
662+
// @ts-ignore - return type mismatch is intentional for Standard Schema
663+
Encode: serializeDates,
630664
hasAdditionalProperties: false,
631665
hasDefault: false,
632666
isOptional: false,
@@ -831,12 +865,14 @@ export const getSchemaValidator = <
831865
references: '',
832866
checkFunc: () => {},
833867
code: '',
834-
// @ts-ignore
835-
Check: (v) => schema['~standard'].validate(v),
868+
// Serialize dates before validation to match JSON.stringify behavior
869+
// @see https://github.com/elysiajs/elysia/issues/1670
870+
// @ts-ignore - type predicate signature mismatch is intentional for Standard Schema
871+
Check: (v) => schema['~standard'].validate(serializeDates(v)),
836872
// @ts-ignore
837873
Errors(value: unknown) {
838874
// @ts-ignore
839-
const response = schema['~standard'].validate(value)
875+
const response = schema['~standard'].validate(serializeDates(value))
840876

841877
if (response instanceof Promise)
842878
throw Error(
@@ -858,8 +894,9 @@ export const getSchemaValidator = <
858894

859895
return response
860896
},
861-
// @ts-ignore
862-
Encode: (value: unknown) => value,
897+
// Serialize Date objects to ISO strings for JSON compatibility
898+
// @ts-ignore - return type mismatch is intentional for Standard Schema
899+
Encode: serializeDates,
863900
hasAdditionalProperties: false,
864901
hasDefault: false,
865902
isOptional: false,
@@ -941,7 +978,7 @@ export const getSchemaValidator = <
941978
references: '',
942979
checkFunc(value: unknown) {
943980
// @ts-ignore
944-
const response = schema['~standard'].validate(value)
981+
const response = schema['~standard'].validate(serializeDates(value))
945982

946983
if (response instanceof Promise)
947984
throw Error(
@@ -951,12 +988,14 @@ export const getSchemaValidator = <
951988
return response
952989
},
953990
code: '',
954-
// @ts-ignore
955-
Check: (v) => schema['~standard'].validate(v),
991+
// Serialize dates before validation to match JSON.stringify behavior
992+
// @see https://github.com/elysiajs/elysia/issues/1670
993+
// @ts-ignore - type predicate signature mismatch is intentional for Standard Schema
994+
Check: (v) => schema['~standard'].validate(serializeDates(v)),
956995
// @ts-ignore
957996
Errors(value: unknown) {
958997
// @ts-ignore
959-
const response = schema['~standard'].validate(value)
998+
const response = schema['~standard'].validate(serializeDates(value))
960999

9611000
if (response instanceof Promise)
9621001
throw Error(
@@ -978,8 +1017,9 @@ export const getSchemaValidator = <
9781017

9791018
return response
9801019
},
981-
// @ts-ignore
982-
Encode: (value: unknown) => value,
1020+
// Serialize Date objects to ISO strings for JSON compatibility
1021+
// @ts-ignore - return type mismatch is intentional for Standard Schema
1022+
Encode: serializeDates,
9831023
hasAdditionalProperties: false,
9841024
hasDefault: false,
9851025
isOptional: false,
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { Elysia, t } from '../../src'
3+
import { serializeDates } from '../../src/schema'
4+
5+
/**
6+
* Tests for Date serialization in Standard Schema validators.
7+
*
8+
* When using Standard Schema validators (Zod, Effect, etc.) with dates,
9+
* Elysia should automatically serialize Date objects to ISO strings
10+
* before response validation, matching JSON.stringify behavior.
11+
*
12+
* @see https://github.com/elysiajs/elysia/issues/1670
13+
*/
14+
describe('Date Serialization', () => {
15+
describe('serializeDates helper', () => {
16+
it('should convert Date to ISO string', () => {
17+
const date = new Date('2026-01-20T12:00:00.000Z')
18+
expect(serializeDates(date)).toBe('2026-01-20T12:00:00.000Z')
19+
})
20+
21+
it('should handle null and undefined', () => {
22+
expect(serializeDates(null)).toBe(null)
23+
expect(serializeDates(undefined)).toBe(undefined)
24+
})
25+
26+
it('should pass through primitives unchanged', () => {
27+
expect(serializeDates('hello')).toBe('hello')
28+
expect(serializeDates(42)).toBe(42)
29+
expect(serializeDates(true)).toBe(true)
30+
})
31+
32+
it('should serialize nested Date in object', () => {
33+
const date = new Date('2026-01-20T12:00:00.000Z')
34+
const result = serializeDates({
35+
name: 'test',
36+
createdAt: date
37+
})
38+
expect(result).toEqual({
39+
name: 'test',
40+
createdAt: '2026-01-20T12:00:00.000Z'
41+
})
42+
})
43+
44+
it('should serialize Date in array', () => {
45+
const date = new Date('2026-01-20T12:00:00.000Z')
46+
const result = serializeDates([date, 'other'])
47+
expect(result).toEqual(['2026-01-20T12:00:00.000Z', 'other'])
48+
})
49+
50+
it('should handle deeply nested objects with dates', () => {
51+
const date = new Date('2026-01-20T12:00:00.000Z')
52+
const result = serializeDates({
53+
user: {
54+
name: 'Alice',
55+
profile: {
56+
createdAt: date,
57+
updatedAt: date
58+
}
59+
},
60+
timestamps: [date, date]
61+
})
62+
expect(result).toEqual({
63+
user: {
64+
name: 'Alice',
65+
profile: {
66+
createdAt: '2026-01-20T12:00:00.000Z',
67+
updatedAt: '2026-01-20T12:00:00.000Z'
68+
}
69+
},
70+
timestamps: [
71+
'2026-01-20T12:00:00.000Z',
72+
'2026-01-20T12:00:00.000Z'
73+
]
74+
})
75+
})
76+
})
77+
78+
describe('Standard Schema with Date response', () => {
79+
// Mock Standard Schema interface
80+
const createMockDateSchema = () => ({
81+
'~standard': {
82+
version: 1,
83+
vendor: 'mock',
84+
validate: (value: unknown) => {
85+
if (typeof value === 'string') {
86+
// Check if valid ISO date string
87+
const date = new Date(value)
88+
if (!isNaN(date.getTime())) {
89+
return { value }
90+
}
91+
}
92+
return {
93+
issues: [{
94+
message: `Expected ISO date string, got ${typeof value}: ${value}`,
95+
path: []
96+
}]
97+
}
98+
}
99+
}
100+
})
101+
102+
it('should serialize Date to ISO string before validation', async () => {
103+
const dateSchema = createMockDateSchema()
104+
105+
const app = new Elysia().get(
106+
'/date',
107+
() => new Date('2026-01-20T12:00:00.000Z'),
108+
{
109+
response: {
110+
200: dateSchema
111+
}
112+
}
113+
)
114+
115+
const response = await app.handle(
116+
new Request('http://localhost/date')
117+
)
118+
119+
expect(response.status).toBe(200)
120+
const body = await response.text()
121+
// JSON.stringify wraps strings in quotes
122+
expect(body).toBe('2026-01-20T12:00:00.000Z')
123+
})
124+
125+
it('should serialize Date in object response', async () => {
126+
const objectWithDateSchema = {
127+
'~standard': {
128+
version: 1,
129+
vendor: 'mock',
130+
validate: (value: unknown) => {
131+
if (
132+
typeof value === 'object' &&
133+
value !== null &&
134+
'createdAt' in value &&
135+
typeof (value as any).createdAt === 'string'
136+
) {
137+
return { value }
138+
}
139+
return {
140+
issues: [{
141+
message: `Expected object with ISO date string createdAt`,
142+
path: []
143+
}]
144+
}
145+
}
146+
}
147+
}
148+
149+
const app = new Elysia().get(
150+
'/object',
151+
() => ({
152+
name: 'test',
153+
createdAt: new Date('2026-01-20T12:00:00.000Z')
154+
}),
155+
{
156+
response: {
157+
200: objectWithDateSchema
158+
}
159+
}
160+
)
161+
162+
const response = await app.handle(
163+
new Request('http://localhost/object')
164+
)
165+
166+
expect(response.status).toBe(200)
167+
const body = await response.json()
168+
expect(body).toEqual({
169+
name: 'test',
170+
createdAt: '2026-01-20T12:00:00.000Z'
171+
})
172+
})
173+
})
174+
})

0 commit comments

Comments
 (0)