Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Linting & Formatting: StyleCop/EditorConfig vs. ESLint/Prettier

For .NET engineers who know: .editorconfig, Roslyn analyzers, StyleCop.Analyzers, and the code quality rules configured in .csproj and global.json You’ll learn: How ESLint and Prettier divide the responsibilities of linting and formatting in the TypeScript world, how to configure them, and how to enforce them in CI and pre-commit hooks Time: 10-15 min read

In the .NET ecosystem, code style enforcement comes from several cooperating systems: .editorconfig for indentation and whitespace, Roslyn analyzers for code quality rules, and optionally StyleCop for style conventions like file headers and member ordering. They are all enforced by the same compiler infrastructure — MSBuild picks them up automatically.

In the TypeScript world, the responsibility is split between two separate tools that do not overlap: ESLint catches logic errors, suspicious patterns, and code quality issues. Prettier handles all formatting — indentation, quotes, semicolons, line length. You configure them separately, run them separately, and they have no awareness of each other except through a bridge package that prevents conflicts. Understanding this split is the first thing to internalize.


The .NET Way (What You Already Know)

Roslyn analyzers run as part of compilation. The analyzer sees the full semantic model of your code — not just text, but the actual meaning of each construct. StyleCop.Analyzers adds rules like “public members must have XML documentation” and “using directives must appear within a namespace.” The .editorconfig file controls whitespace, indent size, and newline conventions. Warnings can be upgraded to errors via .csproj:

<!-- .csproj — treat analyzer warnings as errors -->
<PropertyGroup>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <AnalysisLevel>latest</AnalysisLevel>
  <EnableNETAnalyzers>true</EnableNETAnalyzers>
</PropertyGroup>
# .editorconfig — cross-language formatting rules
[*.cs]
indent_style = space
indent_size = 4
end_of_line = crlf
charset = utf-8-bom
dotnet_sort_system_directives_first = true
csharp_new_line_before_open_brace = all

The experience in Visual Studio is seamless: save a file, it auto-formats. Break a rule, you get a red squiggle. Push to CI, the build fails. You rarely think about the tooling itself.

TypeScript tooling requires more explicit configuration but gives you finer control over the rules.


The TypeScript Way

ESLint — The Linter

ESLint is the standard linter for JavaScript and TypeScript. It analyzes your code for patterns that are likely bugs, code quality issues, or violations of team conventions. ESLint does not care about whitespace or formatting — that’s Prettier’s domain.

ESLint uses a plugin system. The base rules cover JavaScript patterns. TypeScript-specific rules come from @typescript-eslint/eslint-plugin, which has access to TypeScript’s type information and can catch errors that the base rules cannot.

Flat Config Format (the Current Standard)

ESLint transitioned from the legacy .eslintrc.json format to a flat config (eslint.config.js or eslint.config.mjs) starting with ESLint v8 and making it the default in v9. All new projects should use flat config. If you encounter an .eslintrc.json in an existing codebase, it is using the legacy format — the syntax differs but the concepts are the same.

// eslint.config.mjs — flat config format (ESLint v9+)
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';

export default tseslint.config(
  // Base JavaScript recommended rules
  js.configs.recommended,

  // TypeScript-aware rules
  ...tseslint.configs.recommended,

  // Disable ESLint formatting rules that conflict with Prettier
  // This must be last so it overrides everything above
  prettier,

  {
    // Project-specific rule overrides
    rules: {
      // Prevent floating Promises — the most important async rule for .NET engineers
      '@typescript-eslint/no-floating-promises': 'error',

      // Enforce consistent use of 'type' imports (imported solely for types)
      '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],

      // Disallow 'any' — use 'unknown' instead and narrow
      '@typescript-eslint/no-explicit-any': 'warn',

      // Require explicit return types on public functions
      // (comment this out if it's too verbose for your taste)
      '@typescript-eslint/explicit-function-return-type': ['warn', {
        allowExpressions: true,
        allowTypedFunctionExpressions: true,
      }],

      // Prevent unused variables from silently accumulating
      '@typescript-eslint/no-unused-vars': ['error', {
        argsIgnorePattern: '^_',
        varsIgnorePattern: '^_',
      }],

      // Enforce exhaustive switch on discriminated unions
      '@typescript-eslint/switch-exhaustiveness-check': 'error',

      // No console.log in production code — use a logger
      'no-console': ['warn', { allow: ['warn', 'error'] }],
    },
  },

  {
    // Files to ignore — equivalent to .eslintignore
    ignores: ['dist/', 'node_modules/', '.next/', 'coverage/'],
  },
);

Install the required packages:

pnpm add -D eslint @eslint/js typescript-eslint eslint-config-prettier

Why @typescript-eslint/no-floating-promises Is Non-Negotiable

This rule deserves special mention because it catches the most dangerous class of bug for engineers moving from C# to TypeScript. In C#, if you call an async method without await, the compiler warns you. In TypeScript, without this rule, forgetting await is silent:

// Without the lint rule, this compiles and runs — and does nothing observable
async function deleteUser(id: UserId): Promise<void> {
  // Missing await — the delete runs eventually, but this function returns immediately
  userRepository.delete(id);  // Returns a Promise that nobody awaits
}

// With @typescript-eslint/no-floating-promises: 'error'
// ESLint reports: "Promises must be awaited, end with a call to .catch,
// end with a call to .then with a rejection handler, or be explicitly marked
// as ignored with the `void` operator."

// Fix:
await userRepository.delete(id);

// Or explicitly acknowledged (for fire-and-forget — use sparingly):
void userRepository.sendWelcomeEmail(id);

This is the TypeScript equivalent of enabling <Nullable>enable</Nullable> in C# — it prevents an entire class of runtime errors at compile time.

Prettier — The Formatter

Prettier is an opinionated code formatter. “Opinionated” means it has almost no configuration options by design. You give it code, it gives you consistently formatted code. There is no “use 3-space indentation with my specific brace style” — Prettier makes those decisions and they are not negotiable per-project.

This is intentional. The value of Prettier is that formatting discussions end. There are no code review comments about trailing commas or quote style. Everyone’s editor formats on save to the same output.

// .prettierrc — the full set of meaningful options
{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "arrowParens": "always"
}

These are our defaults. The options above are the ones teams actually debate — everything else Prettier decides without asking you.

OptionOur SettingWhat It Does
semitrueSemicolons at end of statements (matches C# convention)
singleQuotetrueSingle quotes for strings (JS/TS convention)
trailingComma"all"Trailing commas everywhere (cleaner git diffs)
printWidth100Wrap lines at 100 characters
tabWidth22 spaces per indent level (JS/TS ecosystem standard)

Add a .prettierignore for files Prettier should not touch:

# .prettierignore
dist/
node_modules/
.next/
coverage/
*.md

Install Prettier:

pnpm add -D prettier eslint-config-prettier

The eslint-config-prettier package disables ESLint formatting rules that would conflict with Prettier. It must be the last item in your ESLint config’s extends array. Without it, ESLint and Prettier fight over indentation and quotes, and every save produces a cycle of reformatting.

VS Code Integration

Install two extensions:

  • ESLint (dbaeumer.vscode-eslint) — surfaces ESLint errors inline
  • Prettier - Code Formatter (esbenp.prettier-vscode) — formats on save

Configure your workspace settings to format on save and use Prettier as the default formatter:

// .vscode/settings.json — commit this to share settings with the team
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "typescript.preferences.importModuleSpecifier": "relative",
  "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
}

Also commit a .vscode/extensions.json to recommend extensions to anyone who opens the repo:

// .vscode/extensions.json
{
  "recommendations": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "ms-vscode.vscode-typescript-next"
  ]
}

CLI Commands

Add these to package.json scripts:

{
  "scripts": {
    "lint": "eslint src --ext .ts,.tsx",
    "lint:fix": "eslint src --ext .ts,.tsx --fix",
    "format": "prettier --write src",
    "format:check": "prettier --check src",
    "typecheck": "tsc --noEmit"
  }
}

The difference between --write and --check:

  • prettier --write modifies files in place — use during development
  • prettier --check exits with a non-zero code if any file would be reformatted — use in CI

ESLint --fix auto-corrects issues that have fixers (missing semicolons, import ordering, trailing commas). Not every rule has a fixer — logic issues like no-floating-promises must be fixed manually.

Pre-Commit Hooks with Husky and lint-staged

Pre-commit hooks run before each commit and reject the commit if they fail. This prevents broken code from entering the repository, the same way a failing CI build prevents broken code from merging. The combination of Husky (Git hooks management) and lint-staged (run linters on only the staged files, not the entire codebase) is the standard.

# Install
pnpm add -D husky lint-staged

# Initialize Husky (creates .husky/ directory)
pnpm exec husky init

This creates .husky/pre-commit. Replace its contents:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm exec lint-staged

Configure lint-staged in package.json:

{
  "lint-staged": {
    "*.{ts,tsx}": [
      "prettier --write",
      "eslint --fix"
    ],
    "*.{json,css,md}": [
      "prettier --write"
    ]
  }
}

What this does: when you run git commit, Husky triggers the pre-commit hook, which runs lint-staged. lint-staged runs Prettier and ESLint on only the files you’ve staged — not the entire repository. If ESLint finds unfixable errors, the commit is rejected and the error output tells you exactly what to fix.

This is the equivalent of having StyleCop run as a commit gate. The key difference: pre-commit hooks in Git are client-side only and can be bypassed with git commit --no-verify. CI enforcement (the next step) is the authoritative gate.

CI Integration

In your GitHub Actions CI workflow, run the checks without auto-fixing:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Type check
        run: pnpm typecheck

      - name: Lint
        run: pnpm lint

      - name: Format check
        run: pnpm format:check

      - name: Test
        run: pnpm test

Run pnpm lint (not pnpm lint:fix) in CI — you want the build to fail on lint errors, not silently fix and continue. The same applies to pnpm format:check vs. pnpm format.

SonarCloud Integration

SonarCloud (covered in depth in Article 7.2) analyzes TypeScript code for code smells, duplication, security hotspots, and coverage. It works alongside ESLint rather than replacing it — ESLint handles TypeScript-specific rules that SonarCloud’s TypeScript analyzer doesn’t cover as well, and SonarCloud provides the project-level dashboard, trend tracking, and quality gates that ESLint does not.

The integration is straightforward: SonarCloud reads your ESLint configuration and coverage reports from CI. In sonar-project.properties:

sonar.projectKey=your-org_your-repo
sonar.organization=your-org
sonar.sources=src
sonar.tests=src
sonar.test.inclusions=**/*.test.ts,**/*.spec.ts
sonar.coverage.exclusions=**/*.test.ts,**/*.spec.ts
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.eslint.reportPaths=eslint-report.json

Generate the ESLint report in a format SonarCloud understands:

{
  "scripts": {
    "lint:report": "eslint src --ext .ts,.tsx -f json -o eslint-report.json || true"
  }
}

The || true prevents the CI step from failing before SonarCloud can read the report — SonarCloud’s quality gate handles the failure decision.


Key Differences

Concern.NETTypeScript
LintingRoslyn analyzers + StyleCopESLint + @typescript-eslint
Formatting.editorconfig + Roslyn formattingPrettier
Rule config location.csproj, global.json, .editorconfigeslint.config.mjs, .prettierrc
Type-aware rulesBuilt into Roslyn (semantic model)@typescript-eslint (needs parserOptions.project)
Format on saveVisual Studio built-inVS Code Prettier extension
Pre-commit enforcementNot built-in (CI only)Husky + lint-staged
CI enforcementdotnet build fails on analyzer errorseslint + prettier --check exit codes
Auto-fixQuick Fix in VS (not CLI)eslint --fix, prettier --write
Severity levelsError, Warning, Info, Hiddenerror, warn, off
Suppress a warning// ReSharper disable ... / #pragma warning disable// eslint-disable-next-line rule-name

Gotchas for .NET Engineers

1. ESLint and Prettier Must Not Configure the Same Rules

The most common misconfiguration when setting up a new project: ESLint’s formatting rules (indent, quotes, semicolons) and Prettier’s formatting rules collide. ESLint fixes indentation to 4 spaces; Prettier reformats to 2 spaces; ESLint re-flags it. The result is a loop where every save generates changes.

The fix is eslint-config-prettier — a config that disables all ESLint rules that handle formatting. It must be the last item in your ESLint config so it overrides everything else:

// eslint.config.mjs — prettier config MUST be last
export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  prettier, // Last — disables ESLint formatting rules
  {
    rules: { /* your custom rules */ },
  },
);

If you see lint errors about indentation or quote style that Prettier is already handling, eslint-config-prettier is either missing or not last in the config.

2. TypeScript-Aware Rules Require parserOptions.project

Some @typescript-eslint rules — including no-floating-promises, no-unsafe-assignment, and switch-exhaustiveness-check — require type information to work. They need to run with access to the TypeScript compiler’s type model. Without it, they either silently skip or produce incorrect results.

Configure this in your flat config:

// eslint.config.mjs
import tseslint from 'typescript-eslint';

export default tseslint.config(
  ...tseslint.configs.recommendedTypeChecked, // Type-checked rules
  {
    languageOptions: {
      parserOptions: {
        project: true,        // Use the tsconfig.json in the same directory
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
);

The trade-off: type-checked rules are slower because they invoke the TypeScript compiler. On a large codebase, eslint with type-checking can take 30-60 seconds vs. 5-10 seconds without it. In CI this is acceptable. For editor integration, VS Code’s ESLint extension handles incremental analysis — it does not re-check the entire project on every keystroke.

If lint performance becomes a problem, split your config: type-checked rules for CI, non-type-checked for pre-commit hooks (where speed matters more).

3. Pre-Commit Hooks Are Client-Side and Can Be Bypassed

git commit --no-verify skips all pre-commit hooks. Engineers in a hurry will do this — especially when they’re told “just commit this quick fix.” Pre-commit hooks are a developer experience feature, not a security control. CI is the enforcing gate.

If your CI workflow does not run pnpm lint and pnpm format:check, then an engineer who bypasses the hook can merge code that violates your standards. The pre-commit hook catches issues early (faster feedback loop). CI enforces them (cannot be bypassed). Both are necessary.

Additionally, Husky only works after pnpm install runs. On a fresh clone, a developer who runs git commit before pnpm install will have no hooks. Document this in your project README and consider adding a script to .github/CONTRIBUTING.md.

4. Prettier’s Defaults May Conflict With Your Existing Conventions

If your team has been using 4-space indentation (C# convention), Prettier’s 2-space default will reformat your entire codebase on first run. This is a large, noisy commit that makes git blame less useful.

Options:

  1. Accept it: do a single “format entire codebase” commit with a clear commit message, then move on. This is the right call for new projects or projects with few existing files.
  2. Configure Prettier’s tabWidth: 4 to match your existing convention. The 2-space default is the strong JS/TS community norm, though — teams that deviate from it encounter friction with copy-pasted examples and library source code.

For new projects, use the 2-space default. For migrating existing TS codebases, do the format commit intentionally and communicate it to the team.


Hands-On Exercise

Set up ESLint, Prettier, and Husky on a fresh TypeScript project.

Step 1: Create the project and install dependencies

mkdir lint-exercise && cd lint-exercise
pnpm init
pnpm add -D typescript @types/node
pnpm add -D eslint @eslint/js typescript-eslint eslint-config-prettier
pnpm add -D prettier
pnpm add -D husky lint-staged

Step 2: Initialize TypeScript

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Step 3: Create the ESLint config

// eslint.config.mjs
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,
  prettier,
  {
    languageOptions: {
      parserOptions: {
        project: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/no-explicit-any': 'warn',
      'no-console': ['warn', { allow: ['warn', 'error'] }],
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);

Step 4: Create the Prettier config

// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2
}

Step 5: Add scripts and lint-staged config to package.json

{
  "scripts": {
    "lint": "eslint src",
    "lint:fix": "eslint src --fix",
    "format": "prettier --write src",
    "format:check": "prettier --check src",
    "typecheck": "tsc --noEmit"
  },
  "lint-staged": {
    "*.{ts,tsx}": ["prettier --write", "eslint --fix"],
    "*.json": ["prettier --write"]
  }
}

Step 6: Initialize Husky

pnpm exec husky init
echo "pnpm exec lint-staged" > .husky/pre-commit

Step 7: Write intentionally bad code and verify the tools catch it

// src/index.ts
async function fetchData(): Promise<string> {
  return Promise.resolve('data')
}

// Floating promise — will be caught by ESLint
function callWithoutAwait() {
  fetchData()  // Missing await
}

// Inconsistent formatting — will be caught by Prettier
const x={a:1,b:2,c:3}

Run:

pnpm typecheck    # Should pass — no type errors
pnpm lint         # Should error: no-floating-promises, semi, etc.
pnpm format:check # Should error: formatting issues
pnpm lint:fix     # Fix what can be auto-fixed
pnpm format       # Fix formatting
pnpm lint         # Should still error on no-floating-promises (must be fixed manually)

Fix the floating promise manually, then verify pnpm lint passes. Now stage the file and make a commit — Husky should run lint-staged automatically.


Quick Reference

Tool Responsibilities

ToolHandlesDoes Not Handle
ESLintLogic errors, async patterns, unused vars, type safety rulesWhitespace, indentation, quotes
PrettierIndentation, line length, quotes, semicolons, trailing commasLogic, correctness, types
eslint-config-prettierDisabling ESLint formatting rules that conflict with PrettierNothing by itself
HuskyRunning hooks at Git eventsEnforcement (client-side only)
lint-stagedRunning tools on staged files only (fast)Running on full codebase
TypeScript compiler (tsc)Type checkingLinting, formatting

Command Reference

CommandPurposeWhen to Use
pnpm lintCheck for ESLint issuesCI, pre-push
pnpm lint:fixFix auto-fixable ESLint issuesDevelopment
pnpm formatFormat all files with PrettierBefore commit
pnpm format:checkCheck formatting without modifyingCI
pnpm typecheckType-check without emittingCI, pre-push

ESLint Rule Reference

RuleWhy It Matters for .NET Engineers
@typescript-eslint/no-floating-promisesCatches missing await — the #1 async gotcha
@typescript-eslint/no-explicit-anyEnforces the unknown discipline
@typescript-eslint/consistent-type-importsReduces bundle size by marking type-only imports
@typescript-eslint/switch-exhaustiveness-checkCompiler-enforced exhaustive discriminated unions
@typescript-eslint/no-unused-varsSame as C#’s unused variable warnings
no-consoleKeeps log statements out of production code

Disabling Rules

// Disable for one line
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = JSON.parse(raw);

// Disable for a block
/* eslint-disable @typescript-eslint/no-explicit-any */
function parseUntypedResponse(raw: string): any {
  return JSON.parse(raw);
}
/* eslint-enable @typescript-eslint/no-explicit-any */

// Disable for an entire file (rarely correct)
/* eslint-disable */

Further Reading