feat: introduce reusable Zod validator registry with comprehensive atomic schema modifiers and localization support
This commit is contained in:
@@ -2,6 +2,16 @@
|
||||
"validation": {
|
||||
"required": "{{field}} is required",
|
||||
"min_length": "{{field}} must be at least {{min}} characters",
|
||||
"invalid_email": "Invalid email format"
|
||||
"min_len": "{{field}} must be at least {{min}} characters",
|
||||
"max_len": "{{field}} must be at most {{max}} characters",
|
||||
"range_len": "{{field}} must be between {{min}} and {{max}} characters",
|
||||
"min_val": "{{field}} must be greater than or equal to {{min}}",
|
||||
"max_val": "{{field}} must be less than or equal to {{max}}",
|
||||
"range_val": "{{field}} must be between {{min}} and {{max}}",
|
||||
"must_be_positive": "{{field}} must be a positive number",
|
||||
"invalid_email": "Invalid email format",
|
||||
"invalid_phone": "Invalid phone number format",
|
||||
"invalid_password_simple": "Password must be at least {{min}} characters",
|
||||
"invalid_password_complex": "Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"validation": {
|
||||
"required": "{{field}} wajib diisi",
|
||||
"min_length": "{{field}} minimal {{min}} karakter",
|
||||
"invalid_email": "Format email tidak valid"
|
||||
"min_len": "{{field}} minimal {{min}} karakter",
|
||||
"max_len": "{{field}} maksimal {{max}} karakter",
|
||||
"range_len": "{{field}} harus antara {{min}} dan {{max}} karakter",
|
||||
"min_val": "{{field}} minimal bernilai {{min}}",
|
||||
"max_val": "{{field}} maksimal bernilai {{max}}",
|
||||
"range_val": "{{field}} harus bernilai antara {{min}} dan {{max}}",
|
||||
"must_be_positive": "{{field}} harus bernilai positif",
|
||||
"invalid_email": "Format email tidak valid",
|
||||
"invalid_phone": "Format nomor telepon tidak valid",
|
||||
"invalid_password_simple": "Kata sandi minimal {{min}} karakter",
|
||||
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,17 +260,11 @@ To prevent over-engineering and package fatigue, we house the validation layer d
|
||||
```tsx
|
||||
// packages/ui/src/validators/sample.validator.ts
|
||||
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>;
|
||||
@@ -306,6 +300,68 @@ function ExampleForm() {
|
||||
|
||||
---
|
||||
|
||||
## Validator Bank Reference
|
||||
|
||||
The `registry.validator.ts` provides a set of pre-configured atomic validators returning modified Zod schemas that automatically emit translated JSON payloads.
|
||||
|
||||
### Available Atomic Validators
|
||||
|
||||
| Category | Validator | Target Type | Description |
|
||||
|---|---|---|---|
|
||||
| **Numeric** | `minValue(min, field?)` | `ZodNumber` | Minimum numeric value |
|
||||
| **Numeric** | `maxValue(max, field?)` | `ZodNumber` | Maximum numeric value |
|
||||
| **Numeric** | `rangeValue(min, max, field?)` | `ZodNumber` | Restricts value between `min` and `max` limits |
|
||||
| **Numeric** | `positiveNumber(field?)` | `ZodNumber` | Restricts to positive numbers |
|
||||
| **String** | `minLength(len, field?)` | `ZodString` | Minimum string character length |
|
||||
| **String** | `maxLength(len, field?)` | `ZodString` | Maximum string character length |
|
||||
| **String** | `rangeLength(min, max, field?)` | `ZodString` | Restricts string length between `min` and `max` bounds |
|
||||
| **Security** | `simplePassword(min)` | `ZodString` | Checks password string length bounds only |
|
||||
| **Security** | `complexPassword(min)` | `ZodString` | Enforces length, 1 uppercase, 1 lowercase, 1 number, and 1 special char |
|
||||
| **Technical** | `emailValidator()` | `ZodString` | Standard email format |
|
||||
| **Technical** | `phoneValidator()` | `ZodString` | Enforces Indonesian (+62) phone number format |
|
||||
|
||||
> [!WARNING]
|
||||
> Always distinguish between `rangeValue` (which bounds the actual numeric integer/float) and `rangeLength` (which bounds the amount of characters in a string).
|
||||
|
||||
### Composition Guide
|
||||
|
||||
Instead of manually chaining long `.min().max().regex()` methods, use the `compose()` helper utility to elegantly stack atomic validators onto a base primitive.
|
||||
|
||||
**Example: User Registration Password Field**
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
import { compose, required, minLength, complexPassword } from '@repo/ui/validators';
|
||||
|
||||
export const userRegistrationSchema = z.object({
|
||||
password: compose(
|
||||
z.string(),
|
||||
required('Password'),
|
||||
complexPassword(8)
|
||||
)
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Validators
|
||||
|
||||
We enforce strict test coverage for our Validation Bank. If you add a new atomic validator to `registry.validator.ts`, you MUST add corresponding tests to `__tests__/registry.validator.test.ts`.
|
||||
|
||||
Tests must explicitly verify the JSON stringified i18n payload:
|
||||
|
||||
```typescript
|
||||
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' } })
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Form
|
||||
|
||||
@@ -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 +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>;
|
||||
|
||||
Reference in New Issue
Block a user