5 JavaScript Practices That Will Help Your Teammates Sleep At Night
August 26th, 2021
Naming our files is seemingly a simple task. But just because it is simple to do doesn't mean it wouldn't become much of a problem. Just like naming our variables in code, it is a practice that can become a huge deal when you aren't the only one writing the code for some project.
Recently, there was a colleague that maintained a package that I had to take over since he was no longer maintaining it.
In a perfect world all code should be easy to maintain and easy to work with Boy, not everything in life goes in our favor. One thing that confused me for awhile was coming across multiple files with the same name. What's worse is that both of their implementation details were similar.
So I asked the questions,
The two files are named as:
The best practice here is not exactly putting them into one file. The bigger picture here is to follow the principle that our code should be easy to maintain. If we have to be confronted with making a risky decision on between two similar things, the code simply loses simplicity. What's worse is the fact that services closely relates to utilities by their definitions.
If this is left unsolved, future teammates will go through the same situation and be asking the same questions to the same author. This is not a very good practice and wastes everyones time!
It's worth noting here that "stuff" can mean lots of things. The point is to get rid of dead/inactive code (including files) that is no longer of use in the project.
If we a have constants.js
file holding all the constants and then we have another constants.js
nested somewhere in another directory as the new location, it is a good practice to immediately remove the constant variables from the old file as soon as possible otherwise readers of your code yet again will suffer the same situation mentioned above.
If they end up importing from the wrong location in several parts of the code, someone has to go and fix each and every import before they produce errors in the production environment.
When we are debugging a function that is expecting arguments it is a good practice to name them appropriately that closely relates to the implementation details.
It is common to come across code that have their arguments written in function calls like this:
function createSignature(obj) {
if (typeof obj === 'string') {
// Do something
} else if (Array.isArray(obj)) {
// Do something
} else {
// Do something
}
}
There is nothing wrong with the code except the fact that it confuses everyone. People reading code like this will naturally expect that the parameter obj
will be some object. However, the implementation details contradict this.
Which one should we trust more, the implementation or the naming of the parameters?
We might as well not trust the entire function! A more generic naming like value
is even a better choice because a value can be any data type which is what the implementation shows.
So when we try to write functions that have different variations in its signature it is best to start off naming the parameters as generic as possible while still keeping a close relation to the body of the function and then work our way to more specific naming as we validate different data types:
function validateURL(value) {
if (typeof value === 'string') {
// URL string
} else if (value && typeof value === 'object') {
// URL instance
}
}
Make no mistake about it. Unit tests save time and sweat in the long run. Now I am not saying this because it is generally a recommended practice in every article out there. I am speaking from experience.
My experience developing in a project without unit tests compared to with was that developing and debugging code produced more sweat in a project with barely any unit tests. Unit tests produces the nice benefit of feeling confident moving forward.
This is what happens when you create unit tests:
You get permanent protection throughout parts of your code. When you continue to develop code while falling way behind on unit tests and you make changes in the future, let me tell you, it is an absolute nightmare. What can happen is when you make changes that result in having to update another part of your code, there can be a devastating domino effect of errors propagating one after the other. This can commonly be the result of implicit dependencies in in our functions. The key word here is implicit. If you don't take control of your code now, you will be missing out on crucial code and not realize it until you actually start receiving the annoying errors. You can establish unit tests now so that when you make changes to your code a couple months later, you still have control and confidence over your code. When you make a mistake, your unit tests will alert you immediately once you run them. This is the permanent protection I was referring to.
You get a firm architectural understanding on your code structure. In complex scenarios during runtime (when users are using your app) it is easy to miss exactly what went down when the user spams user actions, like clicking buttons multiple times, clicks forward, back, goes to their profile, visits their shopping cart, etc in seconds. Our eyes have limitations in what we see in real time (that is why we need debug tools to time travel backwards) as well as what we process mentally during those milliseconds of operations. Unit tests will help isolate the exact timing of your functions and you can test if they are behaving as expected in between function invocations.
You become a team player to your current and incoming team members. Establishing unit tests to code helps other teammates avoid pushing code that conflicts with current existing behavior. It also helps them understand your code.
This is one of those tips that sound obvious but needs to be said again and again. It is a wake up call to those who seldomly use TypeScript to write types that look like something like this:
type ReferenceString = string
type Obj = Record<string, any>
interface TransformFunc {
(
transformer: <V>(value: ReferenceString | Obj | any[]) => V,
...args: any
): any[]
}
const transform: TransformFunc = (transformer, ...args) => {
return args.reduce(
(arr, arg) =>
Array.isArray(arg)
? arr.concat(...arg.map((val) => transformer(val)))
: transformer(arg),
[],
)
}
On average you can get by coding like this for all your projects. But so can you with just this:
const transform = (transformer, ...args) => {
return args.reduce(
(arr, arg) =>
Array.isArray(arg)
? arr.concat(...arg.map((val) => transformer(val)))
: transformer(arg),
[],
)
}
The point is that TypeScript is not being pushed to its full potential and using it in this way just begs the question, "What's the point of TypeScript?" When TypeScript is leveraged using more specific types and taking advantage of its features it offers, it really makes a big difference in the development flow.
One example to make more out of our code with TypeScript is being more explicit and declarative with our type alias ReferenceString
. If reference strings (lets just say they are placeholders for data values) are strings that are prefixed with a symbol, instead of declaring string
we can make TypeScript enforce that all strings that are being applied as this type are prefixed:
type ReferenceString<V extends string = string> = `.${V}`
Your development work flow goes much more smoother when TypeScript is alerting you with it:
type ReferenceString<V extends string = string> = `.${V}`
function validateRefStr<S extends ReferenceString>(value: S): value is S {
return typeof value === 'string' && value.startsWith('.')
}
const ref1 = validateRefStr('.abcdef') // Valid reference
const ref2 = validateRefStr('abcdef.') // Invalid
const ref3 = validateRefStr('=.abcdef.') // Invalid
I think we were all guilty at one point switching between arrow functions and function declarations:
function getDogs() {
return new Promise((resolve, reject) => ['dog1', 'dog2'])
}
const getDogs = () => new Promise((resolve, reject) => ['dog1', 'dog2'])
Which is fine and all, but trust me, when you stick with one writing style as much as you can, it's noticeably different in a positive way. As you become more used to the language you will come across situations where one style must be used in order for the function to behave the way you want it to. Times like those is when you take that chance to write in the alternative way.
The development and debugging process is much smoother when developers are able to read through code while quickly catching the differences and see the reasonings behind them when they see that something was written differently than usual.
An example is when we load iframes asynchronously and need a specific value back after the element has loaded:
async function initContainer({ children, tagName = 'div' } = {}) {
const container = document.createElement(tagName)
if (children) {
container.appendChild(await children(container))
}
return container
}
async function startApp() {
await initContainer({
children: (container) => {
return new Promise((resolve, reject) => {
const attributes = { src: 'http://www.google.com/abc.png' }
const elem = document.createElement('iframe')
Object.entries(attributes).forEach(([attr, value]) => {
elem.setAttribute(attr, value)
})
elem.addEventListener('load', function (evt) {
resolve(elem)
})
elem.addEventListener('error', reject)
})
},
})
}
Here we have two async functions and one function that returns a promise using the new Promise()
syntax.
This seems very good so far. But what if we wanted to improve the readability of the code a little more so that it looks a little cleaner?
To be consistent we can just hide the ugly implementation details inside new Promise
into a file where elements are asynchronously created with, so that we have a nice clean code to work with up front:
// elements.js
function getIframe(container = document.body) {
return new Promise((resolve, reject) => {
const attributes = { src: 'http://www.google.com/abc.png' }
const elem = document.createElement('iframe')
// Assuming "attributes" also includes the "src"
Object.entries(attributes).forEach(([attr, value]) => {
elem.setAttribute(attr, value)
})
elem.addEventListener('load', function (evt) {
resolve(elem)
})
elem.addEventListener('error', reject)
})
}
// app.js
import { getIframe } from '../elements'
async function initContainer({ children, tagName = 'div' } = {}) {
const container = document.createElement(tagName)
if (children) {
container.appendChild(await children(container))
}
return container
}
async function startApp() {
await initContainer({ children: getIframe })
}
Obviously this is optional. But its little things like this that can make everyones day simpler for them :)
And that concludes the end of this post! I have you found this to be valuable and look out for more in the future!