@vertz/schema 0.1.0 → 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 +640 -0
- package/dist/index.d.ts +22 -15
- package/dist/index.js +14 -2
- package/package.json +6 -3
package/README.md
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
# @vertz/schema
|
|
2
|
+
|
|
3
|
+
> Type-safe schema definition and validation for JavaScript/TypeScript
|
|
4
|
+
|
|
5
|
+
A powerful validation library with end-to-end type inference, inspired by Zod. Define schemas with a fluent API and get full TypeScript types automatically.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- **Node.js** 18+ or **Bun** 1.0+
|
|
10
|
+
- **TypeScript** 5.0+
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# npm
|
|
16
|
+
npm install @vertz/schema
|
|
17
|
+
|
|
18
|
+
# bun
|
|
19
|
+
bun add @vertz/schema
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { s } from '@vertz/schema';
|
|
26
|
+
|
|
27
|
+
// Define a schema
|
|
28
|
+
const userSchema = s.object({
|
|
29
|
+
name: s.string().min(1),
|
|
30
|
+
email: s.string().email(),
|
|
31
|
+
age: s.number().int().min(18),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Parse data (throws on invalid)
|
|
35
|
+
const user = userSchema.parse({
|
|
36
|
+
name: 'Alice',
|
|
37
|
+
email: 'alice@example.com',
|
|
38
|
+
age: 25,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Safe parse (returns success/error)
|
|
42
|
+
const result = userSchema.safeParse({
|
|
43
|
+
name: 'Bob',
|
|
44
|
+
email: 'not-an-email',
|
|
45
|
+
age: 17,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (result.success) {
|
|
49
|
+
console.log('Valid user:', result.value);
|
|
50
|
+
} else {
|
|
51
|
+
console.log('Validation errors:', result.error.issues);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Type inference
|
|
55
|
+
type User = typeof userSchema._output;
|
|
56
|
+
// ✅ { name: string; email: string; age: number }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Core Concepts
|
|
60
|
+
|
|
61
|
+
### Primitives
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { s } from '@vertz/schema';
|
|
65
|
+
|
|
66
|
+
s.string() // string
|
|
67
|
+
s.number() // number
|
|
68
|
+
s.boolean() // boolean
|
|
69
|
+
s.bigint() // bigint
|
|
70
|
+
s.date() // Date
|
|
71
|
+
s.symbol() // symbol
|
|
72
|
+
s.int() // number (integer)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Special Types
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
s.any() // any
|
|
79
|
+
s.unknown() // unknown
|
|
80
|
+
s.null() // null
|
|
81
|
+
s.undefined() // undefined
|
|
82
|
+
s.void() // void
|
|
83
|
+
s.never() // never
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Composite Types
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// Objects
|
|
90
|
+
const personSchema = s.object({
|
|
91
|
+
name: s.string(),
|
|
92
|
+
age: s.number(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Arrays
|
|
96
|
+
const numbersSchema = s.array(s.number());
|
|
97
|
+
|
|
98
|
+
// Tuples
|
|
99
|
+
const pairSchema = s.tuple([s.string(), s.number()]);
|
|
100
|
+
|
|
101
|
+
// Enums
|
|
102
|
+
const roleSchema = s.enum(['admin', 'user', 'guest']);
|
|
103
|
+
|
|
104
|
+
// Literals
|
|
105
|
+
const yesSchema = s.literal('yes');
|
|
106
|
+
|
|
107
|
+
// Unions
|
|
108
|
+
const statusSchema = s.union([
|
|
109
|
+
s.literal('pending'),
|
|
110
|
+
s.literal('approved'),
|
|
111
|
+
s.literal('rejected'),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
// Records (dynamic keys)
|
|
115
|
+
const configSchema = s.record(s.string());
|
|
116
|
+
|
|
117
|
+
// Maps
|
|
118
|
+
const mapSchema = s.map(s.string(), s.number());
|
|
119
|
+
|
|
120
|
+
// Sets
|
|
121
|
+
const setSchema = s.set(s.string());
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### String Validations
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
const schema = s.string()
|
|
128
|
+
.min(3) // Min length
|
|
129
|
+
.max(20) // Max length
|
|
130
|
+
.length(10) // Exact length
|
|
131
|
+
.regex(/^[a-z]+$/) // Pattern matching
|
|
132
|
+
.startsWith('hello') // Prefix check
|
|
133
|
+
.endsWith('world') // Suffix check
|
|
134
|
+
.includes('mid') // Substring check
|
|
135
|
+
.uppercase() // Must be all uppercase
|
|
136
|
+
.lowercase() // Must be all lowercase
|
|
137
|
+
.nonempty() // Alias for .min(1)
|
|
138
|
+
.trim(); // Trim whitespace (transforms)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Number Validations
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const schema = s.number()
|
|
145
|
+
.int() // Must be integer
|
|
146
|
+
.positive() // > 0
|
|
147
|
+
.negative() // < 0
|
|
148
|
+
.nonpositive() // <= 0
|
|
149
|
+
.nonnegative() // >= 0
|
|
150
|
+
.min(0) // Minimum value
|
|
151
|
+
.max(100) // Maximum value
|
|
152
|
+
.multipleOf(5) // Must be divisible by n
|
|
153
|
+
.finite(); // No Infinity or NaN
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Format Validators
|
|
157
|
+
|
|
158
|
+
Built-in validators for common formats:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
s.email() // Email address
|
|
162
|
+
s.uuid() // UUID (v1-v5)
|
|
163
|
+
s.url() // HTTP(S) URL
|
|
164
|
+
s.hostname() // Valid hostname
|
|
165
|
+
s.ipv4() // IPv4 address
|
|
166
|
+
s.ipv6() // IPv6 address
|
|
167
|
+
s.base64() // Base64 string
|
|
168
|
+
s.hex() // Hexadecimal string
|
|
169
|
+
s.jwt() // JWT token (format only, not verified)
|
|
170
|
+
s.cuid() // CUID
|
|
171
|
+
s.ulid() // ULID
|
|
172
|
+
s.nanoid() // Nano ID
|
|
173
|
+
|
|
174
|
+
// ISO formats
|
|
175
|
+
s.iso.date() // ISO 8601 date (YYYY-MM-DD)
|
|
176
|
+
s.iso.time() // ISO 8601 time (HH:MM:SS)
|
|
177
|
+
s.iso.datetime() // ISO 8601 datetime
|
|
178
|
+
s.iso.duration() // ISO 8601 duration (P1Y2M3D)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Optional and Nullable
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
const schema = s.string().optional();
|
|
185
|
+
// string | undefined
|
|
186
|
+
|
|
187
|
+
const schema2 = s.string().nullable();
|
|
188
|
+
// string | null
|
|
189
|
+
|
|
190
|
+
const schema3 = s.string().nullish();
|
|
191
|
+
// string | null | undefined
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Default Values
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const schema = s.string().default('hello');
|
|
198
|
+
|
|
199
|
+
schema.parse(undefined); // 'hello'
|
|
200
|
+
schema.parse('world'); // 'world'
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Transformations
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
const schema = s.string().transform((val) => val.toUpperCase());
|
|
207
|
+
|
|
208
|
+
schema.parse('hello'); // 'HELLO'
|
|
209
|
+
|
|
210
|
+
// Chain transformations
|
|
211
|
+
const trimmed = s.string().trim().transform((s) => s.toUpperCase());
|
|
212
|
+
trimmed.parse(' hello '); // 'HELLO'
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Refinements (Custom Validation)
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const schema = s.string().refine(
|
|
219
|
+
(val) => val.includes('@'),
|
|
220
|
+
{ message: 'Must contain @' },
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Multiple refinements
|
|
224
|
+
const passwordSchema = s.string()
|
|
225
|
+
.min(8)
|
|
226
|
+
.refine((val) => /[A-Z]/.test(val), {
|
|
227
|
+
message: 'Must contain uppercase letter',
|
|
228
|
+
})
|
|
229
|
+
.refine((val) => /[0-9]/.test(val), {
|
|
230
|
+
message: 'Must contain number',
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Super Refine (Access to Context)
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
const schema = s.object({
|
|
238
|
+
password: s.string(),
|
|
239
|
+
confirmPassword: s.string(),
|
|
240
|
+
}).superRefine((data, ctx) => {
|
|
241
|
+
if (data.password !== data.confirmPassword) {
|
|
242
|
+
ctx.addIssue({
|
|
243
|
+
code: 'custom',
|
|
244
|
+
path: ['confirmPassword'],
|
|
245
|
+
message: 'Passwords must match',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Branded Types
|
|
252
|
+
|
|
253
|
+
Create nominal types that are structurally identical but semantically distinct:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
const userIdSchema = s.string().uuid().brand('UserId');
|
|
257
|
+
const postIdSchema = s.string().uuid().brand('PostId');
|
|
258
|
+
|
|
259
|
+
type UserId = typeof userIdSchema._output; // string & Brand<'UserId'>
|
|
260
|
+
type PostId = typeof postIdSchema._output; // string & Brand<'PostId'>
|
|
261
|
+
|
|
262
|
+
// Type error: UserId and PostId are not assignable to each other
|
|
263
|
+
function getUser(id: UserId) { /* ... */ }
|
|
264
|
+
function getPost(id: PostId) { /* ... */ }
|
|
265
|
+
|
|
266
|
+
const userId = userIdSchema.parse('...');
|
|
267
|
+
getUser(userId); // ✅ OK
|
|
268
|
+
getPost(userId); // ❌ Type error
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Readonly
|
|
272
|
+
|
|
273
|
+
Mark types as readonly in the type system:
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
const schema = s.object({
|
|
277
|
+
name: s.string(),
|
|
278
|
+
tags: s.array(s.string()),
|
|
279
|
+
}).readonly();
|
|
280
|
+
|
|
281
|
+
type Result = typeof schema._output;
|
|
282
|
+
// Readonly<{ name: string; tags: readonly string[] }>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Catch (Error Recovery)
|
|
286
|
+
|
|
287
|
+
Provide fallback values on parse errors:
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
const schema = s.number().catch(0);
|
|
291
|
+
|
|
292
|
+
schema.parse(42); // 42
|
|
293
|
+
schema.parse('invalid'); // 0 (caught and replaced)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Parsing
|
|
297
|
+
|
|
298
|
+
### `.parse(data)`
|
|
299
|
+
|
|
300
|
+
Parses and returns the data. Throws `ParseError` on validation failure.
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
const schema = s.string();
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const value = schema.parse('hello'); // 'hello'
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error instanceof ParseError) {
|
|
309
|
+
console.log(error.issues);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### `.safeParse(data)`
|
|
315
|
+
|
|
316
|
+
Returns a result object with `success` boolean. Never throws.
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
const schema = s.number();
|
|
320
|
+
|
|
321
|
+
const result = schema.safeParse('42');
|
|
322
|
+
|
|
323
|
+
if (result.success) {
|
|
324
|
+
console.log(result.value); // number
|
|
325
|
+
} else {
|
|
326
|
+
console.log(result.error.issues); // ValidationIssue[]
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Type Inference
|
|
331
|
+
|
|
332
|
+
### Output Types (Parsed Value)
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
import type { Infer, Output } from '@vertz/schema';
|
|
336
|
+
|
|
337
|
+
const schema = s.object({
|
|
338
|
+
name: s.string(),
|
|
339
|
+
age: s.number().optional(),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// All equivalent:
|
|
343
|
+
type User1 = typeof schema._output;
|
|
344
|
+
type User2 = Infer<typeof schema>;
|
|
345
|
+
type User3 = Output<typeof schema>;
|
|
346
|
+
|
|
347
|
+
// Result: { name: string; age?: number }
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Input Types (Before Parsing)
|
|
351
|
+
|
|
352
|
+
Use `Input<T>` for the type before transformations:
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
import type { Input } from '@vertz/schema';
|
|
356
|
+
|
|
357
|
+
const schema = s.string().transform((s) => s.length);
|
|
358
|
+
|
|
359
|
+
type In = Input<typeof schema>; // string
|
|
360
|
+
type Out = Output<typeof schema>; // number
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Coercion
|
|
364
|
+
|
|
365
|
+
Convert values to the target type:
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import { s } from '@vertz/schema';
|
|
369
|
+
|
|
370
|
+
const schema = s.coerce.number();
|
|
371
|
+
|
|
372
|
+
schema.parse('42'); // 42 (string → number)
|
|
373
|
+
schema.parse(42); // 42 (already number)
|
|
374
|
+
|
|
375
|
+
// Available coercions:
|
|
376
|
+
s.coerce.string() // → string
|
|
377
|
+
s.coerce.number() // → number
|
|
378
|
+
s.coerce.boolean() // → boolean
|
|
379
|
+
s.coerce.bigint() // → bigint
|
|
380
|
+
s.coerce.date() // → Date
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## JSON Schema Generation
|
|
384
|
+
|
|
385
|
+
Generate JSON Schema for interoperability:
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import { toJSONSchema } from '@vertz/schema';
|
|
389
|
+
|
|
390
|
+
const schema = s.object({
|
|
391
|
+
name: s.string().min(1),
|
|
392
|
+
age: s.number().int().min(0),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const jsonSchema = toJSONSchema(schema);
|
|
396
|
+
/*
|
|
397
|
+
{
|
|
398
|
+
type: 'object',
|
|
399
|
+
properties: {
|
|
400
|
+
name: { type: 'string', minLength: 1 },
|
|
401
|
+
age: { type: 'integer', minimum: 0 }
|
|
402
|
+
},
|
|
403
|
+
required: ['name', 'age']
|
|
404
|
+
}
|
|
405
|
+
*/
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
## Schema Registry
|
|
409
|
+
|
|
410
|
+
Register and reuse schemas by name:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
import { SchemaRegistry } from '@vertz/schema';
|
|
414
|
+
|
|
415
|
+
const registry = new SchemaRegistry();
|
|
416
|
+
|
|
417
|
+
registry.register('User', s.object({
|
|
418
|
+
id: s.string().uuid(),
|
|
419
|
+
name: s.string(),
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
registry.register('Post', s.object({
|
|
423
|
+
id: s.string().uuid(),
|
|
424
|
+
authorId: registry.ref('User').shape.id, // Reference other schemas
|
|
425
|
+
title: s.string(),
|
|
426
|
+
}));
|
|
427
|
+
|
|
428
|
+
const userSchema = registry.get('User');
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Advanced Patterns
|
|
432
|
+
|
|
433
|
+
### Discriminated Unions
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
const messageSchema = s.discriminatedUnion('type', [
|
|
437
|
+
s.object({
|
|
438
|
+
type: s.literal('text'),
|
|
439
|
+
content: s.string(),
|
|
440
|
+
}),
|
|
441
|
+
s.object({
|
|
442
|
+
type: s.literal('image'),
|
|
443
|
+
url: s.url(),
|
|
444
|
+
alt: s.string().optional(),
|
|
445
|
+
}),
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
type Message = typeof messageSchema._output;
|
|
449
|
+
// { type: 'text'; content: string } | { type: 'image'; url: string; alt?: string }
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Recursive Types (with `lazy`)
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
interface Category {
|
|
456
|
+
name: string;
|
|
457
|
+
subcategories: Category[];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const categorySchema: s.Schema<Category> = s.object({
|
|
461
|
+
name: s.string(),
|
|
462
|
+
subcategories: s.lazy(() => s.array(categorySchema)),
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Intersection
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
const baseSchema = s.object({ id: s.string() });
|
|
470
|
+
const namedSchema = s.object({ name: s.string() });
|
|
471
|
+
|
|
472
|
+
const userSchema = s.intersection(baseSchema, namedSchema);
|
|
473
|
+
// { id: string; name: string }
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Custom Validators
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
const evenSchema = s.custom<number>(
|
|
480
|
+
(val) => typeof val === 'number' && val % 2 === 0,
|
|
481
|
+
'Must be an even number',
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
evenSchema.parse(4); // ✅ 4
|
|
485
|
+
evenSchema.parse(5); // ❌ throws
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### File Validation
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
const imageSchema = s.file()
|
|
492
|
+
.maxSize(5 * 1024 * 1024) // 5MB
|
|
493
|
+
.mimeType(['image/png', 'image/jpeg', 'image/webp']);
|
|
494
|
+
|
|
495
|
+
imageSchema.parse(file); // File object (browser or Node.js)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## Integration with @vertz/core
|
|
499
|
+
|
|
500
|
+
Use schemas for request validation in vertz apps:
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
import { createModuleDef } from '@vertz/core';
|
|
504
|
+
import { s } from '@vertz/schema';
|
|
505
|
+
|
|
506
|
+
const moduleDef = createModuleDef({ name: 'users' });
|
|
507
|
+
|
|
508
|
+
const createUserSchema = s.object({
|
|
509
|
+
name: s.string().min(1),
|
|
510
|
+
email: s.string().email(),
|
|
511
|
+
age: s.number().int().min(18),
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const router = moduleDef.router({ prefix: '/users' });
|
|
515
|
+
|
|
516
|
+
router.post('/', {
|
|
517
|
+
body: createUserSchema,
|
|
518
|
+
handler: (ctx) => {
|
|
519
|
+
// ctx.body is fully typed as { name: string; email: string; age: number }
|
|
520
|
+
const { name, email, age } = ctx.body;
|
|
521
|
+
return { created: true, user: { name, email, age } };
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
If validation fails, a `ValidationException` is automatically thrown with details.
|
|
527
|
+
|
|
528
|
+
## Error Handling
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
import { ParseError } from '@vertz/schema';
|
|
532
|
+
|
|
533
|
+
const result = schema.safeParse(data);
|
|
534
|
+
|
|
535
|
+
if (!result.success) {
|
|
536
|
+
const { error } = result;
|
|
537
|
+
|
|
538
|
+
console.log(error.issues);
|
|
539
|
+
/*
|
|
540
|
+
[
|
|
541
|
+
{
|
|
542
|
+
code: 'invalid_type',
|
|
543
|
+
expected: 'string',
|
|
544
|
+
received: 'number',
|
|
545
|
+
path: ['name'],
|
|
546
|
+
message: 'Expected string, received number'
|
|
547
|
+
},
|
|
548
|
+
...
|
|
549
|
+
]
|
|
550
|
+
*/
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
## Comparison to Zod
|
|
555
|
+
|
|
556
|
+
`@vertz/schema` is heavily inspired by Zod with similar API design:
|
|
557
|
+
|
|
558
|
+
| Feature | @vertz/schema | Zod |
|
|
559
|
+
|---------|--------------|-----|
|
|
560
|
+
| Type inference | ✅ | ✅ |
|
|
561
|
+
| Primitives | ✅ | ✅ |
|
|
562
|
+
| Objects/Arrays | ✅ | ✅ |
|
|
563
|
+
| Transformations | ✅ | ✅ |
|
|
564
|
+
| Refinements | ✅ | ✅ |
|
|
565
|
+
| Branded types | ✅ | ✅ |
|
|
566
|
+
| JSON Schema export | ✅ | ✅ |
|
|
567
|
+
| Schema registry | ✅ | ❌ |
|
|
568
|
+
| Format validators | ✅ (built-in) | ❌ (plugin) |
|
|
569
|
+
| ISO format methods | ✅ (`s.iso.*`) | ❌ |
|
|
570
|
+
|
|
571
|
+
If you're familiar with Zod, you should feel right at home!
|
|
572
|
+
|
|
573
|
+
## API Reference
|
|
574
|
+
|
|
575
|
+
### Factory Functions
|
|
576
|
+
|
|
577
|
+
All schemas are created via the `s` object:
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
import { s } from '@vertz/schema';
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Schema Methods
|
|
584
|
+
|
|
585
|
+
All schemas inherit these methods:
|
|
586
|
+
|
|
587
|
+
- `.parse(data)` — Parse and return (throws on error)
|
|
588
|
+
- `.safeParse(data)` — Parse and return `{ success, value?, error? }`
|
|
589
|
+
- `.optional()` — Make schema optional (`T | undefined`)
|
|
590
|
+
- `.nullable()` — Make schema nullable (`T | null`)
|
|
591
|
+
- `.nullish()` — Make schema nullish (`T | null | undefined`)
|
|
592
|
+
- `.default(value)` — Provide default value
|
|
593
|
+
- `.transform(fn)` — Transform the value after validation
|
|
594
|
+
- `.refine(fn, opts)` — Add custom validation
|
|
595
|
+
- `.superRefine(fn)` — Add custom validation with context
|
|
596
|
+
- `.brand<Brand>()` — Create branded type
|
|
597
|
+
- `.readonly()` — Mark as readonly
|
|
598
|
+
- `.catch(value)` — Provide fallback on error
|
|
599
|
+
- `.optional()` — Alias for `.or(s.undefined())`
|
|
600
|
+
|
|
601
|
+
## TypeScript Tips
|
|
602
|
+
|
|
603
|
+
### Extracting Types
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
const schema = s.object({
|
|
607
|
+
name: s.string(),
|
|
608
|
+
age: s.number(),
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// Extract output type
|
|
612
|
+
type User = typeof schema._output;
|
|
613
|
+
|
|
614
|
+
// Extract input type (before transforms)
|
|
615
|
+
type UserInput = typeof schema._input;
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
### Extending Schemas
|
|
619
|
+
|
|
620
|
+
```typescript
|
|
621
|
+
const baseUserSchema = s.object({
|
|
622
|
+
id: s.string().uuid(),
|
|
623
|
+
createdAt: s.date(),
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const userWithEmailSchema = baseUserSchema.extend({
|
|
627
|
+
email: s.string().email(),
|
|
628
|
+
});
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
## Performance
|
|
632
|
+
|
|
633
|
+
- Schema definitions are immutable and reusable
|
|
634
|
+
- No code generation — pure runtime validation
|
|
635
|
+
- Optimized for common cases (primitives, objects, arrays)
|
|
636
|
+
- JSON Schema generation is cached
|
|
637
|
+
|
|
638
|
+
## License
|
|
639
|
+
|
|
640
|
+
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -380,26 +380,26 @@ declare class StringSchema extends Schema<string> {
|
|
|
380
380
|
private _toUpperCase;
|
|
381
381
|
private _normalize;
|
|
382
382
|
_parse(value: unknown, ctx: ParseContext): string;
|
|
383
|
-
min(n: number, message?: string):
|
|
384
|
-
max(n: number, message?: string):
|
|
385
|
-
length(n: number, message?: string):
|
|
386
|
-
regex(pattern: RegExp):
|
|
387
|
-
startsWith(prefix: string):
|
|
388
|
-
endsWith(suffix: string):
|
|
389
|
-
includes(substring: string):
|
|
390
|
-
uppercase():
|
|
391
|
-
lowercase():
|
|
392
|
-
trim():
|
|
393
|
-
toLowerCase():
|
|
394
|
-
toUpperCase():
|
|
395
|
-
normalize():
|
|
383
|
+
min(n: number, message?: string): this;
|
|
384
|
+
max(n: number, message?: string): this;
|
|
385
|
+
length(n: number, message?: string): this;
|
|
386
|
+
regex(pattern: RegExp): this;
|
|
387
|
+
startsWith(prefix: string): this;
|
|
388
|
+
endsWith(suffix: string): this;
|
|
389
|
+
includes(substring: string): this;
|
|
390
|
+
uppercase(): this;
|
|
391
|
+
lowercase(): this;
|
|
392
|
+
trim(): this;
|
|
393
|
+
toLowerCase(): this;
|
|
394
|
+
toUpperCase(): this;
|
|
395
|
+
normalize(): this;
|
|
396
396
|
_schemaType(): SchemaType;
|
|
397
397
|
_toJSONSchema(_tracker: RefTracker): JSONSchemaObject;
|
|
398
|
-
_clone():
|
|
398
|
+
_clone(): this;
|
|
399
399
|
}
|
|
400
400
|
declare class CoercedStringSchema extends StringSchema {
|
|
401
401
|
_parse(value: unknown, ctx: ParseContext): string;
|
|
402
|
-
_clone():
|
|
402
|
+
_clone(): this;
|
|
403
403
|
}
|
|
404
404
|
declare class CoercedNumberSchema extends NumberSchema {
|
|
405
405
|
_parse(value: unknown, ctx: ParseContext): number;
|
|
@@ -465,6 +465,8 @@ declare class DiscriminatedUnionSchema<T extends DiscriminatedOptions> extends S
|
|
|
465
465
|
declare class EnumSchema<T extends readonly [string, ...string[]]> extends Schema<T[number]> {
|
|
466
466
|
private readonly _values;
|
|
467
467
|
constructor(values: T);
|
|
468
|
+
/** Public accessor for the enum's allowed values. */
|
|
469
|
+
get values(): T;
|
|
468
470
|
_parse(value: unknown, ctx: ParseContext): T[number];
|
|
469
471
|
_schemaType(): SchemaType;
|
|
470
472
|
_toJSONSchema(_tracker: RefTracker): JSONSchemaObject;
|
|
@@ -770,6 +772,11 @@ declare const s: {
|
|
|
770
772
|
datetime: () => IsoDatetimeSchema;
|
|
771
773
|
duration: () => IsoDurationSchema;
|
|
772
774
|
};
|
|
775
|
+
fromDbEnum: <const TValues extends readonly [string, ...string[]]>(column: {
|
|
776
|
+
_meta: {
|
|
777
|
+
enumValues: TValues;
|
|
778
|
+
};
|
|
779
|
+
}) => EnumSchema<TValues>;
|
|
773
780
|
coerce: {
|
|
774
781
|
string: () => CoercedStringSchema;
|
|
775
782
|
number: () => CoercedNumberSchema;
|
package/dist/index.js
CHANGED
|
@@ -1095,7 +1095,8 @@ class StringSchema extends Schema {
|
|
|
1095
1095
|
return schema;
|
|
1096
1096
|
}
|
|
1097
1097
|
_clone() {
|
|
1098
|
-
const
|
|
1098
|
+
const Ctor = this.constructor;
|
|
1099
|
+
const clone = this._cloneBase(new Ctor);
|
|
1099
1100
|
clone._min = this._min;
|
|
1100
1101
|
clone._minMessage = this._minMessage;
|
|
1101
1102
|
clone._max = this._max;
|
|
@@ -1122,7 +1123,8 @@ class CoercedStringSchema extends StringSchema {
|
|
|
1122
1123
|
return super._parse(value == null ? "" : String(value), ctx);
|
|
1123
1124
|
}
|
|
1124
1125
|
_clone() {
|
|
1125
|
-
|
|
1126
|
+
const Ctor = this.constructor;
|
|
1127
|
+
return Object.assign(new Ctor, super._clone());
|
|
1126
1128
|
}
|
|
1127
1129
|
}
|
|
1128
1130
|
|
|
@@ -1299,6 +1301,9 @@ class EnumSchema extends Schema {
|
|
|
1299
1301
|
super();
|
|
1300
1302
|
this._values = values;
|
|
1301
1303
|
}
|
|
1304
|
+
get values() {
|
|
1305
|
+
return this._values;
|
|
1306
|
+
}
|
|
1302
1307
|
_parse(value, ctx) {
|
|
1303
1308
|
if (!this._values.includes(value)) {
|
|
1304
1309
|
ctx.addIssue({
|
|
@@ -2355,6 +2360,13 @@ var s = {
|
|
|
2355
2360
|
datetime: () => new IsoDatetimeSchema,
|
|
2356
2361
|
duration: () => new IsoDurationSchema
|
|
2357
2362
|
},
|
|
2363
|
+
fromDbEnum: (column) => {
|
|
2364
|
+
const values = column._meta.enumValues;
|
|
2365
|
+
if (!values || values.length === 0) {
|
|
2366
|
+
throw new Error("s.fromDbEnum(): not an enum column — _meta.enumValues is missing or empty");
|
|
2367
|
+
}
|
|
2368
|
+
return new EnumSchema(values);
|
|
2369
|
+
},
|
|
2358
2370
|
coerce: {
|
|
2359
2371
|
string: () => new CoercedStringSchema,
|
|
2360
2372
|
number: () => new CoercedNumberSchema,
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertz/schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"description": "Type-safe schema definitions for Vertz",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "https://github.com/vertz-dev/vertz.git",
|
|
@@ -30,11 +31,13 @@
|
|
|
30
31
|
"typecheck": "tsc --noEmit"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
34
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
33
35
|
"bunup": "latest",
|
|
34
36
|
"typescript": "^5.7.0",
|
|
35
|
-
"vitest": "^
|
|
37
|
+
"vitest": "^4.0.18"
|
|
36
38
|
},
|
|
37
39
|
"engines": {
|
|
38
40
|
"node": ">=22"
|
|
39
|
-
}
|
|
41
|
+
},
|
|
42
|
+
"sideEffects": false
|
|
40
43
|
}
|