├── .gitignore ├── .prettierrc ├── src ├── extensions │ ├── vite-env.d.ts │ └── shared-kernel.ts ├── application │ ├── const.ts │ ├── utils.ts │ ├── types.ts │ └── validation.ts ├── styles │ ├── global.css │ └── app.css ├── services │ └── validation.ts └── main.ts ├── vite.config.js ├── package.json ├── tsconfig.json ├── README.md └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100 3 | } 4 | -------------------------------------------------------------------------------- /src/extensions/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | server: { 3 | port: 1984, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "validation", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "preview": "vite preview" 8 | }, 9 | "devDependencies": { 10 | "typescript": "^4.4.4", 11 | "vite": "^2.7.2" 12 | } 13 | } -------------------------------------------------------------------------------- /src/extensions/shared-kernel.ts: -------------------------------------------------------------------------------- 1 | type Nullable = T | null; 2 | type Optional = T | undefined; 3 | type List = T[]; 4 | 5 | type NumberLike = string; 6 | type Comparable = string | number; 7 | 8 | type DateString = string; 9 | type TimeStamp = number; 10 | type NumberYears = number; 11 | 12 | type LocalFile = File; 13 | type Image = LocalFile; 14 | -------------------------------------------------------------------------------- /src/application/const.ts: -------------------------------------------------------------------------------- 1 | import type { KnownSpecialty } from "./types"; 2 | 3 | export const MIN_ALLOWED_AGE_YEARS = 20; 4 | export const MAX_ALLOWED_AGE_YEARS = 50; 5 | 6 | export const MAX_SPECIALTY_LENGTH = 50; 7 | export const DEFAULT_SPECIALTIES: List = ["engineer", "scientist", "psychologist"]; 8 | 9 | export const MIN_EXPERIENCE_YEARS = 3; 10 | 11 | export const MIN_PASSWORD_SIZE = 10; 12 | -------------------------------------------------------------------------------- /src/application/utils.ts: -------------------------------------------------------------------------------- 1 | export const exists = (entity: TEntity) => !!entity; 2 | export const contains = (value: string, pattern: RegExp) => value.search(pattern) >= 0; 3 | 4 | export const inRange = (value: Comparable, min: Comparable, max: Comparable) => 5 | value >= min && value <= max; 6 | 7 | export const yearsOf = (date: TimeStamp): NumberYears => 8 | new Date().getFullYear() - new Date(date).getFullYear(); 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM", "dom.iterable"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "noEmit": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true 16 | }, 17 | "include": ["./src"] 18 | } 19 | -------------------------------------------------------------------------------- /src/application/types.ts: -------------------------------------------------------------------------------- 1 | type ApplicantName = string; 2 | type PhoneNumber = string; 3 | type EmailAddress = string; 4 | type BirthDate = DateString; 5 | type UserPhoto = Image; 6 | 7 | type KnownSpecialty = "engineer" | "scientist" | "psychologist"; 8 | type UnknownSpecialty = string; 9 | type ExperienceYears = NumberLike; 10 | 11 | type Password = string; 12 | 13 | export { KnownSpecialty }; 14 | export type ApplicationForm = { 15 | name: ApplicantName; 16 | phone: PhoneNumber; 17 | email: EmailAddress; 18 | birthDate: BirthDate; 19 | photo: UserPhoto; 20 | 21 | specialty: KnownSpecialty; 22 | customSpecialty: UnknownSpecialty; 23 | experience: ExperienceYears; 24 | 25 | password: Password; 26 | }; 27 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | html, 8 | body { 9 | margin: 0; 10 | min-height: 100%; 11 | } 12 | 13 | html { 14 | font-family: Avenir, Helvetica, Arial, sans-serif; 15 | font-size: 1.2rem; 16 | line-height: 1.5; 17 | } 18 | 19 | body { 20 | margin: 0; 21 | padding: 40px; 22 | 23 | background-image: linear-gradient(#4b5a75, #59a9b1); 24 | } 25 | 26 | h1, 27 | h2, 28 | h3 { 29 | margin-bottom: 0.2em; 30 | } 31 | 32 | p { 33 | margin: 0.4em 0 1em; 34 | } 35 | 36 | a { 37 | color: black; 38 | text-decoration-color: rgba(0, 0, 0, 0.2); 39 | } 40 | 41 | a:hover { 42 | color: white; 43 | text-decoration-color: rgba(255, 255, 255, 0.2); 44 | } 45 | 46 | input, 47 | select, 48 | button { 49 | font-size: 1rem; 50 | } 51 | 52 | button { 53 | cursor: pointer; 54 | } 55 | 56 | input[type="file"] { 57 | max-width: 100%; 58 | margin: 0; 59 | } 60 | 61 | @media (max-width: 620px) { 62 | html { 63 | font-size: 1rem; 64 | } 65 | 66 | body { 67 | padding: 15px; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/styles/app.css: -------------------------------------------------------------------------------- 1 | .app { 2 | width: 100%; 3 | max-width: 640px; 4 | margin: auto; 5 | 6 | padding: 20px 40px; 7 | border-radius: 10px; 8 | 9 | background-color: rgba(255, 255, 255, 0.6); 10 | } 11 | 12 | .form { 13 | margin: 2rem 0; 14 | } 15 | 16 | .field:not([hidden]) { 17 | display: block; 18 | margin: 1.2rem 0; 19 | } 20 | 21 | .question { 22 | display: block; 23 | margin: 0.25rem 0; 24 | font-weight: 500; 25 | } 26 | 27 | .description { 28 | display: block; 29 | margin: 0.25rem 0; 30 | font-size: 0.8rem; 31 | } 32 | 33 | .input { 34 | -webkit-appearance: none; 35 | appearance: none; 36 | 37 | display: block; 38 | width: 100%; 39 | height: 40px; 40 | padding: 5px 10px; 41 | 42 | border: 0; 43 | border-radius: 5px; 44 | 45 | background-color: white; 46 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.5); 47 | } 48 | 49 | .select { 50 | display: block; 51 | width: 100%; 52 | min-height: 40px; 53 | } 54 | 55 | .validation-error { 56 | display: block; 57 | margin: 0.25rem 0; 58 | 59 | font-size: 0.8rem; 60 | color: #ca1c1d; 61 | } 62 | 63 | .footer { 64 | margin: 1.75em 0; 65 | font-size: 0.8rem; 66 | text-align: center; 67 | } 68 | 69 | @media (max-width: 620px) { 70 | .app { 71 | padding: 5px 25px; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/services/validation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Basics for validation and functional rule composition: 3 | */ 4 | export type ValidationRule = (data: T) => boolean; 5 | 6 | type RequiresAll = ValidationRule; 7 | type RequiresAny = ValidationRule; 8 | 9 | export function all(rules: List>): RequiresAll { 10 | return (data) => rules.every((isValid) => isValid(data)); 11 | } 12 | 13 | export function some(rules: List>): RequiresAny { 14 | return (data) => rules.some((isValid) => isValid(data)); 15 | } 16 | 17 | /** 18 | * Returning error messages after the validation: 19 | */ 20 | export type ErrorMessage = string; 21 | export type ErrorMessages = Partial>; 22 | export type ValidationRules = Partial>>; 23 | 24 | type ValidationResult = { 25 | valid: boolean; 26 | errors: ErrorMessages; 27 | }; 28 | 29 | export function createValidator( 30 | rules: ValidationRules, 31 | errors: ErrorMessages 32 | ) { 33 | return function validate(data: TData): ValidationResult { 34 | const result: ValidationResult = { 35 | valid: true, 36 | errors: {}, 37 | }; 38 | 39 | Object.keys(rules).forEach((key) => { 40 | const field = key as keyof TData; 41 | const validate = rules[field]; 42 | 43 | if (!validate) return; 44 | if (!validate(data)) { 45 | result.valid = false; 46 | result.errors[field] = errors[field]; 47 | } 48 | }); 49 | 50 | return result; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Declarative validation in rule-based approach and functional programming 2 | 3 | [Sample frontend app](https://bespoyasov.ru/showcase/declarative-validation/) with HTML-form data validation made using a rule-based approach, written in TypeScript and (almost pure) functional programming paradigm. 4 | 5 | ## About Project 6 | 7 | Client data validation is complicated. It requires different types of logic to work together, and sometimes difficult to distinguish those types properly. This can result in a messy code. 8 | 9 | The rule-based approach helps to separate domain, UI, and infrastructure logic. It makes the validation rules independent from the other code and reusable in different projects. 10 | 11 | Read more about this approach in my posts: 12 | 13 | - 🇬🇧 [Declarative validation in rule-based approach](https://dev.to/bespoyasov/declarative-data-validation-with-functional-programming-and-rule-based-approach-22a4) 14 | - 🇷🇺 [Декларативная валидация данных в rule-based стиле](http://bespoyasov.ru/blog/declarative-rule-based-validation/) 15 | 16 | ## App Example 17 | 18 | For this post, I created a sample app—Mars colonizer application form. 19 | 20 | This form contains fields with different data types (name, date, email, phone, password), different sets of rules and relationships, and even interdependent rules. 21 | 22 | [Check out the form](https://bespoyasov.ru/showcase/declarative-validation/) yourself to see how it's working! 23 | 24 | ## Source Code 25 | 26 | Although the main idea is explained in the posts, I left some comments in the source code. Check out the [validation rules](https://github.com/bespoyasov/rule-based-data-validation/blob/main/src/application/validation.ts) and the [validation infrastructure code](https://github.com/bespoyasov/rule-based-data-validation/blob/main/src/services/validation.ts) to feel the idea behind the rule-based validation. 27 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./styles/global.css"; 2 | import "./styles/app.css"; 3 | 4 | import type { ApplicationForm } from "./application/types"; 5 | import type { ApplicationErrors } from "./application/validation"; 6 | import type { ErrorMessage } from "./services/validation"; 7 | 8 | import { validateForm } from "./application/validation"; 9 | 10 | const errorSelector = ".validation-error"; 11 | 12 | const specialtySelect = document.getElementById("specialty"); 13 | const customSpecialty = document.getElementById("custom"); 14 | const applicationForm = document.getElementById("form"); 15 | 16 | function observeDependentFields() { 17 | const select = specialtySelect as HTMLSelectElement; 18 | const dependant = customSpecialty as HTMLElement; 19 | 20 | select.addEventListener("change", (e) => { 21 | const target = e.target as HTMLSelectElement; 22 | const showDependant = target.value === "other"; 23 | 24 | dependant.hidden = !showDependant; 25 | if (showDependant) dependant.querySelector("input")?.focus?.(); 26 | }); 27 | } 28 | 29 | function createErrorElement(message: ErrorMessage): HTMLSpanElement { 30 | const error = document.createElement("span"); 31 | error.className = "validation-error"; 32 | error.textContent = message; 33 | return error; 34 | } 35 | 36 | type FieldError = { 37 | field: string; 38 | message: ErrorMessage; 39 | }; 40 | 41 | function showErrorMessage({ field, message }: FieldError) { 42 | const input = applicationForm?.querySelector(`[name="${field}"]`); 43 | const scope = input?.closest("label"); 44 | if (!scope) return; 45 | 46 | const error = createErrorElement(message); 47 | scope.appendChild(error); 48 | } 49 | 50 | function showErrors(errors: ApplicationErrors) { 51 | Object.entries(errors).forEach(([field, message]) => { 52 | showErrorMessage({ field, message }); 53 | }); 54 | } 55 | 56 | function clearErrors() { 57 | const errors = applicationForm?.querySelectorAll(errorSelector); 58 | errors?.forEach((node) => node.remove()); 59 | } 60 | 61 | /** 62 | * Business logic is isolated and decoupled from the UI logic. 63 | * Here we control only how the UI (HTML-form) behaves, 64 | * if the UI requirements change we will need to update only this module. 65 | * 66 | * Since the UI logic depends on the validation rules and not otherwise, 67 | * the change of business requirements is reflected in the validation rules. 68 | */ 69 | 70 | function handleFormSubmit(e: SubmitEvent) { 71 | e.preventDefault(); 72 | clearErrors(); 73 | 74 | const data = Object.fromEntries(new FormData(e.target as HTMLFormElement)); 75 | const { valid, errors } = validateForm(data as ApplicationForm); 76 | 77 | if (valid) setTimeout(() => alert("Application sent!"), 0); 78 | else showErrors(errors); 79 | } 80 | 81 | observeDependentFields(); 82 | applicationForm?.addEventListener("submit", handleFormSubmit); 83 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mars Colonizer Application 🚀 8 | 9 | 10 |
11 |

Mars Colonizer Application 🚀

12 |

Tell us about yourself and get a chance to join the first colonizer mission to Mars!

13 |

Please, keep in mind that this mission is one direction only. Once on Mars, there is no turning back.

14 | 15 |
16 | 20 | 21 | 25 | 26 | 31 | 32 | 37 | 38 | 47 | 48 | 53 | 54 | 58 | 59 | 64 | 65 | 70 | 71 | 72 |
73 |
74 | 75 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/application/validation.ts: -------------------------------------------------------------------------------- 1 | import type { ApplicationForm } from "./types"; 2 | import type { ValidationRule, ValidationRules, ErrorMessages } from "../services/validation"; 3 | 4 | import { all, some, createValidator } from "../services/validation"; 5 | import { contains, exists, inRange, yearsOf } from "./utils"; 6 | import { 7 | MIN_ALLOWED_AGE_YEARS as MIN_AGE, 8 | MAX_ALLOWED_AGE_YEARS as MAX_AGE, 9 | DEFAULT_SPECIALTIES, 10 | MAX_SPECIALTY_LENGTH, 11 | MIN_EXPERIENCE_YEARS, 12 | MIN_PASSWORD_SIZE, 13 | } from "./const"; 14 | 15 | type Rule = ValidationRule; 16 | export type ApplicationRules = ValidationRules; 17 | export type ApplicationErrors = ErrorMessages; 18 | 19 | export const validateName: Rule = ({ name }) => exists(name); 20 | export const validateEmail: Rule = ({ email }) => email.includes("@") && email.includes("."); 21 | 22 | const onlyInternational: Rule = ({ phone }) => phone.startsWith("+"); 23 | const onlySafeCharacters: Rule = ({ phone }) => !contains(phone, /[^\d\s\-\(\)\+]/g); 24 | const phoneRules = [onlyInternational, onlySafeCharacters]; 25 | 26 | /** 27 | * We might want to avoid multiple type conversions. 28 | * Instead, we can create a single converter function that will 29 | * transform a “raw” object with string values into an object with converted values. 30 | * (In that case it's better to explicitly type both data structures.) 31 | */ 32 | const validDate: Rule = ({ birthDate }) => !Number.isNaN(Date.parse(birthDate)); 33 | const allowedAge: Rule = ({ birthDate }) => 34 | inRange(yearsOf(Date.parse(birthDate)), MIN_AGE, MAX_AGE); 35 | 36 | const birthDateRules = [validDate, allowedAge]; 37 | 38 | const isKnownSpecialty: Rule = ({ specialty }) => DEFAULT_SPECIALTIES.includes(specialty); 39 | const isValidCustom: Rule = ({ customSpecialty: custom }) => 40 | exists(custom) && custom.length <= MAX_SPECIALTY_LENGTH; 41 | 42 | const specialtyRules = [isKnownSpecialty, isValidCustom]; 43 | 44 | const isNumberLike: Rule = ({ experience }) => Number.isFinite(Number(experience)); 45 | const isExperienced: Rule = ({ experience }) => Number(experience) >= MIN_EXPERIENCE_YEARS; 46 | const experienceRules = [isNumberLike, isExperienced]; 47 | 48 | const atLeastOneCapital = /[A-Z]/g; 49 | const atLeastOneDigit = /\d/gi; 50 | 51 | const hasRequiredSize: Rule = ({ password }) => password.length >= MIN_PASSWORD_SIZE; 52 | const hasCapital: Rule = ({ password }) => contains(password, atLeastOneCapital); 53 | const hasDigit: Rule = ({ password }) => contains(password, atLeastOneDigit); 54 | const passwordRules = [hasRequiredSize, hasCapital, hasDigit]; 55 | 56 | /** 57 | * We can use these rules directly 58 | * when we need to update the form on every input change. 59 | */ 60 | export const validatePhone = all(phoneRules); 61 | export const validateBirthDate = all(birthDateRules); 62 | export const validateSpecialty = some(specialtyRules); 63 | export const validateExperience = all(experienceRules); 64 | export const validatePassword = all(passwordRules); 65 | 66 | /** 67 | * If we don't need error messages 68 | * we can use a composed rule for validating the whole form. 69 | */ 70 | export const validateFormWithoutErrors = all([ 71 | validateName, 72 | validateEmail, 73 | validatePhone, 74 | validateBirthDate, 75 | validateSpecialty, 76 | validateExperience, 77 | validatePassword, 78 | ]); 79 | 80 | /** 81 | * If we need error message we can use a validator factory 82 | * to “remember” what rules to use for the validation 83 | * and what errors to show when the data is invalid. 84 | */ 85 | const rules: ApplicationRules = { 86 | name: validateName, 87 | email: validateEmail, 88 | phone: validatePhone, 89 | birthDate: validateBirthDate, 90 | specialty: validateSpecialty, 91 | experience: validateExperience, 92 | password: validatePassword, 93 | }; 94 | 95 | const errors: ApplicationErrors = { 96 | name: "Your name is required for this mission.", 97 | email: "The correct email format is user@example.com.", 98 | phone: "Please, use only +, -, (, ), and whitespace.", 99 | birthDate: "We require applicants to be between 20 and 50 years.", 100 | specialty: "Please, use up to 50 characters to describe your specialty.", 101 | experience: "For this mission, we search for experience of 3+ years.", 102 | password: 103 | "Your password should be longer than 10 characters, include a capital letter and a digit.", 104 | }; 105 | 106 | export const validateForm = createValidator(rules, errors); 107 | --------------------------------------------------------------------------------