DEV Community

Cover image for Using the same validation logic on the client and server side with Isomorphic-validation.
itihon
itihon

Posted on

Using the same validation logic on the client and server side with Isomorphic-validation.

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:

View on Github

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
Enter fullscreen mode Exit fullscreen mode

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);
  },
};

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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; 
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.',
});

Enter fullscreen mode Exit fullscreen mode

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.',
});

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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',
});

Enter fullscreen mode Exit fullscreen mode

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',
});

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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:

validations dependency graph

3. Creating UI effects

.
⊞─── public
├── repository.js
⊟─── validation
    ⊞── profiles
    ⊟── ui
    │   └── apply-effects.js
    ⊞── validations
    ⊞── validators
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };

Enter fullscreen mode Exit fullscreen mode

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 };

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Now our dependency graph has the following structure:

profiles dependency graph

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
Enter fullscreen mode Exit fullscreen mode

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 ...');
});

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The final dependency graph looks as the following:

final dependency graph

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)