Being a multi-paradigm programming language, Javascript allows writing code in different styles. And while you probably don't want to mix OOP and functional approaches on the architectural level, there is no reason to limit the power of a multi-paradigm language on a smaller code unit level. Nonetheless, functional programming techniques are often ignored where they would fit nicely. In this article, I'd like to demonstrate how one of such simple techniques can make our code better.
Without further theoretical reasoning, here is what we are going to do:
- Write a module that checks if all object fields pass the provided validation rules.
- Start with the most straightforward imperative implementation and improve the code in a couple of steps.
- Compare the results.
We will not do this just to demonstrate how different techniques can be used to solve the same problem. Our goal is to improve two very important metrics of clean code: re-usability and readability. This article covers the very basics, but I will provide some links at the end if you want to dive a little deeper into this topic.
Just make it work first
Assume our application allows users to publish new articles that will show up somewhere in the feed. Just like the one you are reading now. The shape of the article will be as simple as possible:
{
title: String;
tags: Array<String>;
text: String;
}
On the server side before saving a new article in the database we have to validate if article fields conform to the following validation rules:
-
title
is a string not shorter than 20 and not longer than 200 characters. -
tags
is an array of strings with at least one element in it. -
text
is a string not shorter than 200 characters and not longer than 100 000 characters.
Translated to code, the first and most naive definition of validator rules might look like this:
const rules = {
title: value => typeof value === 'string' && value.length > 20 && value.length < 200,
tags: value => Array.isArray(value) && value.length > 0,
text: value => typeof value === 'string' && value.length > 200 && value < 100_000
}
(side note) Yes, you can use the _
symbol as a numeric separator in big numbers since ES2021 to improve readability. Just in case you don't do this yet:)
Now let's create the function that will iterate over the object's fields and validate each of those. Instead of just returning true
or false
to indicate if the object passed validation, we want the function to return an array of erroneous fields (if any) so we can provide some feedback to the client.
const getValidationErrors = (rules, objectToValidate) => {
return Object.entries(rules).reduce((acc, [key, validatorFn]) => {
const value = objectToValidate[key];
if (!validatorFn(value)) {
return [...acc, key];
}
return acc;
}, []);
}
The validation algorithm is quite primitive:
- Create an array of key-value pairs of the
rules
object where the key is the name of the field we are validating. - For each key-value pair get the relevant field's value and run the validator function provided for this field name in the
rules
. If validation fails, add the key name in question to the errors array. - Return the errors array (return value of the
reduce
function).
We would want more from this algorithm in a real-world application(validate inputs, handle errors), but for this demo purpose let's keep the shortest version.
Obvious refactoring
There are some drawbacks to this implementation that we can spot immediately. We will likely need to validate the same conditions in other places in our application. Extracting these validators into reusable functions exported from the validation utils module is an obvious improvement.
export const isString = (value) => {
return typeof value === 'string' || value instanceof String;
}
export const isNonEmptyArray = (value) => {
return Array.isArray(value) && value.length > 0;
}
export const lengthIsInRange = (value, min, max) => {
return value.length > min && value.length < max;
}
No changes needed in the getValidationErrors
function. But rules
now look much better:
const rules = {
title: isString(value) && lengthIsInRange(value, 30, 250),
tags: isNonEmptyArray(value),
text: isString(value) && lengthIsInRange(value, 0, 100_000)
}
Let's look at the code we have written so far and evaluate it:
- Our code does its job, fields are being validated.
- The
getValidationErrors
function is not too bad. It's concise and provides feedback. - Validators are defined as reusable functions, so our code is DRY enough.
Are we good to push the changes and open a PR? 👌
It's probably OK to do so at this point. But our code can be made better with very little effort. We are calling functions, but do not use good functional programming practices.
Currying to make it elegant
If you look at the code you will likely notice how we repeatedly write (value)
calling each validator and apply the logical AND
operator multiple times. When you see this, chances are high that these repeating parts can be abstracted.
In my validator rule, I'd like to just list validators separated by commas. Then inside getValidationErrors
function, I will call each of these validator functions with the value
to validate as a single parameter. It's easily doable for isString
and isNonEmptyArray
, but lengthIsInRange
has two other parameters that we need to apply in advance before the function will be called along with other validators. This is where a simple concept of currying comes into play. Let's rewrite lengthIsInRange
as a higher order function:
export const lengthIsInRange = (min, max) => {
return (value) => value.length > min && value.length < max;
}
Here we prepare our validator by fixing min
and max
arguments beforehand in the lexical context of the outer function. This allows us to call the actual validator function later with only one argument: the object to validate. Which is exactly what we needed.
All we need to do the getValidationErrors
function is to rename validatorFn
to validators
and change this line:
if (!validatorFn(value))
to:
if (!validators.every((rule) => rule(value)))
Here is the full version of the updated getValidationErrors
:
const getValidationErrors = (rules, objectToValidate) => {
return Object.entries(rules).reduce((acc, [key, validators]) => {
const value = objectToValidate[key];
if (!validators.every((rule) => rule(value))) {
return [...acc, key];
}
}, []);
}
And our rules definitions will now look like this:
const rules = {
title: [isString, lengthIsInRange(30, 250)],
tags: [isNonEmptyArray],
text: [isString, lengthIsInRange(200, 100_000)]
};
Compare this version to previous implementations. The goal we set at the beginning was to improve code re-usability and readability. I will leave it up to you to decide which version looks better from this point of view.
Bonus: function as a language first-class object
We probably don't want to run any additional validations if the field is optional and the value for this field is missing. We hence need a way to distinguish between required and optional fields. Let's create a separate validator in its simplest possible version for this purpose:
export const isDefined = (value) => {
return value !== undefined;
}
And update our rules, making tags
field optional:
const rules = {
title: [isDefined, isString, lengthIsInRange(30, 250)],
tags: [isNonEmptyArray],
text: [isDefined, isString, lengthIsInRange(200, 100_000)]
};
Now in our getValidationErrors
function, we can check if isDefined
is among validators first and only then run the remaining validations, otherwise just proceed to the next field. How do I do this? As you know, functions are first class objects in Javascript, so I can check for function presence in an Array just like I would do for a string or a number:
if (!isDefined(value) && !validators.includes(isDefined)) {
return acc;
}
Full version of revamped getValidationErrors
:
const getValidationErrors = (rules, objectToValidate) => {
return Object.entries(rules).reduce((acc, [key, validators]) => {
const value = objectToValidate[key];
if (!isDefined(value) && !validators.includes(isDefined)) {
return acc;
}
if (!validators.every((rule) => rule(value))) {
return [...acc, key];
}
}, []);
}
If the value is not defined and isDefined
is not among validators the function returns early. Since it happens in the reduce
context, don't forget to return the unchanged accumulator value. This way, we keep our rules definitions clean, avoiding the need of creating data structures like {required: true, validators: [...]}
. 👏
Bear in mind this is not necessarily the way to go for every single task. You should always weigh the pros and cons and pick the right data structure for your requirements. But to make efficient choices it is paramount to be aware of the capabilities that language offers you.
Is getValidationErrors
getting too messy?
It probably is. While the function body is still only 9 lines long and its readability is acceptable, the function is doing several things now. Since we are talking about functional programming techniques, let's turn getValidationErrors
into a chain of operations applied to the rules
object entries:
const getValidationErrors = (rules, objectToValidate) => {
return Object.entries(rules)
.map(([field, validators]) => ({
field,
validators,
value: objectToValidate[field]
}))
.filter(shouldReportValidationError)
.map(({field}) => field);
}
const shouldReportValidationError = (validationData) => {
return shouldBeValidated(validationData) && !passesAllValidators(validationData);
}
const shouldBeValidated = ({validators, value}) => {
const canSkipValidation = !isDefined(value) && !validators.includes(isDefined);
return !canSkipValidation;
};
const passesAllValidators = ({validators, value}) => {
return validators.every((rule) => rule(value));
};
A quick look at the getValidationErrors
is enough now to understand it is doing the following things for every field:
- Mapping the field's validation rules and value to a data structure for further processing.
- Applying validation rules abstracted in
shouldReportValidationError
. - Mapping erroneous fields to the correct error format (just the name of the field in our case).
In a real-world scenario when the logic behind every step will likely be more complex, the value of such refactoring grows. We decomposed the logic in several steps and no longer mix array processing with imperative code on the getValidationErrors
level.
Code examples
You can play around with code examples from this article in an online editor by this link. Just type node <file name>.js
in the terminal to run the code.
Conclusion
Hope this article motivates you to leverage currying and other useful functional programming techniques in your Javascript code.
If you wish to learn more about functional techniques in Javascript I highly recommend this series of articles by Randy Coulman. In particular, the third article in the series explains the idea of currying or partial application in more detail.
Thanks for reading!
Top comments (0)