The Pragmatic JavaScript Style Guide

These are opinions I've developed after 8 years of writing JavaScript (and TypeScript) for companies big and small. Some ideas may be controversial, but I suggest giving it five minutes before making up your mind. Let's begin.

Prefer let over const bindings. There simply isn't enough benefit to a const declaration to warrant the keystrokes nor the rewriting. Use const for module-level constants, most exports, and... that's about it. Pragmatically speaking, let works just fine for bindings that are treated as immutable. More, it's rare that you care about the mutability of the binding itself, especially if its scope is limited. When I was younger I thought let and const could also be used to signal intent about interior mutability. That just isn't practical.

Prefer large files over small ones. I trust that this will be read charitably and applied within reason. Keep all code related to one aspect of your program in a single file for as long as possible and don't break it up just for cosmetic reasons. It's far easier to understand what's going on, and what's available to you, if you're not jumping through layers and layers of files. The same applies for functions. Decide whether to split code up based on concerns, not aesthetics.

Prefer "always" for prettier's function parentheses option. Save yourself the work when you decide to add parameter or return types.

// avoid
let fn = a => a?.foo?.bar

// prefer
let fn = (a) => a?.foo?.bar

Following that, avoid implicit returns (within reason). It gets tiring to expand functions when you decide to perform logging, add a conditional, etc. Again, within reason. Let the minifier do the job for you.

// avoid
let fn = (a, b) => a + b

// prefer
let fn = (a, b) => {
    return a + b
}

On this topic, I absolutely suggest using an automatic code formatter like prettier. I don't, however, suggest using automated tools that rewrite your code in development, such as eslint fixers that automatically convert let bindings to const if they aren't reassigned, or explicit returns to implicit ones. These tools do not appreciate the fact that you are still modifying a piece of code and you'll just have to undo their suggestions. If you want to use tools like this, run them at a more appropriate time (e.g. pre-commit).

Prefer the multi-line comment syntax when documenting functions or variables even if the comment is a single line. This syntax will use your comment as a JSDoc description and you'll get nicer overlay hints in your IDE.

// avoid
// some comment about foo
let foo: string

// prefer
/** some comment about foo */
let foo: string

Prefer named exports over export default. Named exports naturally reuse the same name when importing the value which is better for consistency. They also don't require you to rewrite the import syntax when you decide to import a second value. More, export default's behavior is subtly different from named exports, making it a bit of an oddball in the ESM world that should be avoided except for dynamic import() convenience.

// avoid
// foo.ts
const Foo = 123
export default Foo
// main.ts
import SomeInconsistentName from "./foo"

// prefer
// foo.ts
export const Foo = 123
// main.ts
import {Foo} from "./foo"

It's easy to get tripped up on the fact that each case in a switch statement doesn't introduce a new lexical scope. Use braces as needed:

// avoid
let foo
switch (thing) {
    case "a":
        foo = "whatever"
        doSomethingWith(foo)
        break
    case "b":
        foo = "whatever"
        doSomethingDifferentWith(foo)
        break
}

// prefer
switch (thing) {
    case "a": {
        let foo = "whatever"
        doSomethingWith(foo)
        break
    }
    case "b": {
        let foo = "whatever"
        doSomethingDifferentWith(foo)
        break
    }
}

Immutability should be looked at holistically. Mutating objects created inside a function is entirely fine, and often leads to more readable code. Instead, think about immutability more in the larger context of your application and less in what "functional" idioms are used in places where mutability doesn't leak.

A for loop is generally more readable than Array.prototype.reduce. As a recovering abuser of this method, I'm now unconvinced that there's a reasonable use for it in JavaScript. It can function as .map(), .filter(), and more, and therefore conveys no meaning. Worse, it often produces code that's less readable simply because the author is allergic to a conventional loop (and mutation where it doesn't matter).

// avoid
let [good, bad] = things.reduce((([good, bad]), thing) => {
    if (thing.field === "whatever") {
        return [[...good, thing], bad]
    } else {
        return [good, [...bad, thing]]
    }
}, [[], []])

// prefer
let good = [], bad = []
for (let thing of things) {
    if (thing.field === "whatever") {
        good.push(thing)
    } else {
        bad.push(thing)
    }
}

Be familiar with the difference between the forms promise.resolve(ok, err) and promise.resolve(ok).catch(err) because it's not just a matter of syntax. Prefer using async/await, but don't be overzealous with try/catch blocks. You probably want to differentiate the error handling for promise rejections vs. errors that occur in the code that runs after the promise resolves, even if it can be clunky at times. If you have a promise-returning function, it's generally safest to mark the function as async so that it can't inadvertently throw a synchronous error.

TypeScript

Prefer /** @ts-expect-error */ over /** @ts-ignore */. The former will produce an error if the error is fixed, the latter won't and you'll likely never remove it. You can also use this to ensure switch statements are exhaustive (somebody please tell me if there's a better way).

let thing: "a" | "b" = "a"

switch (thing) {
    case "a":
        break
    case "b":
        break
    default:
        // if you handled all statically-verifiable cases then
        // this block will be unreachable (an expected "error").
        /** @ts-expect-error */
        console.error("unknown thing: %s", thing)
}

Prefer discriminated unions over optional properties when practical. In this example, we're trying to express that T's with different type's have different sets of properties. With optional properties, the compiler can't know which are associated with which type.

// avoid
interface T {
    type: "a" | "b"
    a?: number
    b?: string
}

// prefer
type T = 
    | { type: "a", a: number }
    | { type: "b", b: string }