Goldblog
GitHubSiteTwitter

The Blurry Line Between Formatting and Style

June 19, 2023

Iā€™ve been doing a lot of advocacy work on how to properly use formatters such as Prettier alongside linters such as ESLint.

  • Formatters should be used for formatting (changes to code trivia/whitespace that donā€™t impact runtime, such as tabs vs. spaces)
  • Linters should be used for logical issues (runtime behavior) and style (non-logical issues that do impact runtime behavior, such as sorting imports)

The delineation of formatting vs. stylistic concerns is normally pretty straightforward to follow. Turning off any ESLint rule that would conflict with Prettier is not much work these days: neither ESLint nor typescript-eslintā€™s core rulesets enable any rules that violate that philosophy, and eslint-config-prettier can do it for you if youā€™re working with other configs.

But! There are some edge cases in formatting/style that can seem to cross the divide and make it unclear when to use which tool. Iā€™ll cover some of those surprising intricacies in this post, along with how Iā€™d suggest resolving them.

Formatting Can Technically Change Behavior

Did you know that JavaScriptā€™s Function.prototype.toString() returns the string equivalent of a function? That means any tool that modifies the text of a function -including formatters, minifiers, and transpilers- technically can change the runtime behavior of code.

For example, this code would log a different message based on whether itā€™s formatted with spaces or tabs:

function greet(name) {
    console.log(`Hello, ${name}!`);
}

console.log(greet.toString().includes("\t") ? "tab" : "no tab");

In practice, exceedingly few real code stringifies functions, let alone cares about the formatting of the result. Iā€™ve never seen it be an issue. But it is a nifty proof of concept that these concerns can matter!

AST Modifications

Formatters such as Prettier generally donā€™t make modifications to code that change how the code is represented by tooling (generally known as its AST, or Abstract Syntax Tree). That means some changes can surprisingly be considered out-of-scope for a formatter.

Most notably (for me), Prettier does not have an option to enforce curly brackets. Doing so would change the consequent (what follows the if(...)) of if statements from a Statement (e.g. console.log()) to a Block (e.g. { console.log(); }).

That limitation makes sense on its own, but presents an inconvenience: enforcing curly brackets is arguably a formatting concern, not a stylistic one - and so shouldnā€™t be handled by linters! šŸ˜«.

For a while, I compromised my principles and would include usage of the curly rule in my ESLint configs. But that always felt wrong - even if the formatting couldnā€™t reasonably handle this AST modification on its own, enforcing curly brackets really is something that should be handled by the formatter.

prettier-plugin-curly

Thatā€™s why I recently wrote & published my first Prettier plugin: prettier-plugin-curly. It adds the enforcement of consistent brace style (i.e. the curly ruleā€™s all option) to Prettier. I now use it in my template-typescript-node-packageā€™s .prettierrc.

{
    "plugins": ["prettier-plugin-curly"],
    "useTabs": true
}

If youā€™re interested in enforcing curly brackets as part of your formatter, Iā€™d encourage you to try prettier-plugin-curly out. Itā€™s the first time Iā€™ve written a Prettier plugin and Iā€™d love to be told how to improve it. ā¤ļø

Order Matters

One last sometimes-surprisingly-impactful concern is ordering. Many developers -myself included- prefer ordering file imports, properties on objects and types, and other constructs in a predictable way, to make it easier to scan through them. Itā€™s tempting to suggest ordering as within the realm of a formatter.

Imports

However, order does impact runtime behavior - especially for imports!

The order your files import and/or require each other can make a difference because code files sometimes trigger side effects when theyā€™re run.

Consider this set of three files, where running index.js causes logs to run in the imported files in order of import:

// index.js
import { b } from "./b.js";
import { a } from "./a.js";
// a.js
console.log("A!");
export const a = "a";
// b.js
console.log("B!");
export const b = "b";

Your code might be triggering more intensive side effects, such as registering CSS styles, calling fetch(), or reading/writing files on disk. Changing the order of module imports is generally too risky for formatters.

Property Destructures

In fact, even the ordering of destructured object properties can make a difference! Object properties defined as getters can introduce side effects.

For example, this code block logs a: 0 and b: 1 now, but alphabetizing the { b, a } to { a, b } would cause it to instead log a: 1 and b: 0:

let count = 0;

const values = {
    get a() {
        return `a: ${count++}`;
    },
    get b() {
        return `b: ${count++}`;
    },
};

const { b, a } = values;

console.log(a);
console.log(b);

Changes in runtime behavior from sorting imports or property destructures are still pretty rare, though they can happen (especially in the case of imports). I donā€™t recommend using a Prettier plugin for sorting.

eslint-plugin-perfectionist

Instead, I recommend using eslint-plugin-perfectionist to apply sorting at the lint level. This plugin provides a nice comprehensive set of rules for sorting constructs in JavaScript and TypeScript code.

{
  extends: ["plugin:perfectionist/recommended-natural"],
  plugins: ["perfectionist"],
}

eslint-plugin-perfectionist is also pretty new -released mid-2023- and Iā€™d highly recommend trying it out. Itā€™s enabled in my template-typescript-node-packageā€™s .eslintrc.cjs.

Summarizing

I think a succinct set of guidelines out of all of this would be:

  • Formatters should never change code behavior (except for stringifying functions)
  • Linting for style should auto-fix any remaining changes that donā€™t impact code behavior (except for rare edge cases such as order-dependent side effects)

What do you think? Are there other edge cases that need to be handled? Please let me know!

Resources

You can read more about the concerns of formatters vs. linters in the following posts Iā€™ve written:

This blog post was inspired by a timely discussion thread on Twitter with @azat_io_en and @helloklose. Thanks to Azat for sharing out eslint-plugin-perfectionist and Oksel for asking great questions! šŸ™Œ

Josh GoldbergHi, I'm Josh! I'm a full time independent open source developer. I work on projects in the TypeScript ecosystem such as typescript-eslint and TypeStat. This is my blog about JavaScript, TypeScript, and open source web development.
This site's open source on GitHub. Found a problem? File an issue!