feat: introduce reusable Zod validator registry with comprehensive atomic schema modifiers and localization support

This commit is contained in:
Firman Ramdhani
2026-06-15 20:44:27 +07:00
parent c9278d09cb
commit f7c7bc6907
7 changed files with 340 additions and 20 deletions
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
compose,
required,
emailValidator,
minValue,
maxValue,
rangeValue,
positiveNumber,
minLength,
maxLength,
rangeLength,
simplePassword,
complexPassword,
phoneValidator,
} from '../registry.validator';
describe('Validator Registry', () => {
describe('compose()', () => {
it('should compose multiple string modifiers', () => {
const schema = compose(z.string(), required('Password'), complexPassword(8));
const res = schema.safeParse('Weak');
expect(res.success).toBe(false);
const res2 = schema.safeParse('StrongPass1!');
expect(res2.success).toBe(true);
});
});
describe('General Validators', () => {
it('required() should enforce min 1 length', () => {
const schema = compose(z.string(), required('TestField'));
const res = schema.safeParse('');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:required', values: { field: 'TestField' } })
);
});
it('emailValidator() should enforce email format', () => {
const schema = compose(z.string(), emailValidator());
const res = schema.safeParse('invalid-email');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_email' })
);
});
});
describe('Numeric Validators', () => {
it('minValue() should enforce min', () => {
const schema = compose(z.number(), minValue(10, 'Age'));
const res = schema.safeParse(5);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:min_val', values: { min: 10, field: 'Age' } })
);
});
it('maxValue() should enforce max', () => {
const schema = compose(z.number(), maxValue(100, 'Percentage'));
const res = schema.safeParse(105);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:max_val', values: { max: 100, field: 'Percentage' } })
);
});
it('rangeValue() should enforce range', () => {
const schema = compose(z.number(), rangeValue(10, 20, 'Range'));
const res = schema.safeParse(5);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:range_val', values: { min: 10, max: 20, field: 'Range' } })
);
});
it('positiveNumber() should enforce positive', () => {
const schema = compose(z.number(), positiveNumber('Amount'));
const res = schema.safeParse(-5);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:must_be_positive', values: { field: 'Amount' } })
);
});
});
describe('String Length Validators', () => {
it('minLength() should enforce min length', () => {
const schema = compose(z.string(), minLength(5, 'Username'));
const res = schema.safeParse('abc');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:min_len', values: { min: 5, field: 'Username' } })
);
});
it('maxLength() should enforce max length', () => {
const schema = compose(z.string(), maxLength(10, 'Username'));
const res = schema.safeParse('thisisaverylongusername');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:max_len', values: { max: 10, field: 'Username' } })
);
});
it('rangeLength() should enforce range', () => {
const schema = compose(z.string(), rangeLength(3, 5, 'Code'));
const res = schema.safeParse('ab');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:range_len', values: { min: 3, max: 5, field: 'Code' } })
);
});
});
describe('Security Validators', () => {
it('simplePassword() should enforce length only', () => {
const schema = compose(z.string(), simplePassword(6));
const res = schema.safeParse('short');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_password_simple', values: { min: 6 } })
);
expect(schema.safeParse('longenough').success).toBe(true);
});
it('complexPassword() should enforce complex rules', () => {
const schema = compose(z.string(), complexPassword(8));
expect(schema.safeParse('weakpassword').success).toBe(false);
expect(schema.safeParse('NoSpecial1').success).toBe(false);
expect(schema.safeParse('ValidPass1!').success).toBe(true);
const res = schema.safeParse('short');
expect(res.success).toBe(false);
});
});
describe('Technical Validators', () => {
it('phoneValidator() should enforce indonesian phone pattern', () => {
const schema = compose(z.string(), phoneValidator());
expect(schema.safeParse('08123456789').success).toBe(false);
expect(schema.safeParse('+628123456789').success).toBe(true);
const res = schema.safeParse('invalid');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_phone' })
);
});
});
});
+1
View File
@@ -1 +1,2 @@
export * from './sample.validator';
export * from './registry.validator';
@@ -0,0 +1,96 @@
import { z, type ZodString, type ZodNumber, type ZodTypeAny } from 'zod';
// ─── UTILITIES ─────────────────────────────────────────────────────────────
export const compose = <T extends ZodTypeAny>(
base: T,
...modifiers: ((schema: any) => any)[]
): any => {
return modifiers.reduce((acc, curr) => curr(acc), base);
};
// ─── GENERAL ───────────────────────────────────────────────────────────────
export const required = (field?: string) => (schema: ZodString) => {
return schema.min(1, {
message: JSON.stringify({ key: 'validation:required', values: { field: field || 'Field' } }),
});
};
export const emailValidator = () => (schema: ZodString) => {
return schema.email({
message: JSON.stringify({ key: 'validation:invalid_email' }),
});
};
// ─── NUMERIC ───────────────────────────────────────────────────────────────
export const minValue = (min: number, field?: string) => (schema: ZodNumber) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:min_val', values: { min, field } }),
});
};
export const maxValue = (max: number, field?: string) => (schema: ZodNumber) => {
return schema.max(max, {
message: JSON.stringify({ key: 'validation:max_val', values: { max, field } }),
});
};
export const rangeValue = (min: number, max: number, field?: string) => (schema: ZodNumber) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:range_val', values: { min, max, field } }) })
.max(max, { message: JSON.stringify({ key: 'validation:range_val', values: { min, max, field } }) });
};
export const positiveNumber = (field?: string) => (schema: ZodNumber) => {
return schema.positive({
message: JSON.stringify({ key: 'validation:must_be_positive', values: { field } }),
});
};
// ─── STRING LENGTH ─────────────────────────────────────────────────────────
export const minLength = (min: number, field?: string) => (schema: ZodString) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:min_len', values: { min, field } }),
});
};
export const maxLength = (max: number, field?: string) => (schema: ZodString) => {
return schema.max(max, {
message: JSON.stringify({ key: 'validation:max_len', values: { max, field } }),
});
};
export const rangeLength = (min: number, max: number, field?: string) => (schema: ZodString) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:range_len', values: { min, max, field } }) })
.max(max, { message: JSON.stringify({ key: 'validation:range_len', values: { min, max, field } }) });
};
// ─── SECURITY ──────────────────────────────────────────────────────────────
export const simplePassword = (min: number = 8) => (schema: ZodString) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:invalid_password_simple', values: { min } }),
});
};
export const complexPassword = (min: number = 8) => (schema: ZodString) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[A-Z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[a-z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[^A-Za-z0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) });
};
// ─── TECHNICAL ─────────────────────────────────────────────────────────────
export const phoneValidator = () => (schema: ZodString) => {
// Regex for Indonesian phone number (+62...)
return schema.regex(/^\+62\d{8,13}$/, {
message: JSON.stringify({ key: 'validation:invalid_phone' }),
});
};
@@ -1,15 +1,9 @@
import { z } from 'zod';
import { compose, emailValidator, minLength } from './registry.validator';
export const sampleValidator = z.object({
email: z.string().email({
message: JSON.stringify({ key: 'validation:invalid_email' }),
}),
name: z.string().min(3, {
message: JSON.stringify({
key: 'validation:min_length',
values: { field: 'Nama', min: 3 },
}),
}),
email: compose(z.string(), emailValidator()),
name: compose(z.string(), minLength(3, 'Nama')),
});
export type SampleValidatorType = z.infer<typeof sampleValidator>;