Skip to content

Commit e2482a0

Browse files
feat: object and shape validation
1 parent 0aec0cc commit e2482a0

File tree

4 files changed

+236
-5
lines changed

4 files changed

+236
-5
lines changed

src/lib/isURL.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,6 @@ export default function isURL(url: string, options?: Partial<IsURLOptions>): boo
8585
let protocol, auth, host, port, port_str, ipv6
8686
let split: string[] = []
8787

88-
const hostname = split.join('@')
89-
9088
split = url.split('#')
9189
url = split.shift() ?? ''
9290

@@ -116,13 +114,13 @@ export default function isURL(url: string, options?: Partial<IsURLOptions>): boo
116114
}
117115

118116
split = url.split('/')
119-
url = split.shift() ?? ''
117+
const hostname = split.shift() ?? ''
120118

121-
if (url === '' && !options.require_host) {
119+
if (hostname === '' && !options.require_host) {
122120
return true
123121
}
124122

125-
split = url.split('@')
123+
split = hostname.split('@')
126124
if (split.length > 1) {
127125
if (options.disallow_auth) {
128126
return false

src/validation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import type { BooleanValidator } from './validators/booleans'
33
import type { DateValidator } from './validators/dates'
44
import type { EnumValidator } from './validators/enums'
55
import type { NumberValidator } from './validators/numbers'
6+
import type { ObjectValidator } from './validators/objects'
67
import type { StringValidator } from './validators/strings'
78

89
import { array } from './validators/arrays'
910
import { boolean } from './validators/booleans'
1011
import { date } from './validators/dates'
1112
import { enum_ } from './validators/enums'
1213
import { number } from './validators/numbers'
14+
import { object } from './validators/objects'
1315
import { string } from './validators/strings'
1416

1517
interface Validator {
@@ -19,6 +21,7 @@ interface Validator {
1921
boolean: () => BooleanValidator
2022
enum: <T extends string | number>(values: readonly T[]) => EnumValidator<T>
2123
date: () => DateValidator
24+
object: <T extends Record<string, any>>() => ObjectValidator<T>
2225
}
2326

2427
export const v: Validator = {
@@ -28,4 +31,5 @@ export const v: Validator = {
2831
boolean,
2932
enum: enum_,
3033
date,
34+
object,
3135
}

src/validators/objects.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { ValidationError, ValidationResult, Validator } from '../types'
2+
import { BaseValidator } from './base'
3+
4+
export class ObjectValidator<T extends Record<string, any>> extends BaseValidator<T> {
5+
private schema: Record<string, Validator<any>> = {}
6+
private strictMode = false
7+
8+
constructor() {
9+
super()
10+
this.addRule({
11+
name: 'object',
12+
test: (value: unknown): value is T =>
13+
typeof value === 'object' && value !== null && !Array.isArray(value),
14+
message: 'Must be an object',
15+
})
16+
}
17+
18+
shape(schema: Record<string, Validator<any>>): this {
19+
this.schema = schema
20+
return this.addRule({
21+
name: 'shape',
22+
test: (value: T) => {
23+
// If value is null/undefined, let the required/optional rules handle it
24+
if (value === null || value === undefined)
25+
return true
26+
27+
// In strict mode, check for extra fields
28+
if (this.strictMode) {
29+
const schemaKeys = new Set(Object.keys(schema))
30+
const valueKeys = Object.keys(value)
31+
return valueKeys.every(key => schemaKeys.has(key))
32+
}
33+
34+
return true
35+
},
36+
message: 'Invalid object shape',
37+
})
38+
}
39+
40+
strict(strict = true): this {
41+
this.strictMode = strict
42+
return this
43+
}
44+
45+
validate(value: T): ValidationResult {
46+
const result = super.validate(value)
47+
48+
// If the base validation passed and we have a schema, validate each field
49+
if (result.valid && Object.keys(this.schema).length > 0 && value !== null && value !== undefined) {
50+
const fieldErrors: ValidationError[] = []
51+
52+
for (const [key, validator] of Object.entries(this.schema)) {
53+
const fieldValue = value[key]
54+
const fieldResult = validator.validate(fieldValue)
55+
56+
if (!fieldResult.valid) {
57+
fieldErrors.push(
58+
...fieldResult.errors.map(error => ({
59+
...error,
60+
field: key,
61+
message: `${key}: ${error.message}`,
62+
})),
63+
)
64+
}
65+
}
66+
67+
if (fieldErrors.length > 0) {
68+
return {
69+
valid: false,
70+
errors: fieldErrors,
71+
}
72+
}
73+
}
74+
75+
return result
76+
}
77+
}
78+
79+
export function object<T extends Record<string, any>>(): ObjectValidator<T> {
80+
return new ObjectValidator<T>()
81+
}

test/validation.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,154 @@ describe('Validation Library', () => {
287287
})
288288
})
289289

290+
describe('Object Validator', () => {
291+
test('basic object validation', () => {
292+
const validator = v.object()
293+
expect(validator.test({})).toBe(true)
294+
expect(validator.test({ foo: 'bar' })).toBe(true)
295+
expect(validator.test(null as any)).toBe(false)
296+
expect(validator.test([] as any)).toBe(false)
297+
expect(validator.test('not an object' as any)).toBe(false)
298+
})
299+
300+
test('shape validation', () => {
301+
const validator = v.object().shape({
302+
name: v.string().min(2),
303+
age: v.number().min(18),
304+
email: v.string().email(),
305+
})
306+
307+
expect(validator.test({
308+
name: 'John',
309+
age: 25,
310+
email: 'john@example.com',
311+
})).toBe(true)
312+
313+
expect(validator.test({
314+
name: 'J', // too short
315+
age: 25,
316+
email: 'john@example.com',
317+
})).toBe(false)
318+
319+
expect(validator.test({
320+
name: 'John',
321+
age: 16, // too young
322+
email: 'john@example.com',
323+
})).toBe(false)
324+
325+
expect(validator.test({
326+
name: 'John',
327+
age: 25,
328+
email: 'invalid-email', // invalid email
329+
})).toBe(false)
330+
})
331+
332+
test('nested object validation', () => {
333+
const validator = v.object().shape({
334+
user: v.object().shape({
335+
name: v.string().min(2),
336+
age: v.number().min(18),
337+
}),
338+
settings: v.object().shape({
339+
theme: v.string(),
340+
notifications: v.boolean(),
341+
}),
342+
})
343+
344+
expect(validator.test({
345+
user: {
346+
name: 'John',
347+
age: 25,
348+
},
349+
settings: {
350+
theme: 'dark',
351+
notifications: true,
352+
},
353+
})).toBe(true)
354+
355+
expect(validator.test({
356+
user: {
357+
name: 'J', // too short
358+
age: 25,
359+
},
360+
settings: {
361+
theme: 'dark',
362+
notifications: true,
363+
},
364+
})).toBe(false)
365+
})
366+
367+
test('strict mode validation', () => {
368+
const validator = v.object().shape({
369+
name: v.string(),
370+
age: v.number(),
371+
}).strict()
372+
373+
expect(validator.test({
374+
name: 'John',
375+
age: 25,
376+
})).toBe(true)
377+
378+
expect(validator.test({
379+
name: 'John',
380+
age: 25,
381+
extra: 'field', // extra field not allowed in strict mode
382+
})).toBe(false)
383+
})
384+
385+
test('complex object validation', () => {
386+
const validator = v.object().shape({
387+
name: v.string().min(2).max(50),
388+
email: v.string().email(),
389+
age: v.number().min(18).integer(),
390+
website: v.string().url().optional(),
391+
tags: v.array<string>().each(v.string()).optional(),
392+
address: v.object().shape({
393+
street: v.string(),
394+
city: v.string(),
395+
zip: v.string(),
396+
}).optional(),
397+
})
398+
399+
expect(validator.test({
400+
name: 'John Doe',
401+
email: 'john@example.com',
402+
age: 25,
403+
website: 'https://example.com',
404+
tags: ['developer', 'typescript'],
405+
address: {
406+
street: '123 Main St',
407+
city: 'New York',
408+
zip: '10001',
409+
},
410+
})).toBe(true)
411+
412+
const result = validator.validate({
413+
name: 'J', // too short
414+
email: 'invalid-email',
415+
age: 16, // too young
416+
website: 'not-a-url',
417+
tags: [123], // invalid tag type
418+
address: {
419+
street: '123 Main St',
420+
city: 'New York',
421+
zip: '10001',
422+
},
423+
})
424+
425+
expect(result.valid).toBe(false)
426+
expect(result.errors).toHaveLength(5)
427+
428+
// Check specific error messages
429+
const errorMessages = result.errors.map(e => e.message)
430+
expect(errorMessages).toContain('name: Must be at least 2 characters long') // name too short
431+
expect(errorMessages).toContain('email: Must be a valid email address') // invalid email
432+
expect(errorMessages).toContain('age: Must be at least 18') // age too young
433+
expect(errorMessages).toContain('website: Must be a valid URL') // invalid URL
434+
expect(errorMessages).toContain('tags: Each item in array is invalid') // invalid array item type
435+
})
436+
})
437+
290438
describe('Validation Results', () => {
291439
test('validate returns detailed results', () => {
292440
const validator = v.string().min(5).max(10)

0 commit comments

Comments
 (0)