Josh Goldberg

The Blurry Line Between Formatting and Style

Jun 19, 202310 minute read

Why separating responsibilities between your formatter and linter isn't always clear-cut.

Edit (January 2024): Since writing this blog post, ESLint core deprecated formatting rules and ESLint Stylistic was announced and released. Hooray! 🙌

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

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 little 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.


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 create-typescript-app’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.


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
export const a = "a";
// b.js
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;


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.


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 create-typescript-app’s .eslintrc.cjs.


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

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


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! 🙌

Liked this post? Thanks! Let the world know: