5 JavaScript Practices That Will Help You In The Long Run
August 14th, 2021
In this article, I will go over some practices in JavaScript that will help you in the long run. You might have already heard of some (or all) of them, but it's the details that follow below them that is most important.
Some of these examples are real-world examples taken from a production code-base. Since they were shipped to production I would like to take this opportunity to help others understand the good bad when we write code.
As time passes the day to realize this becomes an important practice comes closer than ever. By not handling different data types going into your functions there is a good chance your program will suffer from errors sooner or later. You either learn by a real mistake or you learn from resources that help you avoid future mistakes.
I have come across many situations in code that look something like this:
function createList({ list = [] }) {
return `
<ul>
${list.map((item) => {
return `
<li>
${item.title}
</li>
`
})}
</ul>
`
}
While this runs perfectly fine without issues, what I find is that developers often read this as "default list to an empty array" and assume that this will combat errors where list was passed in as an unexpected/bad type. But JavaScript reads this as "default list to an empty array when it does not have a value to default to or when it is undefined
".
Prior to ES6 the way most of us initialized values was to use the ||
operator like this:
function createList({ list }) {
list = list || []
return `
<ul>
${list.map((item) => {
return `
<li>
${item.title}
</li>
`
})}
</ul>
`
}
This closely resembles the behavior from the previous example and since code has shifted (conventially) to use defaulted parameters to do this, new developers who are learning JavaScript who are interchanging between learning from old and new tutorials might mistaken this as the same behavior because the practice is used to achieve the same goal.
So if this function was called and passed in null
, we would receive a TypeError
because we are using an array method on a null
value. Since null
is a value, JavaScript will accept this and use it to default list
to null
.
If you use TypeScript, it will catch this and present you with an error message. This is true, but its actually not uncommon where I see people silence crucial errors by writing // @ts-ignore
. Please do not ignore TypeScript errors, they are there to help you fix them before something bad will happen.
The difference between ternary operators and the &&
(logical AND) is not that much different when trying to assign a value to something. Though the small difference between these two can actually become your savior more often than you would imagine.
I'm not talking about scenarios where you would use it in an if
statement:
if (value !== null && value) {
// Do something
}
In these cases the &&
operator is perfectly fine and is a good choice to write code in a cleaner way.
But when you start to assign values it is a bad practice! By relying on &&
, you as a developer are responsible for ensuring that it won't produce errors when different data types are received.
For example in unexpected situations like below:
function createListItem(item) {
return item && `<li>${item.title}</li>`
}
function createList({ list = [] }) {
return `
<ul>
${list.map((item) => {
return createListItem(item)
})}
</ul>
`
}
This will produce an unexpected result like this:
<ul>
<li>undefined</li>
</ul>
This happens because when we use &&
it will immediately return the value of the first operand that evaluates to false
By using ternary operators it forces us to default to a value we expect, making our code more predictable:
function createListItem(item) {
return item ? `<li>${item.title}</li>` : ''
}
function createList({ list = [] }) {
return `
<ul>
${list.map((item) => {
return createListItem(item)
})}
</ul>
`
}
Now we can at least expect a cleaner outcome when a bad type is passed in:
<ul></ul>
Users who are not technical geniuses might not know what undefined
means whereas the technical people will quickly catch that this is a human coding flaw.
Speaking of ternary operators here is a real world code example written from somebody:
await dispatch({
type: 'update-data',
payload: {
pageName,
dataKey: dataOut ? dataOut : dataKey,
data: res,
},
})
For those who might not know, this can be rewritten to:
await dispatch({
type: 'update-data',
payload: {
pageName,
dataKey: dataOut || dataKey,
data: res,
},
})
This is because the way the ternary operator works is that the first operand is evaluated as a condition that is used to decide whether to return the value in the second or third operand.
Though the code is valid the reason why I brought this up is to explain that ternary operators are best used to close the gap between certainty and uncertainty.
In the previous example we aren't really sure what item
will be in the way it is written:
function createListItem(item) {
return item && `<li>${item.title}</li>`
}
If we use ternary operators, we can be certain that the item
will not be implicitly included as a child of the parent ul
element:
function createListItem(item) {
return item ? `<li>${item.title}</li>` : ''
}
Once you realize you are using two pieces of code in more than one place it is a good idea to start thinking about creating a helper utility.
Consider this example:
function newDispatch(action) {
if (!isObject(action)) {
throw new Error('Actions must be plain objects')
}
if (typeof action.type === 'undefined') {
throw new Error('Action types cannot be undefined.')
}
//TODO: add is Dispatching
this.root = this.reducer(this.root, action)
return action
}
function rawRootDispatch(action) {
if (!isObject(action)) {
throw new Error('Actions must be plain objects')
}
if (typeof action.type === 'undefined') {
throw new Error('Action types cannot be undefined.')
}
this.rawRoot = this.rawRootReducer(this.rawRoot, action)
return action
}
The problem with this is that it is not very manageable in the long run. If we make more functions that work with action objects and needed to validate them to be objects before continuing, we have to write more of these:
if (!isObject(action)) {
throw new Error('Actions must be plain objects')
}
There is also not much control besides throwing an error. What if we don't want the program to fail but still want values to go through the validation process?
A function utility will solve those problems:
function validateObject(value, { throw: shouldThrow = false } = {}) {
if (!isObject(action)) {
if (shouldThrow) {
throw new Error('Actions must be plain objects')
}
return false
}
return true
}
Then there is also the validation to check if action.type is undefined
:
if (typeof action.type === 'undefined') {
throw new Error('Action types cannot be undefined.')
}
Since we have a validateObject
utility we can reuse it:
function validateAction(value, { throw: shouldThrow = false }) {
if (validateObject(value)) {
if (typeof value.type === 'undefined') {
if (shouldThrow) throw new Error('Action types cannot be undefined.')
return false
}
return true
}
return false
}
Since we have two validators now but have similar behavior we can further create a higher level utility to produce different/custom validators:
function createValidator(validateFn, options) {
let { throw: shouldThrow = false, invalidMessage = '' } = options
const validator = function (value, otherOptions) {
if (validateFn(value)) return true
if (typeof otherOptions.throw = 'boolean') {
if (otherOptions.throw) throw new Error(invalidMessage)
return false
}
if (shouldThrow) throw new Error(invalidMessage)
return false
}
validator.toggleThrow = function (enableThrow) {
shouldThrow = enableThrow
}
}
Now we can make a suite of validators without having to write throw new Error('...')
everywhere:
// prettier-ignore
const allPass = (...fns) => (v) => fns.every((fn) => !!fn(v))
const isObject = (v) => v !== null && !Array.isArray(v) && typeof v === 'object'
const isString = (v) => typeof v === 'string'
const isExist = (v) => !!v
const isURL = (v) => v.startsWith('http')
const validateAction = createValidator(allPass(isObject, isExist))
const validateStr = createValidator(isString)
const validateURL = createValidator(allPass(isURL, validateStr))
const validateObject = createValidator(isObject, {
throw: true,
invalidMessage: 'Value is not an object',
})
const action = {
type: 'update-data',
payload: {
dataKey: 'form[password]',
dataOut: '',
dataObject: { firstName: 'Mike', lastName: 'Gonzo' },
},
}
console.log(validateAction(action)) // true
console.log(validateURL('http://google.com')) // true
console.log(validateURL('htt://google.com')) // false
validateObject([]) // Error: Value is not an object
I cannot stress enough of how important this is to your code. If your code will be viewed by someone other than yourself, it is a good practice to explain what your code is doing
It is one of my biggest pet peeves when I read through code because what ends up happening is you're forcing the reader to search throughout other parts of the code for hints to understand exactly what is happening which can be a headache when you need to understand it to be able to understand what comes next.
function createSignature({ sk, message, pk }: any) {
//
}
Now I don't mean comment your code as in doing this and calling it a day:
// Create the signature with the sk, message and optionally an sk
function createSignature({ sk, message, pk }: any) {
//
}
Not only is this vague but we don't know where message comes from or what it is. Is it a string? An array of strings? Is it required? Is this an actual message like what you would receive in your email? Is it okay to call it something else? What is the true meaning of it?
Do everyone a favor and be a team player:
/**
* Create the signature with the sk, message and optionally an sk
* Message should be converted to base64 before calling this function
*/
function createSignature({
sk,
message,
pk,
}: {
sk: string, // secret key
message: string,
pk: string, // public key
}) {
//
}
A good practice to follow is to name your functions in a way that it resembles what your mind is already accustomed to when we think of the good things in life.
For example, when we think of a glass cup of water, what is more positive, the glass being half full or the glass being half empty?
Although they both mean the exact same thing, the latter has the negative notion that if the glass is half empty we need to think about a refill soon. Do we have anymore water left? Will I be able to last a whole day if not?
Now if we say that the glass is half full, there is a positive notion that we are "almost there".
Now lets jump to function naming in code. If we are working with DOM nodes and we are making a function to hide or show elements, how would you name a function that checks if an input element is usable or not?
function isEnabled(element) {
return element.disabled === false
}
function isDisabled(element) {
return element.disabled === true
}
Which one would you rather use? Neither one is wrong, they are both functions that achieve the same thing without problems, only that they are named differently.
So what is the big deal?
If we think about all the times we write conditional statements or check if something is successful, majority of the time we are used to receiving true
for successful attempts, and false
for bad attempts.
This happens so often that when we write or read through code we can quickly skim through conditional statements and get away with scenarios where we assume the function behaves expectedly seeing that it returns true
if everything looks right.
But think about it. If we stuck with isEnabled
we wouldn't have to worry about other meanings behind the word "enabled". If isEnabled
returns true, that's really straight forward and we are assured that if it is not enabled then it straight up means disabled or false
.
If we stuck with isDisabled
we have to remember that true
is not a positive result from this function. This goes against what we are already accustomed to! And for this reason it is more easier to mistaken the behavior which increases the risk of errors in your code.
Here's another scenario. If we were parsing values from a YAML string, sometimes we come across a (seemingly) boolean value where true
is written as "true"
or false
as "false"
.
function isBooleanTrue(value) {
return value === 'true' || value === true
}
function isBooleanFalse(value) {
return value === 'false' || value === false
}
Consider this example in YAML syntax:
- components:
- type: button
hidden: 'false'
style:
border: 1px solid red
This parses to JSON as:
[
{
"components": [
{
"hidden": "false",
"type": "button",
"style": {
"border": "1px solid red"
}
}
]
}
]
If we were to check if an element is hidden, we have two options to choose: isBooleanTrue
and isBooleanFalse
.
Lets see how this looks like if we chose isBooleanFalse
:
import parsedComponents from './components'
const components = parsedComponents.map((parsedComponent) => {
const node = document.createElement(parsedComponent.type)
for (const [styleKey, styleValue] of component) {
node.style[styleKey] = styleValue
}
return node
})
function toggle(node) {
// Check if it is currently visible
if (isBooleanFalse(node.hidden)) {
node.style.visibility = 'hidden'
} else {
node.style.visibility = 'visible'
}
}
I find this semantic a little confusing even while writing this function. Although the behavior achieves what the toggle
functions intends, this confusion supports the general idea that our code should be simple, readable and maintainable, which is why naming your functions is so important.
And that concludes the end of this post! I found you found this to be valuable and look out for more in the future!