@forgehive/schema 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -59
- package/dist/index.d.ts +74 -71
- package/dist/index.js +68 -182
- package/dist/test/custom-validations.test.js +45 -131
- package/dist/test/dates.test.js +41 -21
- package/dist/test/describe.test.d.ts +1 -0
- package/dist/test/describe.test.js +172 -0
- package/dist/test/from.test.d.ts +1 -0
- package/dist/test/from.test.js +162 -0
- package/dist/test/index.test.js +130 -30
- package/dist/test/optionals.test.js +22 -18
- package/dist/test/parse.test.d.ts +1 -0
- package/dist/test/parse.test.js +134 -0
- package/dist/test/record.test.js +47 -62
- package/package.json +2 -2
- package/src/index.ts +105 -251
- package/src/test/custom-validations.test.ts +45 -132
- package/src/test/dates.test.ts +45 -22
- package/src/test/describe.test.ts +190 -0
- package/src/test/from.test.ts +191 -0
- package/src/test/index.test.ts +148 -31
- package/src/test/optionals.test.ts +23 -18
- package/src/test/parse.test.ts +160 -0
- package/src/test/record.test.ts +48 -72
- package/tsconfig.json +2 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Schema, type SchemaDescription } from '../index'
|
|
2
|
+
|
|
3
|
+
// `Schema.from()` rebuilds a Schema from standard JSON Schema. Each test feeds
|
|
4
|
+
// an EXPLICIT JSON Schema object and then validates concrete data, so the
|
|
5
|
+
// accepted input format and its effect are obvious at a glance.
|
|
6
|
+
|
|
7
|
+
describe('Schema.from(JSON Schema) -> validation', () => {
|
|
8
|
+
it('basic types', () => {
|
|
9
|
+
const jsonSchema: SchemaDescription = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
name: { type: 'string' },
|
|
13
|
+
age: { type: 'number' },
|
|
14
|
+
active: { type: 'boolean' },
|
|
15
|
+
},
|
|
16
|
+
required: ['name', 'age', 'active'],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const schema = Schema.from(jsonSchema)
|
|
20
|
+
|
|
21
|
+
expect(schema.validate({ name: 'John', age: 30, active: true })).toBe(true)
|
|
22
|
+
expect(schema.validate({ name: 'John', age: '30', active: true })).toBe(false) // age not a number
|
|
23
|
+
expect(schema.validate({ name: 'John', age: 30 })).toBe(false) // active missing
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('string validations (min, max, pattern)', () => {
|
|
27
|
+
const jsonSchema: SchemaDescription = {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
username: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
minLength: 3,
|
|
33
|
+
maxLength: 20,
|
|
34
|
+
pattern: '^[a-zA-Z0-9_]+$',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ['username'],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const schema = Schema.from(jsonSchema)
|
|
41
|
+
|
|
42
|
+
expect(schema.validate({ username: 'john_doe' })).toBe(true)
|
|
43
|
+
expect(schema.validate({ username: 'ab' })).toBe(false) // too short
|
|
44
|
+
expect(schema.validate({ username: 'a'.repeat(21) })).toBe(false) // too long
|
|
45
|
+
expect(schema.validate({ username: 'john-doe' })).toBe(false) // pattern mismatch
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('number validations (minimum, maximum)', () => {
|
|
49
|
+
const jsonSchema: SchemaDescription = {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
age: { type: 'number', minimum: 18, maximum: 100 },
|
|
53
|
+
},
|
|
54
|
+
required: ['age'],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const schema = Schema.from(jsonSchema)
|
|
58
|
+
|
|
59
|
+
expect(schema.validate({ age: 25 })).toBe(true)
|
|
60
|
+
expect(schema.validate({ age: 18 })).toBe(true)
|
|
61
|
+
expect(schema.validate({ age: 17 })).toBe(false) // below minimum
|
|
62
|
+
expect(schema.validate({ age: 101 })).toBe(false) // above maximum
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('optional fields (absent from `required`)', () => {
|
|
66
|
+
const jsonSchema: SchemaDescription = {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
name: { type: 'string' },
|
|
70
|
+
age: { type: 'number' },
|
|
71
|
+
},
|
|
72
|
+
required: ['name'], // age is optional
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const schema = Schema.from(jsonSchema)
|
|
76
|
+
|
|
77
|
+
expect(schema.validate({ name: 'John', age: 30 })).toBe(true)
|
|
78
|
+
expect(schema.validate({ name: 'John' })).toBe(true) // optional age omitted
|
|
79
|
+
expect(schema.validate({ age: 30 })).toBe(false) // required name omitted
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('arrays', () => {
|
|
83
|
+
const jsonSchema: SchemaDescription = {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: {
|
|
86
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
87
|
+
},
|
|
88
|
+
required: ['tags'],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const schema = Schema.from(jsonSchema)
|
|
92
|
+
|
|
93
|
+
expect(schema.validate({ tags: ['a', 'b'] })).toBe(true)
|
|
94
|
+
expect(schema.validate({ tags: [] })).toBe(true)
|
|
95
|
+
expect(schema.validate({ tags: ['a', 2] })).toBe(false) // wrong item type
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('records (additionalProperties)', () => {
|
|
99
|
+
const jsonSchema: SchemaDescription = {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
data: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
propertyNames: { type: 'string' },
|
|
105
|
+
additionalProperties: { type: 'number' },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
required: ['data'],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const schema = Schema.from(jsonSchema)
|
|
112
|
+
|
|
113
|
+
expect(schema.validate({ data: { a: 1, b: 2 } })).toBe(true)
|
|
114
|
+
expect(schema.validate({ data: { a: 'x' } })).toBe(false) // value not a number
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('mixed records (anyOf additionalProperties)', () => {
|
|
118
|
+
const jsonSchema: SchemaDescription = {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
data: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
propertyNames: { type: 'string' },
|
|
124
|
+
additionalProperties: {
|
|
125
|
+
anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
required: ['data'],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const schema = Schema.from(jsonSchema)
|
|
133
|
+
|
|
134
|
+
expect(schema.validate({ data: { a: 'x', b: 1, c: true } })).toBe(true)
|
|
135
|
+
expect(schema.validate({ data: { a: [1, 2] } })).toBe(false) // array not allowed
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('nested objects', () => {
|
|
139
|
+
const jsonSchema: SchemaDescription = {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
user: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
name: { type: 'string', minLength: 2 },
|
|
146
|
+
age: { type: 'number' },
|
|
147
|
+
},
|
|
148
|
+
required: ['name'],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ['user'],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const schema = Schema.from(jsonSchema)
|
|
155
|
+
|
|
156
|
+
expect(schema.validate({ user: { name: 'John', age: 30 } })).toBe(true)
|
|
157
|
+
expect(schema.validate({ user: { name: 'John' } })).toBe(true) // nested age optional
|
|
158
|
+
expect(schema.validate({ user: { name: 'J' } })).toBe(false) // name too short
|
|
159
|
+
expect(schema.validate({ user: { age: 30 } })).toBe(false) // nested name required
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('string format: email', () => {
|
|
163
|
+
const jsonSchema: SchemaDescription = {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
email: { type: 'string', format: 'email' },
|
|
167
|
+
},
|
|
168
|
+
required: ['email'],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const schema = Schema.from(jsonSchema)
|
|
172
|
+
|
|
173
|
+
expect(schema.validate({ email: 'john@example.com' })).toBe(true)
|
|
174
|
+
expect(schema.validate({ email: 'not-an-email' })).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('string format: date-time', () => {
|
|
178
|
+
const jsonSchema: SchemaDescription = {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
createdAt: { type: 'string', format: 'date-time' },
|
|
182
|
+
},
|
|
183
|
+
required: ['createdAt'],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const schema = Schema.from(jsonSchema)
|
|
187
|
+
|
|
188
|
+
expect(schema.validate({ createdAt: '2024-03-20T12:00:00Z' })).toBe(true)
|
|
189
|
+
expect(schema.validate({ createdAt: 'not-a-date' })).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
})
|
package/src/test/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import Schema
|
|
1
|
+
import Schema from '../index'
|
|
2
2
|
|
|
3
3
|
describe('Schema basic types', () => {
|
|
4
4
|
it('should validate a string', () => {
|
|
@@ -28,16 +28,18 @@ describe('Schema basic types', () => {
|
|
|
28
28
|
})
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
describe('Schema description', () => {
|
|
31
|
+
describe('Schema description (JSON Schema)', () => {
|
|
32
32
|
it('should describe a string', () => {
|
|
33
33
|
const schema = new Schema({
|
|
34
34
|
name: Schema.string(),
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
const result = schema.describe()
|
|
38
|
-
expect(result).
|
|
38
|
+
expect(result.type).toBe('object')
|
|
39
|
+
expect(result.properties).toEqual({
|
|
39
40
|
name: { type: 'string' },
|
|
40
41
|
})
|
|
42
|
+
expect(result.required).toEqual(['name'])
|
|
41
43
|
})
|
|
42
44
|
|
|
43
45
|
it('should describe a string and number', () => {
|
|
@@ -47,26 +49,29 @@ describe('Schema description', () => {
|
|
|
47
49
|
})
|
|
48
50
|
|
|
49
51
|
const result = schema.describe()
|
|
50
|
-
expect(result).toEqual({
|
|
52
|
+
expect(result.properties).toEqual({
|
|
51
53
|
name: { type: 'string' },
|
|
52
54
|
age: { type: 'number' },
|
|
53
55
|
})
|
|
56
|
+
expect(result.required).toEqual(['name', 'age'])
|
|
54
57
|
})
|
|
55
58
|
})
|
|
56
59
|
|
|
57
60
|
describe('Schema hydrate', () => {
|
|
58
|
-
it('should
|
|
59
|
-
const
|
|
60
|
-
name:
|
|
61
|
-
age:
|
|
62
|
-
}
|
|
63
|
-
|
|
61
|
+
it('should rebuild a schema from its description', () => {
|
|
62
|
+
const original = new Schema({
|
|
63
|
+
name: Schema.string(),
|
|
64
|
+
age: Schema.number(),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const schema = Schema.from(original.describe())
|
|
64
68
|
|
|
65
69
|
const result = schema.describe()
|
|
66
|
-
expect(result).toEqual({
|
|
70
|
+
expect(result.properties).toEqual({
|
|
67
71
|
name: { type: 'string' },
|
|
68
72
|
age: { type: 'number' },
|
|
69
73
|
})
|
|
74
|
+
expect(result.required).toEqual(['name', 'age'])
|
|
70
75
|
})
|
|
71
76
|
})
|
|
72
77
|
|
|
@@ -96,20 +101,31 @@ describe('Schema validation errors', () => {
|
|
|
96
101
|
})
|
|
97
102
|
})
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
describe('Schema optional fields', () => {
|
|
105
|
+
it('should validate with missing optional field', () => {
|
|
106
|
+
const schema = new Schema({
|
|
107
|
+
name: Schema.string(),
|
|
108
|
+
age: Schema.number().optional(),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const result = schema.validate({
|
|
112
|
+
name: 'World',
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(result).toBe(true)
|
|
116
|
+
})
|
|
105
117
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
it('should omit optional fields from the required list', () => {
|
|
119
|
+
const schema = new Schema({
|
|
120
|
+
name: Schema.string(),
|
|
121
|
+
age: Schema.number().optional(),
|
|
122
|
+
})
|
|
109
123
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
const result = schema.describe()
|
|
125
|
+
expect(result.required).toEqual(['name'])
|
|
126
|
+
expect(result.properties).toHaveProperty('age')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
113
129
|
|
|
114
130
|
describe('Schema arrays', () => {
|
|
115
131
|
it('should validate array of strings', () => {
|
|
@@ -167,32 +183,102 @@ describe('Schema arrays', () => {
|
|
|
167
183
|
})
|
|
168
184
|
|
|
169
185
|
const result = schema.describe()
|
|
170
|
-
expect(result).toEqual({
|
|
186
|
+
expect(result.properties).toEqual({
|
|
171
187
|
tags: { type: 'array', items: { type: 'string' } },
|
|
172
188
|
scores: { type: 'array', items: { type: 'number' } },
|
|
173
189
|
})
|
|
174
190
|
})
|
|
175
191
|
|
|
176
192
|
it('should hydrate array schema from description', () => {
|
|
177
|
-
const
|
|
178
|
-
tags:
|
|
179
|
-
scores:
|
|
180
|
-
}
|
|
181
|
-
const schema = Schema.from(
|
|
193
|
+
const original = new Schema({
|
|
194
|
+
tags: Schema.array(Schema.string()),
|
|
195
|
+
scores: Schema.array(Schema.number()),
|
|
196
|
+
})
|
|
197
|
+
const schema = Schema.from(original.describe())
|
|
182
198
|
|
|
183
199
|
const result = schema.describe()
|
|
184
|
-
expect(result).toEqual({
|
|
200
|
+
expect(result.properties).toEqual({
|
|
185
201
|
tags: { type: 'array', items: { type: 'string' } },
|
|
186
202
|
scores: { type: 'array', items: { type: 'number' } },
|
|
187
203
|
})
|
|
188
204
|
})
|
|
189
205
|
})
|
|
190
206
|
|
|
207
|
+
describe('Schema nested objects', () => {
|
|
208
|
+
it('should validate and describe nested object fields', () => {
|
|
209
|
+
const schema = new Schema({
|
|
210
|
+
user: Schema.object({
|
|
211
|
+
name: Schema.string(),
|
|
212
|
+
age: Schema.number().optional(),
|
|
213
|
+
}),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
expect(schema.validate({ user: { name: 'John', age: 30 } })).toBe(true)
|
|
217
|
+
expect(schema.validate({ user: { name: 'John' } })).toBe(true)
|
|
218
|
+
expect(schema.validate({ user: { age: 30 } })).toBe(false)
|
|
219
|
+
|
|
220
|
+
const result = schema.describe()
|
|
221
|
+
expect(result.properties?.user).toMatchObject({
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: { name: { type: 'string' }, age: { type: 'number' } },
|
|
224
|
+
required: ['name'],
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('should round-trip a nested object schema', () => {
|
|
229
|
+
const original = new Schema({
|
|
230
|
+
user: Schema.object({
|
|
231
|
+
name: Schema.string().min(3),
|
|
232
|
+
}),
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const clone = Schema.from(original.describe())
|
|
236
|
+
expect(clone.validate({ user: { name: 'John' } })).toBe(true)
|
|
237
|
+
expect(clone.validate({ user: { name: 'Jo' } })).toBe(false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('should infer types through safeParse for an optional nested object', () => {
|
|
241
|
+
const base = new Schema({
|
|
242
|
+
name: Schema.string().describe('The name of the user'),
|
|
243
|
+
address: Schema.object({
|
|
244
|
+
street: Schema.string(),
|
|
245
|
+
city: Schema.string(),
|
|
246
|
+
state: Schema.string(),
|
|
247
|
+
zip: Schema.string(),
|
|
248
|
+
}).optional().describe('The address of the user'),
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Optional nested object omitted
|
|
252
|
+
const withoutAddress = base.safeParse({ name: 'World' })
|
|
253
|
+
expect(withoutAddress.success).toBe(true)
|
|
254
|
+
if (withoutAddress.success) {
|
|
255
|
+
// res.data.name is string, res.data.address is optional
|
|
256
|
+
const name: string = withoutAddress.data.name
|
|
257
|
+
const street: string = withoutAddress.data.address?.street ?? ''
|
|
258
|
+
expect(name).toBe('World')
|
|
259
|
+
expect(street).toBe('')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Optional nested object present
|
|
263
|
+
const withAddress = base.safeParse({
|
|
264
|
+
name: 'World',
|
|
265
|
+
address: { street: 'Main', city: 'Town', state: 'CA', zip: '00000' },
|
|
266
|
+
})
|
|
267
|
+
expect(withAddress.success).toBe(true)
|
|
268
|
+
if (withAddress.success) {
|
|
269
|
+
expect(withAddress.data.address?.street).toBe('Main')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Partial nested object is rejected (street/city/... required when present)
|
|
273
|
+
expect(base.validate({ name: 'World', address: { street: 'Main' } })).toBe(false)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
191
277
|
describe('Schema custom validation', () => {
|
|
192
278
|
it('should validate with custom rules', () => {
|
|
193
279
|
const schema = new Schema({
|
|
194
280
|
age: Schema.number().min(0).max(120),
|
|
195
|
-
email: Schema.
|
|
281
|
+
email: Schema.email(),
|
|
196
282
|
})
|
|
197
283
|
|
|
198
284
|
const result = schema.validate({
|
|
@@ -202,4 +288,35 @@ describe('Schema custom validation', () => {
|
|
|
202
288
|
|
|
203
289
|
expect(result).toBe(true)
|
|
204
290
|
})
|
|
291
|
+
|
|
292
|
+
it('should serialize element descriptions', () => {
|
|
293
|
+
const schema = new Schema({
|
|
294
|
+
name: Schema.string().describe('the user name'),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const result = schema.describe()
|
|
298
|
+
expect(result.properties?.name).toMatchObject({
|
|
299
|
+
type: 'string',
|
|
300
|
+
description: 'the user name',
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('should keep element descriptions through the describe/from round-trip', () => {
|
|
305
|
+
const schema = new Schema({
|
|
306
|
+
name: Schema.string().describe('The name of the user'),
|
|
307
|
+
age: Schema.number().describe('The age of the user'),
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const clone = Schema.from(schema.describe())
|
|
311
|
+
const result = clone.describe()
|
|
312
|
+
|
|
313
|
+
expect(result.properties?.name).toMatchObject({
|
|
314
|
+
type: 'string',
|
|
315
|
+
description: 'The name of the user',
|
|
316
|
+
})
|
|
317
|
+
expect(result.properties?.age).toMatchObject({
|
|
318
|
+
type: 'number',
|
|
319
|
+
description: 'The age of the user',
|
|
320
|
+
})
|
|
321
|
+
})
|
|
205
322
|
})
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Schema } from '../index'
|
|
2
2
|
|
|
3
|
-
describe('Schema
|
|
3
|
+
describe('Schema Optional Fields', () => {
|
|
4
4
|
describe('Optional Fields', () => {
|
|
5
5
|
it('should handle optional string fields', () => {
|
|
6
|
-
const schema = Schema
|
|
7
|
-
name:
|
|
8
|
-
age:
|
|
6
|
+
const schema = new Schema({
|
|
7
|
+
name: Schema.string().optional(),
|
|
8
|
+
age: Schema.number()
|
|
9
9
|
})
|
|
10
10
|
|
|
11
11
|
// Valid with optional field present
|
|
@@ -19,9 +19,9 @@ describe('Schema Custom Validations', () => {
|
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
it('should handle optional array fields', () => {
|
|
22
|
-
const schema = Schema
|
|
23
|
-
tags:
|
|
24
|
-
scores:
|
|
22
|
+
const schema = new Schema({
|
|
23
|
+
tags: Schema.array(Schema.string()).optional(),
|
|
24
|
+
scores: Schema.array(Schema.number())
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
// Valid with optional array present
|
|
@@ -35,26 +35,28 @@ describe('Schema Custom Validations', () => {
|
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
it('should correctly describe optional fields', () => {
|
|
38
|
-
const schema = Schema
|
|
39
|
-
name:
|
|
40
|
-
age:
|
|
41
|
-
tags:
|
|
38
|
+
const schema = new Schema({
|
|
39
|
+
name: Schema.string().optional(),
|
|
40
|
+
age: Schema.number(),
|
|
41
|
+
tags: Schema.array(Schema.string()).optional()
|
|
42
42
|
})
|
|
43
43
|
|
|
44
44
|
const description = schema.describe()
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
expect(description.
|
|
48
|
-
expect(description.
|
|
46
|
+
// Optionality is expressed by absence from the `required` list
|
|
47
|
+
expect(description.required).toEqual(['age'])
|
|
48
|
+
expect(description.properties?.name).toEqual({ type: 'string' })
|
|
49
|
+
expect(description.properties?.age).toEqual({ type: 'number' })
|
|
50
|
+
expect(description.properties?.tags).toEqual({ type: 'array', items: { type: 'string' } })
|
|
49
51
|
})
|
|
50
52
|
})
|
|
51
53
|
|
|
52
54
|
describe('Schema Roundtrip', () => {
|
|
53
55
|
it('should maintain optional fields through describe/from cycle', () => {
|
|
54
|
-
const originalSchema = Schema
|
|
55
|
-
name:
|
|
56
|
-
age:
|
|
57
|
-
tags:
|
|
56
|
+
const originalSchema = new Schema({
|
|
57
|
+
name: Schema.string().optional(),
|
|
58
|
+
age: Schema.number(),
|
|
59
|
+
tags: Schema.array(Schema.string()).optional()
|
|
58
60
|
})
|
|
59
61
|
|
|
60
62
|
const description = originalSchema.describe()
|
|
@@ -68,6 +70,9 @@ describe('Schema Custom Validations', () => {
|
|
|
68
70
|
expect(reconstructedSchema.validate(validData)).toBe(true)
|
|
69
71
|
expect(originalSchema.validate(dataWithoutOptional)).toBe(true)
|
|
70
72
|
expect(reconstructedSchema.validate(dataWithoutOptional)).toBe(true)
|
|
73
|
+
|
|
74
|
+
// The reconstructed schema preserves which field stayed required
|
|
75
|
+
expect(reconstructedSchema.describe().required).toEqual(['age'])
|
|
71
76
|
})
|
|
72
77
|
})
|
|
73
78
|
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Schema } from '../index'
|
|
2
|
+
|
|
3
|
+
describe('Schema.parse()', () => {
|
|
4
|
+
it('returns typed data on success', () => {
|
|
5
|
+
const schema = new Schema({
|
|
6
|
+
name: Schema.string(),
|
|
7
|
+
age: Schema.number(),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const data = schema.parse({ name: 'John', age: 30 })
|
|
11
|
+
|
|
12
|
+
expect(data).toEqual({ name: 'John', age: 30 })
|
|
13
|
+
// Type-level: the result is { name: string; age: number }
|
|
14
|
+
const name: string = data.name
|
|
15
|
+
const age: number = data.age
|
|
16
|
+
expect(name).toBe('John')
|
|
17
|
+
expect(age).toBe(30)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('strips unknown keys', () => {
|
|
21
|
+
const schema = new Schema({
|
|
22
|
+
name: Schema.string(),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const data = schema.parse({ name: 'John', extra: 'ignored' })
|
|
26
|
+
|
|
27
|
+
expect(data).toEqual({ name: 'John' })
|
|
28
|
+
expect(data).not.toHaveProperty('extra')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('coerces nothing and throws ZodError on invalid data', () => {
|
|
32
|
+
const schema = new Schema({
|
|
33
|
+
name: Schema.string(),
|
|
34
|
+
age: Schema.number(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(() => schema.parse({ name: 'John', age: 'thirty' })).toThrow()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('throws a ZodError exposing issues with path and message', () => {
|
|
41
|
+
const schema = new Schema({
|
|
42
|
+
name: Schema.string(),
|
|
43
|
+
age: Schema.number().min(18),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
schema.parse({ name: 123, age: 10 })
|
|
48
|
+
throw new Error('expected parse to throw')
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const zodError = err as { issues?: Array<{ path: PropertyKey[]; message: string }> }
|
|
51
|
+
expect(Array.isArray(zodError.issues)).toBe(true)
|
|
52
|
+
const paths = (zodError.issues ?? []).map((issue) => issue.path.join('.'))
|
|
53
|
+
expect(paths).toContain('name')
|
|
54
|
+
expect(paths).toContain('age')
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('parses nested objects and optionals', () => {
|
|
59
|
+
const schema = new Schema({
|
|
60
|
+
user: Schema.object({
|
|
61
|
+
name: Schema.string(),
|
|
62
|
+
nickname: Schema.string().optional(),
|
|
63
|
+
}),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(schema.parse({ user: { name: 'John' } })).toEqual({ user: { name: 'John' } })
|
|
67
|
+
expect(schema.parse({ user: { name: 'John', nickname: 'JJ' } })).toEqual({
|
|
68
|
+
user: { name: 'John', nickname: 'JJ' },
|
|
69
|
+
})
|
|
70
|
+
expect(() => schema.parse({ user: {} })).toThrow()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('Schema.safeParse()', () => {
|
|
75
|
+
it('returns success: true with data on valid input', () => {
|
|
76
|
+
const schema = new Schema({
|
|
77
|
+
name: Schema.string(),
|
|
78
|
+
age: Schema.number(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const result = schema.safeParse({ name: 'John', age: 30 })
|
|
82
|
+
|
|
83
|
+
expect(result.success).toBe(true)
|
|
84
|
+
if (result.success) {
|
|
85
|
+
expect(result.data).toEqual({ name: 'John', age: 30 })
|
|
86
|
+
// Type-level: result.data is fully typed
|
|
87
|
+
const name: string = result.data.name
|
|
88
|
+
expect(name).toBe('John')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns success: false with an error on invalid input', () => {
|
|
93
|
+
const schema = new Schema({
|
|
94
|
+
name: Schema.string(),
|
|
95
|
+
age: Schema.number(),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const result = schema.safeParse({ name: 'John', age: 'thirty' })
|
|
99
|
+
|
|
100
|
+
expect(result.success).toBe(false)
|
|
101
|
+
if (!result.success) {
|
|
102
|
+
expect(result.error).toBeDefined()
|
|
103
|
+
expect(Array.isArray(result.error.issues)).toBe(true)
|
|
104
|
+
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
|
105
|
+
expect(paths).toContain('age')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not throw on invalid input', () => {
|
|
110
|
+
const schema = new Schema({
|
|
111
|
+
age: Schema.number(),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(() => schema.safeParse({ age: 'nope' })).not.toThrow()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('reports each failing field in the issues list', () => {
|
|
118
|
+
const schema = new Schema({
|
|
119
|
+
email: Schema.email(),
|
|
120
|
+
age: Schema.number().min(18),
|
|
121
|
+
username: Schema.string().min(3),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const result = schema.safeParse({ email: 'nope', age: 5, username: 'ab' })
|
|
125
|
+
|
|
126
|
+
expect(result.success).toBe(false)
|
|
127
|
+
if (!result.success) {
|
|
128
|
+
const paths = result.error.issues.map((issue) => issue.path.join('.'))
|
|
129
|
+
expect(paths).toEqual(expect.arrayContaining(['email', 'age', 'username']))
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('strips unknown keys on success', () => {
|
|
134
|
+
const schema = new Schema({
|
|
135
|
+
name: Schema.string(),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const result = schema.safeParse({ name: 'John', extra: true })
|
|
139
|
+
|
|
140
|
+
expect(result.success).toBe(true)
|
|
141
|
+
if (result.success) {
|
|
142
|
+
expect(result.data).toEqual({ name: 'John' })
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('infers optional fields as possibly undefined', () => {
|
|
147
|
+
const schema = new Schema({
|
|
148
|
+
name: Schema.string(),
|
|
149
|
+
age: Schema.number().optional(),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const result = schema.safeParse({ name: 'John' })
|
|
153
|
+
|
|
154
|
+
expect(result.success).toBe(true)
|
|
155
|
+
if (result.success) {
|
|
156
|
+
const age: number | undefined = result.data.age
|
|
157
|
+
expect(age).toBeUndefined()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|