Linting & Formatting: StyleCop/EditorConfig vs. ESLint/Prettier
For .NET engineers who know:
.editorconfig, Roslyn analyzers, StyleCop.Analyzers, and the code quality rules configured in.csprojandglobal.jsonYou’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.
| Option | Our Setting | What It Does |
|---|---|---|
semi | true | Semicolons at end of statements (matches C# convention) |
singleQuote | true | Single quotes for strings (JS/TS convention) |
trailingComma | "all" | Trailing commas everywhere (cleaner git diffs) |
printWidth | 100 | Wrap lines at 100 characters |
tabWidth | 2 | 2 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 --writemodifies files in place — use during developmentprettier --checkexits 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 | .NET | TypeScript |
|---|---|---|
| Linting | Roslyn analyzers + StyleCop | ESLint + @typescript-eslint |
| Formatting | .editorconfig + Roslyn formatting | Prettier |
| Rule config location | .csproj, global.json, .editorconfig | eslint.config.mjs, .prettierrc |
| Type-aware rules | Built into Roslyn (semantic model) | @typescript-eslint (needs parserOptions.project) |
| Format on save | Visual Studio built-in | VS Code Prettier extension |
| Pre-commit enforcement | Not built-in (CI only) | Husky + lint-staged |
| CI enforcement | dotnet build fails on analyzer errors | eslint + prettier --check exit codes |
| Auto-fix | Quick Fix in VS (not CLI) | eslint --fix, prettier --write |
| Severity levels | Error, Warning, Info, Hidden | error, 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:
- 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.
- Configure Prettier’s
tabWidth: 4to 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
| Tool | Handles | Does Not Handle |
|---|---|---|
| ESLint | Logic errors, async patterns, unused vars, type safety rules | Whitespace, indentation, quotes |
| Prettier | Indentation, line length, quotes, semicolons, trailing commas | Logic, correctness, types |
eslint-config-prettier | Disabling ESLint formatting rules that conflict with Prettier | Nothing by itself |
| Husky | Running hooks at Git events | Enforcement (client-side only) |
| lint-staged | Running tools on staged files only (fast) | Running on full codebase |
TypeScript compiler (tsc) | Type checking | Linting, formatting |
Command Reference
| Command | Purpose | When to Use |
|---|---|---|
pnpm lint | Check for ESLint issues | CI, pre-push |
pnpm lint:fix | Fix auto-fixable ESLint issues | Development |
pnpm format | Format all files with Prettier | Before commit |
pnpm format:check | Check formatting without modifying | CI |
pnpm typecheck | Type-check without emitting | CI, pre-push |
ESLint Rule Reference
| Rule | Why It Matters for .NET Engineers |
|---|---|
@typescript-eslint/no-floating-promises | Catches missing await — the #1 async gotcha |
@typescript-eslint/no-explicit-any | Enforces the unknown discipline |
@typescript-eslint/consistent-type-imports | Reduces bundle size by marking type-only imports |
@typescript-eslint/switch-exhaustiveness-check | Compiler-enforced exhaustive discriminated unions |
@typescript-eslint/no-unused-vars | Same as C#’s unused variable warnings |
no-console | Keeps 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
- ESLint Documentation — Getting Started — Flat config setup and rule configuration reference
- typescript-eslint Documentation — The definitive guide to type-aware TypeScript linting rules
- Prettier Documentation — Configuration options and editor integration
- lint-staged Documentation — Configuration patterns for pre-commit hooks on large codebases