Declarative code: it’s a hot term in all types of programming. But what does it really mean? More importantly, how do you make the jump from understanding the concept to actually writing declarative code in your projects?
This can be a challenging mental shift to make. Fortunately, modern versions of JavaScript make getting started with declarative code easier than ever.
Declarative vs. Imperative Language
In addition to its popularity with coders, declarative vs. imperative language has plenty of relevance to other disciplines. Consider the following sentence:
I got in my car, drove to the market, put food in my cart and paid the cashier.
The sentence above is imperative: it describes how to do something. Like a JRR Tolkien novel, it's filled with detail. However, it's missing what all of these steps add up to. This is where declarative language comes in.
I bought groceries from the grocery store.
The sentence above is declarative. It describes what you’ve done without elaborating on how you’ve done it. It's the Hemingway version of buying groceries.
Declarative language is often used to shortcut information which is already clear to the reader. Most people know the steps involved in going to the store, no need to burden them with the details.
Code can be written in the same way, using a declarative style to quickly communicate purpose without getting bogged down in implementation.
Never Use “For” Loops to Iterate Arrays Again
I don’t iterate arrays with for
loops anymore. And with good reason: I have to read every single line of a for loop to understand what it's doing. Looping isn’t the issue, it’s the lack of context that a plain for
loop provides. This is the hole that declarative code can fill, and JavaScript provides built-in functions to help us out.
Array.map()
Take a look at the example below, try to figure out what it's doing:
const numbers = [1, 2, 3, 4]; const numbersDoubled = []; for (let i = 0; i < numbers.length; i++) { numbersDoubled.push(numbers[i] * 2); }
The code above uses a for loop to iterate over the numbers
array. During each iteration, the current number is multiplied by two and pushed to numbersDoubled
. When the loop has finished, every value in numbersDoubled
will be twice that of its corresponding value in numbers
.
The solution above is functional, but you have to parse every line and then make an intuitive leap to understand what the code is doing. Can this fairly simple operation be communicated in an easier-to-understand way?
const numbers = [1, 2, 3, 4]; const numbersDoubled = numbers.map((number) => number * 2);
This solution is more declarative. Array.map() returns a new array derived from the value of the array on which it is called. Each value in the source array is passed to a callback function, where it can be transformed before being passed to the new array.
Just like going to the grocery store, the steps of looping over an array and generating a new variable are the same every time. No need to constantly re-write them!
This might seem like a trivial difference, but Array.map() communicates a lot of information that I'd otherwise need to piece together myself. With Array.map(), I know that numbersDoubled
will be a new array derived from numbers
, and that it will be the same length as numbers. Unless casting occurs in the callback, I can reasonably assume its values will the same type as numbers
. The only thing I have to parse is the callback function.
BUT WAIT, this solution provides even more new information. Array.map() is immutable, meaning it returns a new array instead of modifying the source array. By using this Array.map(), I'm indicating that numbers
will not be modified when deriving numbersDoubled
.
Look at all that information! I've communicated a lot more about my code while also managing to write less of it.
Array.filter()
Consider another scenario. Instead of doubling each value in numbers
, I want to create a copy that contains only even numbers. An imperative solution might look like this:
const numbers = [1, 2, 3, 4]; const evenNumbers = []; for (let i = 0; i < numbers.length; i++) { if (numbers[i] % 2 === 0) { evenNumbers.push(numbers[i]); } }
for
loop above iterates over the array and uses the remainder operator to determine if each number is evenly divisible by 0. If this expression is truthy, the number is pushed to evenNumbers
. Like the first example, this solution works but it must be parsed to be understood.Fortunately, there's Array.filter(). Similar to map, Array.filter() creates a new array by passing each value in the source array to a callback function. However, this callback must simply return true
or false
. If the return value is true
, the value is included in the new array. If false
, it is left out.
const numbers = [1, 2, 3, 4]; const evenNumbers = numbers.filter((number) => number % 2 === 0);
The solution above still uses the remainder operator to determine if number is even, but the steps of iterating over the source and populating a new array are concisely handled by the filter function.
This is a big improvement, but this operation can be even more declarative. An easy target for writing declarative code is standardizing operations: what operations in this example could be turned into a reusable function?
const isNumberEven = (number) => number % 2 === 0; const numbers = [1, 2, 3, 4]; const evenNumbers = numbers.filter(isNumberEven);
evenNumbers
is a constant value immutably derived from numbers
, and that the filter is only including numbers which are even. That's a lot of information in very few lines.As operations become more complex, the information communicated by declarative code becomes even more valuable. Let's look at another example.
Array.reduce()
This time around, I want to sum all the values in numbers
. An imperative approach might look like this:
const numbers = [1, 2, 3, 4]; let numbersTotal = 0; for (let number of numbers) { numbersTotal += number; }
The code above sums the array of numbers, but it still doesn’t tell us anything about itself. I could be performing any number of actions inside this loop, the only way to find out is to read it.
const numbers = [1, 2, 3, 4]; const numbersTotal = numbers.reduce((total, number) => total += number , 0);
Array.reduce() provides important context: it says new values are being derived from the contents of any array. This new value can be of any type, but common usages include mathematical operations like the summing above.
The syntax is the same as map and filter, but adds another argument. The 0
at the end is called the “accumulator”. Each iteration passes the accumulator into the callback function as the first argument, where it can be updated before finally being returned as the output of the reduce function. In this scenario, I am adding each number from the array to the accumulator. When complete, the result is the sum of every number in numbers
!
This solution has the added benefit of updating numbersTotal
to a const. Since this variable is never changes, the const keyword is more accurate than its let counterpart (which allows value reassignment)
Like the filter example, the process of adding two numbers can be made more declarative. Here's an example:
const addNumbers = (numberOne, numberTwo) => numberOne + numberTwo; const numbers = [1, 2, 3, 4]; const numbersTotal = numbers.reduce(addNumbers, 0);
The Big Picture
Let's look at all three of these operations performed imperatively:
const numbers = [1, 2, 3, 4]; const numbersDoubled = []; for (let i = 0; i < numbers.length; i++) { numbersDoubled.push(numbers[i] * 2); } const evenNumbers = []; for (let i = 0; i < numbers.length; i++) { if (numbers[i] % 2 === 0) { evenNumbers.push(numbers[i]); } } let numbersTotal = 0; for (let number of numbers) { numbersTotal += number; }
const doubleNumber = (number) => number * 2; const isNumberEven = (number) => number % 2 === 0; const addNumbers = (numberOne, numberTwo) => numberOne + numberTwo; const numbers = [1, 2, 3, 4]; const numbersDoubled = numbers.map(doubleNumber); const evenNumbers = numbers.filter(isNumberEven); const numbersTotal = numbers.reduce(addNumbers, 0);
Declarative code will make your projects easier-to-read, more self-documenting and easier to test. As if you need any more reasons, it's also a fantastic entry point into functional programming (but we'll save that for another blog).
Previous Post