In this post I want to briefly describe the usage of Isomorphic-validation, a javascript validation library that was intended to be used on the client and server side and make the process of validating user input seamless across your application.
In the two previous posts I covered some aspects of using this library on the client side by creating a sign-in and sign-up form:
Here I will show why this library is actually isomorphic. I'm going to incorporate that sign-in and sign-up examples into a simple Node.js
application.
If you want to run this project locally it is there:
0. Setting up the project
First, I created the following folder structure:
.
⊟── public
│ ⊞─── bundles
│ ├── signin.html
│ ├── signup.html
│ └── style.css
├── repository.js
⊟─── validation
⊞── profiles
⊞── ui
⊞── validations
⊞── validators
repository.js
// repository.js
/**
* Mock repository
*/
const existingEmails = new Map([
['[email protected]', 1],
['[email protected]', 2],
['[email protected]', 3],
['[email protected]', 4],
]);
export default {
async getUserIdBy({ email = '' }) {
return existingEmails.get(email);
},
};
public/signin.html
<!-- public/signin.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Sign in</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<form action="signin" method="post" name="signinForm" autocomplete="off">
<h2>Sign in</h2>
<label class="form-field" for="email">
<input type="text" name="email" id="email" required />
<span class="field-title">E-mail</span>
</label>
<label class="form-field" for="password">
<input type="password" name="password" id="password" required />
<span class="field-title">Password</span>
</label>
<input type="submit" name="submitBtn" disabled />
</form>
<script type="module" src="bundles/signin.js"></script>
</body>
</html>
public/signup.html
<!-- public/signup.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Sign up</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<form action="signup" method="post" name="signupForm" autocomplete="off">
<h2>Sign up</h2>
<label class="form-field" for="email">
<input type="text" name="email" id="email" required />
<span class="field-title">E-mail</span>
</label>
<label class="form-field" for="password">
<input type="password" name="password" id="password" required />
<span class="field-title">Password</span>
</label>
<label class="form-field" for="pwdConfirm">
<input type="password" name="pwdConfirm" id="pwdConfirm" required />
<span class="field-title">Password confirmation</span>
</label>
<input type="submit" name="submitBtn" disabled />
</form>
<script type="module" src="bundles/signup.js"></script>
</body>
</html>
public/style.css
// public/style.css
* {
padding: 0;
margin: 0;
box-sizing: border-box;
font-family: Helvetica;
}
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: lightslategray;
}
h2 {
text-align: center;
width: 100%;
}
form {
display: flex;
flex-wrap: wrap;
gap: 48px;
justify-content: flex-start;
align-items: center;
width: min-content;
padding-inline: 24px;
padding-block: 32px;
border: 1px solid slategray;
border-radius: 2px;
background: rgb(255,255,255);
background: linear-gradient(123deg, rgba(255,255,255,1) 24%, rgba(244,250,255,1) 51%, rgba(255,255,255,1) 77%);
}
label {
padding: 4px;
background-color: white;
}
input {
height: 32px;
width: 100%;
border: none;
background: none;
outline: none;
font-size: 16px;
}
input[type="submit"] {
height: 42px;
border: 1px solid lightslategray;
background-color: slategray;
color: white;
transition: opacity .1s;
}
input[type="submit"]:disabled {
opacity: .5;
cursor: not-allowed;
}
input[type="submit"]:not(:disabled):hover {
filter: brightness(1.1);
}
.form-field {
width: 220px;
position: relative;
border: 1px solid slategray;
border-radius: 2px;
cursor: text;
}
.field-title {
position: absolute;
top: 0;
padding-block: 4px;
display: grid;
align-items: center;
height: 100%;
color: slategray;
transition: font-size .1s, height .1s ease-out;
}
.form-field > input:valid + .field-title {
font-size: 12px;
height: 20px;
}
.form-field > input:valid {
padding-top: 14px;
}
I didn't want to set up the whole database for this simple demonstration, so I just created a mock repository file with registerd e-mail addresses in order to implement the checking an e-mail for existance functionality on the sign-up form.
1. Preparing validators
.
⊞─── public
├── repository.js
⊟─── validation
⊞── profiles
⊞── ui
⊞── validations
⊟── validators
├── is-email.js
├── is-email-not-registered.js
├── is-email-not-registered-s.js
├── is-max-length.js
├── is-min-length.js
├── is-password-confirmed.js
└── is-strong-password.js
validation/validators/is-email.js
// validation/validators/is-email.js
import { Predicate } from 'isomorphic-validation';
import isEmail from 'validator/es/lib/isEmail';
export default Predicate(isEmail, {
err: 'Must be in the E-mail format.',
});
validation/validators/is-email-not-registered.js
// validation/validators/is-email-not-registered.js
import { Predicate } from 'isomorphic-validation';
const isEmailNotRegistered = value =>
fetch('/checkemail', { method: 'post', body: new URLSearchParams({ email: value }) }).then(res => res.json());
export default Predicate(isEmailNotRegistered, {
err: 'The E-mail must not be already registered.',
waitMsg: 'Wait a moment, we are checking your E-mail.',
});
validation/validators/is-email-not-registered-s.js
// validation/validators/is-email-not-registered-s.js
import repository from '../../repository.js';
const isEmailNotRegisteredS = async value => !(await repository.getUserIdBy({ email: value }));
export default isEmailNotRegisteredS;
validation/validators/is-max-length.js
// validation/validators/is-max-length.js
import { Predicate } from 'isomorphic-validation';
import isLength from 'validator/es/lib/isLength';
const isMaxLength = max =>
Predicate(value => isLength(value, { max }), {
err: `Should not be longer than ${max} characters.`,
});
export default isMaxLength;
validation/validators/is-min-length.js
// validation/validators/is-min-length.js
import { Predicate } from 'isomorphic-validation';
import isLength from 'validator/es/lib/isLength';
const isMinLength = min =>
Predicate(value => isLength(value, { min }), {
err: `Must be at least ${min} characters long.`,
});
export default isMinLength;
validation/validators/is-password-confirmed.js
// validation/validators/is-password-confirmed.js
import { Predicate } from 'isomorphic-validation';
import equals from 'validator/es/lib/equals';
export default Predicate(equals, {
err: 'Password and password confirmation must be the same',
});
validation/validators/is-strong-password.js
// validation/validators/is-strong-password.js
import { Predicate } from 'isomorphic-validation';
import isStrongPassword from 'validator/es/lib/isStrongPassword';
export default Predicate(isStrongPassword, {
err: 'Min. 8 symbols, 1 capital letter, 1 number, 1 special character',
});
Here I'm using validators from the library validator.js. You can use validators provided by other libraries or write your own. They only have to return a Boolean
value or a Promise
that fullfils with a Boolean
value. I wrap them in Predicate
in order to pass error messages along with them. If your project requires internationalization you can pass translation keys instead. See an example of usage with i18next library.
There are two validators that will be checking an e-mail registration, one is-email-not-registered.js
which will be executed on the client side and make a request to the server, and is-email-not-registered-s.js
which will be making a request to the database on the server, that is in our case to the mock repository.
2. Preparing validations
.
⊞─── public
├── repository.js
⊟─── validation
⊞── profiles
⊞── ui
⊟── validations
│ ├── email.js
│ └── password.js
⊞─── validators
validation/validations/email.js
// validation/validations/email.js
import { Validation } from 'isomorphic-validation';
import isEmail from '../validators/is-email.js';
import isMinLength from '../validators/is-min-length.js';
import isMaxLength from '../validators/is-max-length.js';
const emailV = Validation()
.constraint(isMinLength(8), { next: false })
.constraint(isMaxLength(48), { next: false })
.constraint(isEmail, { next: false });
export default emailV;
validation/validations/password.js
// validation/validations/password.js
import { Validation } from 'isomorphic-validation';
import isStrongPassword from '../validators/is-strong-password.js';
const passwordV = Validation().constraint(isStrongPassword);
export default passwordV;
Here I'm creating two Validation
objects for e-mail and password fields with validators that will be shared by both forms. We do not duplicate the validation logic even though the sign-up form differs from the sign-in form in the way that it requires additional validators for checking an e-mail registration and checking password and password confirmation equality. We will add them in the following steps.
At this point our dependency graph looks like this:
3. Creating UI effects
.
⊞─── public
├── repository.js
⊟─── validation
⊞── profiles
⊟── ui
│ └── apply-effects.js
⊞── validations
⊞── validators
validation/ui/apply-effects.js
// validation/ui/apply-effects.js
import { Validation } from 'isomorphic-validation';
import { applyAccess, applyOutline, applyBox, renderFirstError, renderProperty } from 'isomorphic-validation/ui';
const delayedOutline = {
false: { delay: 2000, value: '2px solid lightpink' },
true: { delay: 500, value: '' },
};
const delayedAccess = {
true: { delay: 600 },
};
const disabledAccess = {
false: { value: true },
true: { value: true },
};
const remainedOutline = {
false: { delay: 20000, value: '' },
true: { delay: 20000, value: '' },
};
const changedOutline = {
false: { delay: 500, value: '2px solid lightpink' },
true: { delay: 500, value: '' },
};
const editIcon = {
false: { value: '🖊' },
true: { value: '🖊' },
position: 'LEVEL_RIGHT',
};
const loadImg = `
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDUwIDUwIj4KICA8Y2lyY2xlIGN4PSIyNSIgY3k9IjI1IiByPSIyMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjBBNUZBIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWRhc2hhcnJheT0iNjAgMTIwIj4KICAgIDxhbmltYXRlVHJhbnNmb3JtIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgdHlwZT0icm90YXRlIiBmcm9tPSIwIDI1IDI1IiB0bz0iMzYwIDI1IDI1IiBkdXI9IjFzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSI+PC9hbmltYXRlVHJhbnNmb3JtPgogIDwvY2lyY2xlPgo8L3N2Zz4=" alt="SVG Image" />
`;
const loadIcon = {
false: { delay: 1000, value: loadImg },
true: { delay: 1000, value: loadImg },
position: 'LEVEL_RIGHT',
};
const validIcon = {
false: { delay: 1000, value: '' },
true: { delay: 500, value: '✔' },
position: 'LEVEL_RIGHT_BESIDE',
style: { color: 'green', left: '-8px' },
};
const errMsg = {
false: { delay: 2000, value: renderFirstError('err') },
position: 'BELOW_CENTER',
mode: 'MAX_SIDE',
style: { color: 'firebrick', fontSize: '12px', padding: '4px' },
};
const changedMsg = {
false: { delay: 500, value: renderFirstError('err') },
position: 'BELOW_CENTER',
mode: 'MAX_SIDE',
style: { color: 'firebrick', fontSize: '12px', padding: '4px' },
};
const waitMsg = {
false: { delay: 1000, value: renderProperty('waitMsg') },
true: { delay: 1000, value: renderProperty('waitMsg') },
position: 'BELOW_CENTER',
mode: 'MAX_SIDE',
style: { color: 'steelblue', fontSize: '12px', padding: '4px' },
};
const applyEffects = validationProfile => {
const [form, groupingValidation] = validationProfile;
form.addEventListener(
'input',
Validation.group(
groupingValidation.validations.map((validation, idx) => {
const iconEID = form[idx].name + 'icon';
const errMsgEID = form[idx].name + 'error';
const outlineEID = form[idx].name + 'outline';
const formField = form[idx].parentNode;
return validation
.started(applyOutline(formField, remainedOutline, outlineEID))
.validated(applyOutline(formField, delayedOutline, outlineEID))
.changed(applyOutline(formField, changedOutline, outlineEID))
.started(applyBox(formField, editIcon, iconEID))
.started(applyBox(formField, loadIcon, iconEID))
.validated(applyBox(formField, validIcon, iconEID))
.changed(applyBox(formField, validIcon, iconEID))
.started(applyBox(formField, waitMsg, errMsgEID))
.validated(applyBox(formField, errMsg, errMsgEID))
.changed(applyBox(formField, changedMsg, errMsgEID));
}),
)
.started(applyAccess(form.submitBtn, disabledAccess))
.validated(applyAccess(form.submitBtn, delayedAccess)),
);
};
export default applyEffects;
Here I simply wrap the effects I demonstrated in the previous post's example in a function in order to apply them to both forms. Insted of using the library's UI effect functions, you can also create your own effects, make them a part of your components and use Validation
or Predicate
objects inside your components to connect validity states to your components' states.
4. Creating validation profiles
.
⊟── public
├── repository.js
⊟── validation
⊟── profiles
│ ├── signin.js
│ └── signup.js
⊞─── ui
⊞─── validations
⊞─── validators
validation/profiles/signin.js
// validation/profiles/signin.js
import { Validation } from 'isomorphic-validation';
import emailV from '../validations/email.js';
import passwordV from '../validations/password.js';
import applyEffects from '../ui/apply-effects.js';
/** Creating profiles */
const [signinForm, signinV] = Validation.profile('[name="signinForm"]', ['email', 'password'], [emailV, passwordV]);
/** UI effects */
applyEffects([signinForm, signinV]);
export { signinForm, signinV };
validation/profiles/signup.js
// validation/profiles/signup.js
import { Validation } from 'isomorphic-validation';
import emailV from '../validations/email.js';
import passwordV from '../validations/password.js';
import isPasswordConfirmed from '../validators/is-password-confirmed.js';
import isEmailNotRegistered from '../validators/is-email-not-registered.js';
import applyEffects from '../ui/apply-effects.js';
const pwdConfirm = Validation();
/** Creating profiles */
const [signupForm, signupV] = Validation.profile(
'[name="signupForm"]',
['email', 'password', 'pwdConfirm'],
[emailV, passwordV, pwdConfirm],
);
signupV.email.client.constraint(isEmailNotRegistered, { debounce: 5000 });
Validation.glue(signupV.password, signupV.pwdConfirm).constraint(isPasswordConfirmed);
/** UI effects */
applyEffects([signupForm, signupV]);
export { signupForm, signupV };
Here signin.js
and signup.js
will be entry points for a module bundler. This is the place where we apply UI effects. Also, signup.js
is the place to add sign-up form specific validators:
// this validator will be added on the client side only
signupV.email.client.constraint(isEmailNotRegistered, { debounce: 5000 });
Validation.glue(signupV.password, signupV.pwdConfirm)
.constraint(isPasswordConfirmed);
Now our dependency graph has the following structure:
We are using the same validations on both forms without duplicating.
5. Creating the server
.
├── index.js
⊞─── public
├── repository.js
⊟── validation
⊞─── profiles
⊞─── ui
⊞─── validations
⊞─── validators
index.js
// index.js
import express from 'express';
import bodyParser from 'body-parser';
import { signinV } from './validation/profiles/signin.js';
import { signupV } from './validation/profiles/signup.js';
import isEmailNotRegisteredS from './validation/validators/is-email-not-registered-s.js';
const app = express();
const urlencodeParser = bodyParser.urlencoded({ extended: false });
app.use(express.static('public'));
function signinHandler(req, res) {
const { validationResult } = req;
if (validationResult.isValid) {
// check credentials and authorize the user
// ...
res.send('VALID');
} else {
// respond with the validation error
// ...
res.json(validationResult);
}
}
function signupHandler(req, res) {
const { validationResult } = req;
if (validationResult.isValid) {
// create an account
// ...
res.send('VALID');
} else {
// respond with the validation error
// ...
res.json(validationResult);
}
}
function checkemailHandler(req, res) {
res.json(req.validationResult.isValid);
}
const emailV = signupV.email.server.constraint(isEmailNotRegisteredS);
// validations are added as middlewares
app.post('/signin', urlencodeParser, signinV, signinHandler);
app.post('/signup', urlencodeParser, signupV, signupHandler);
app.post('/checkemail', urlencodeParser, emailV, checkemailHandler);
app.listen(8080, () => {
console.log('Listening port 8080 ...');
});
On the server side, we use validations as Express
middleware functions:
// here the .server property actually can be ommited
const emailV = signupV.email.server.constraint(isEmailNotRegisteredS);
// validations are added as middleware functions
app.post('/signin', urlencodeParser, signinV, signinHandler);
app.post('/signup', urlencodeParser, signupV, signupHandler);
app.post('/checkemail', urlencodeParser, emailV, checkemailHandler);
Final result
This is the final project's file system structure:
.
├── index.js
⊟── public
│ ⊞─── bundles
│ ├── signin.html
│ ├── signup.html
│ └── style.css
├── repository.js
⊟── validation
⊟── profiles
│ ├── signin.js
│ └── signup.js
⊟── ui
│ └── apply-effects.js
⊟── validations
│ ├── email.js
│ └── password.js
⊟── validators
├── is-email.js
├── is-email-not-registered.js
├── is-email-not-registered-s.js
├── is-max-length.js
├── is-min-length.js
├── is-password-confirmed.js
└── is-strong-password.js
The final dependency graph looks as the following:
Conclusion
What we achived so far:
No duplicates of validation logic across different forms with the same kind of fields.
No duplicates of validation logic between client and server.
All validation logic resides in one place which provides one source of truth.
If you read up to this point I'd like to know your opinion. What do you think about this approach? Does this library have the right to exist?
Top comments (0)