From f7c7bc69070634e4947c6057cdc649f769e4f0ac Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:44:27 +0700 Subject: [PATCH] feat: introduce reusable Zod validator registry with comprehensive atomic schema modifiers and localization support --- .../core-i18n/src/locales/en/validation.json | 12 +- .../core-i18n/src/locales/id/validation.json | 12 +- packages/ui/docs/FORM-COMPONENTS.md | 74 +++++++-- .../__tests__/registry.validator.test.ts | 153 ++++++++++++++++++ packages/ui/src/validators/index.ts | 1 + .../ui/src/validators/registry.validator.ts | 96 +++++++++++ .../ui/src/validators/sample.validator.ts | 12 +- 7 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/validators/__tests__/registry.validator.test.ts create mode 100644 packages/ui/src/validators/registry.validator.ts diff --git a/packages/core-i18n/src/locales/en/validation.json b/packages/core-i18n/src/locales/en/validation.json index aeeae87..07c7e53 100644 --- a/packages/core-i18n/src/locales/en/validation.json +++ b/packages/core-i18n/src/locales/en/validation.json @@ -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" } } diff --git a/packages/core-i18n/src/locales/id/validation.json b/packages/core-i18n/src/locales/id/validation.json index 4b23762..4b31ecb 100644 --- a/packages/core-i18n/src/locales/id/validation.json +++ b/packages/core-i18n/src/locales/id/validation.json @@ -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" } } diff --git a/packages/ui/docs/FORM-COMPONENTS.md b/packages/ui/docs/FORM-COMPONENTS.md index 0b47481..ab768df 100644 --- a/packages/ui/docs/FORM-COMPONENTS.md +++ b/packages/ui/docs/FORM-COMPONENTS.md @@ -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; @@ -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 diff --git a/packages/ui/src/validators/__tests__/registry.validator.test.ts b/packages/ui/src/validators/__tests__/registry.validator.test.ts new file mode 100644 index 0000000..522acba --- /dev/null +++ b/packages/ui/src/validators/__tests__/registry.validator.test.ts @@ -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' }) + ); + }); + }); +}); diff --git a/packages/ui/src/validators/index.ts b/packages/ui/src/validators/index.ts index e0280a7..ecd3afa 100644 --- a/packages/ui/src/validators/index.ts +++ b/packages/ui/src/validators/index.ts @@ -1 +1,2 @@ export * from './sample.validator'; +export * from './registry.validator'; diff --git a/packages/ui/src/validators/registry.validator.ts b/packages/ui/src/validators/registry.validator.ts new file mode 100644 index 0000000..f793215 --- /dev/null +++ b/packages/ui/src/validators/registry.validator.ts @@ -0,0 +1,96 @@ +import { z, type ZodString, type ZodNumber, type ZodTypeAny } from 'zod'; + +// ─── UTILITIES ───────────────────────────────────────────────────────────── + +export const compose = ( + 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' }), + }); +}; diff --git a/packages/ui/src/validators/sample.validator.ts b/packages/ui/src/validators/sample.validator.ts index 7db625b..f7b6dbd 100644 --- a/packages/ui/src/validators/sample.validator.ts +++ b/packages/ui/src/validators/sample.validator.ts @@ -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;