Skip to main content

TypeScript Linting and Static Analysis

TypeScript's type system catches many errors at compile time, but linting and static analysis tools enforce code quality, consistent style, and catch additional issues beyond type errors. A robust linting setup prevents bugs, improves maintainability, and ensures code consistency across the team.

Overview

While TypeScript's compiler ensures type safety, linting tools ensure code quality and consistency. The TypeScript compiler focuses on "will this code run correctly?" while linting focuses on "is this code maintainable, secure, and following best practices?"

The TypeScript quality stack:

  • TypeScript compiler - Type checking and compilation
  • ESLint - Code quality, patterns, and logical errors
  • Prettier - Consistent code formatting
  • Husky + lint-staged - Pre-commit enforcement
  • CI/CD pipelines - Quality gates before merge

These tools should be configured in your project and enforced automatically in CI/CD pipelines. Manual code reviews should focus on architecture and logic, not formatting or style violations that tools catch automatically. See our Pipeline Configuration for CI/CD integration patterns.


ESLint for Code Quality

ESLint is the standard linting tool for TypeScript and JavaScript. It detects problematic patterns, enforces coding conventions, and catches potential bugs that the TypeScript compiler might miss. While TypeScript catches type errors, ESLint catches logical errors, unused code, and style violations.

Why ESLint Matters

The TypeScript compiler focuses on type correctness, not code quality. Consider this example:

// TypeScript compiler:  No errors - types are correct
function processPayment(payment: Payment): void {
const result = fetchPaymentStatus(payment.id); // Returns Promise<Status>
console.log('Processing payment'); // Console statement in production code
}

// ESLint: Multiple issues detected
// 1. no-floating-promises: Promise not awaited or handled
// 2. no-console: Console statement should use logging framework
// 3. explicit-function-return-type: Async function should return Promise<void>

ESLint catches issues like:

  • Unused variables - Often indicate bugs or incomplete refactoring
  • Unreachable code - Dead code that should be removed
  • Console statements - Should use proper logging in production
  • Floating promises - Async operations without error handling
  • Complexity violations - Functions that are too complex to maintain

Without ESLint, these issues only surface during code review (wasting reviewer time) or in production (causing bugs).

TypeScript-Specific ESLint

Use @typescript-eslint/parser and @typescript-eslint/eslint-plugin to enable TypeScript-aware linting. This provides rules specific to TypeScript like preferring interface over type for object shapes, enforcing consistent type assertions, and detecting misuse of TypeScript features.

Base configuration:

// .eslintrc.json
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"project": "./tsconfig.json" // Required for type-aware rules
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
// TypeScript-specific rules
"@typescript-eslint/no-explicit-any": "error", // Ban 'any' type
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_", // Allow unused vars prefixed with _
"varsIgnorePattern": "^_"
}
],
"@typescript-eslint/explicit-function-return-type": [
"error",
{
"allowExpressions": true, // Allow inferred returns in arrow functions
"allowTypedFunctionExpressions": true
}
],
"@typescript-eslint/no-floating-promises": "error", // Must handle promises
"@typescript-eslint/await-thenable": "error", // Only await promises
"@typescript-eslint/no-misused-promises": "error", // Proper promise usage
"@typescript-eslint/strict-boolean-expressions": "warn", // Strict boolean checks

// General code quality
"no-console": "warn", // Prevent console.log in production
"no-debugger": "error", // No debugger statements
"no-alert": "error", // No alert() calls
"eqeqeq": ["error", "always"], // Require === and !==
"curly": ["error", "all"], // Require braces for all control structures
"no-var": "error", // Use let/const, not var
"prefer-const": "error", // Prefer const when variable not reassigned
"no-duplicate-imports": "error" // One import statement per module
}
}

Installation

npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

# For React projects, also install:
npm install --save-dev eslint-plugin-react eslint-plugin-react-hooks

# For Angular projects, use Angular ESLint:
ng add @angular-eslint/schematics

Running ESLint

# Check all TypeScript files
npx eslint 'src/**/*.ts' 'src/**/*.tsx'

# Auto-fix fixable issues
npx eslint 'src/**/*.ts' --fix

# Check specific file
npx eslint src/services/payment-service.ts

# Use cache for faster repeated runs
npx eslint 'src/**/*.ts' --cache

Common Violations and Fixes

RuleIssueFix
no-explicit-anyUsing any typeReplace with proper type or unknown
no-unused-varsUnused variableRemove variable or prefix with _ if intentionally unused
no-floating-promisesPromise not awaited/handledAdd await or .catch()
strict-boolean-expressionsNon-boolean in conditionExplicitly compare: if (value !== null) not if (value)
explicit-function-return-typeMissing return typeAdd explicit return type annotation

Example fixes:

// BAD: Using 'any'
function process(data: any) {
return data.amount;
}

// GOOD: Proper type
function process(data: Payment) {
return data.amount;
}

// BAD: Unused variable
function calculate(amount: number, fee: number) {
const discount = 0.1; // ERROR: 'discount' is declared but never used
return amount + fee;
}

// GOOD: Remove unused variable
function calculate(amount: number, fee: number) {
return amount + fee;
}

// BAD: Floating promise
async function loadData() {
apiClient.fetchPayments(); // ERROR: Promise not awaited
}

// GOOD: Await promise
async function loadData() {
await apiClient.fetchPayments();
}

For React-specific ESLint rules, see our React Testing guidelines.


Prettier for Code Formatting

Prettier is an opinionated code formatter that automatically formats code to a consistent style. Unlike ESLint which focuses on code quality, Prettier handles purely stylistic concerns - indentation, line length, quote style, trailing commas, semicolons, etc.

Why Prettier

Manual formatting wastes time and creates noise in code reviews:

  • Developers spend time manually formatting code
  • Code reviews include comments like "add a space here" or "wrong indentation"
  • Diffs are polluted with formatting changes unrelated to logic
  • Different developers format code differently, creating inconsistency

Prettier eliminates these problems by enforcing a consistent, non-configurable format. It integrates with editors to format-on-save and can be enforced in CI to reject incorrectly formatted code.

Prettier vs ESLint

Prettier: Handles formatting (whitespace, quotes, semicolons, line breaks) ESLint: Handles code quality (logic errors, best practices, unused code)

Use both together - Prettier for formatting, ESLint for quality. Some ESLint rules conflict with Prettier (like quotes, semi, indent). Disable these using eslint-config-prettier.

Configuration

// .prettierrc
{
"semi": true, // Require semicolons
"trailingComma": "es5", // Trailing commas where valid in ES5
"singleQuote": true, // Use single quotes
"printWidth": 100, // Max line length
"tabWidth": 2, // 2 space indentation
"useTabs": false, // Spaces, not tabs
"arrowParens": "always", // Always parentheses in arrow functions
"endOfLine": "lf" // Unix line endings
}
# .prettierignore
# Build outputs
dist/
build/
coverage/

# Dependencies
node_modules/

# Generated code
src/generated/

# Lock files
package-lock.json
yarn.lock

Installation

npm install --save-dev prettier eslint-config-prettier

# Install Prettier plugin for ESLint integration
npm install --save-dev eslint-plugin-prettier

Update ESLint config to use Prettier:

// .eslintrc.json
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:prettier/recommended" // Must be last - disables conflicting rules
]
}

Running Prettier

# Check formatting
npx prettier --check 'src/**/*.ts' 'src/**/*.tsx'

# Format files
npx prettier --write 'src/**/*.ts' 'src/**/*.tsx'

# Format all supported files
npx prettier --write .

# Check specific file
npx prettier --check src/services/payment-service.ts

Editor Integration

Configure your IDE to run Prettier on save. This provides instant formatting feedback and ensures code is always formatted correctly.

VS Code (.vscode/settings.json):

{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

IntelliJ/WebStorm: Settings → Languages & Frameworks → JavaScript → Prettier → Enable "On save"

See our IDE Setup guide for detailed editor configuration.


Type-Aware Linting

TypeScript ESLint supports type-aware rules that leverage TypeScript's type checker for deeper analysis. These rules catch issues that require understanding types, like using floating promises, awaiting non-promises, or returning promises from void functions.

Enabling Type-Aware Rules

Set parserOptions.project in ESLint config to point to your tsconfig.json. This allows ESLint to access type information but makes linting slower - only enable for comprehensive CI checks, not fast pre-commit hooks.

// .eslintrc.json
{
"parserOptions": {
"project": "./tsconfig.json" // Enables type-aware rules
},
"rules": {
// These rules require type information
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error"
}
}

Type-Aware Rules in Action

//  no-floating-promises - Promise not awaited
async function fetchData() {
apiClient.get('/data'); // ERROR: Promise not awaited or handled
}

// Fix: Await the promise
async function fetchData() {
await apiClient.get('/data');
}

// await-thenable - Awaiting non-promise
async function process(value: number) {
await value; // ERROR: 'number' is not a promise
}

// Fix: Don't await non-promises
async function process(value: number) {
return value;
}

// no-unsafe-member-access - Accessing member on 'any'
function process(data: any) {
return data.amount; // ERROR: Unsafe member access on 'any' type
}

// Fix: Properly type the parameter
function process(data: Payment) {
return data.amount;
}

// no-unnecessary-type-assertion - Type assertion not needed
const amount: number = payment.amount as number; // ERROR: Unnecessary assertion

// Fix: Remove unnecessary assertion
const amount: number = payment.amount;

// prefer-nullish-coalescing - Should use ?? instead of ||
const name = user.name || 'Unknown'; // ERROR: '' and 0 are falsy but valid

// Fix: Use nullish coalescing
const name = user.name ?? 'Unknown'; // Only null/undefined trigger default

Performance Considerations

Type-aware rules significantly increase linting time (they require TypeScript compilation). For a typical project:

  • Without type-aware rules: 2-5 seconds
  • With type-aware rules: 10-30 seconds

Strategy: Use type-aware rules in CI for comprehensive checks, but consider disabling them in pre-commit hooks for faster feedback.

// .eslintrc.precommit.json (fast config for pre-commit)
{
"extends": ["./.eslintrc.json"],
"parserOptions": {
// Don't load project for faster linting
},
"rules": {
// Disable slow type-aware rules
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off"
}
}

Pre-commit Hooks with Husky and lint-staged

Pre-commit hooks run linting and formatting checks before commits, providing immediate feedback and preventing incorrectly formatted or low-quality code from entering the repository. This catches issues early, before they reach CI.

Why Pre-commit Hooks

Without pre-commit hooks:

  1. Developer writes code
  2. Developer commits code
  3. Developer pushes code
  4. CI runs, finds linting errors
  5. PR is blocked
  6. Developer fixes errors, pushes again
  7. Total time: 10-15 minutes

With pre-commit hooks:

  1. Developer writes code
  2. Pre-commit hook runs, finds errors
  3. Developer fixes errors
  4. Commit succeeds
  5. Total time: 2-3 minutes

Pre-commit hooks provide instant feedback, preventing the commit-push-wait-fix cycle.

Husky and lint-staged

Husky: Manages Git hooks in npm projects lint-staged: Runs linters only on staged files (files about to be committed)

Running linters only on staged files makes pre-commit checks fast - you only check files you're changing, not the entire codebase.

Configuration

// package.json
{
"scripts": {
"prepare": "husky install", // Install hooks automatically on npm install
"lint": "eslint 'src/**/*.{ts,tsx}'",
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
"format": "prettier --write 'src/**/*.{ts,tsx,json,md}'",
"format:check": "prettier --check 'src/**/*.{ts,tsx,json,md}'",
"type-check": "tsc --noEmit"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix", // Auto-fix ESLint issues
"prettier --write" // Format with Prettier
],
"*.{json,md}": [
"prettier --write" // Format non-TS files
]
}
}

Installation

npm install --save-dev husky lint-staged

# Initialize Husky (creates .husky directory)
npx husky install

# Create pre-commit hook
npx husky add .husky/pre-commit "npx lint-staged"

How It Works

  1. Developer stages files for commit (git add file.ts)
  2. Developer runs git commit -m "message"
  3. Pre-commit hook triggers (.husky/pre-commit)
  4. lint-staged runs ESLint and Prettier on only staged *.ts and *.tsx files
  5. If checks pass, commit proceeds; if they fail, commit is blocked
  6. Developer sees errors, fixes them, and re-commits
# Example output on commit
$ git commit -m "Add payment processing"
Preparing lint-staged...
Running tasks for staged files...
❯ *.{ts,tsx} - 3 files
eslint --fix
prettier --write
Applying modifications from tasks...
Cleaning up temporary files...
[main abc1234] Add payment processing
3 files changed, 45 insertions(+)

Bypassing Hooks (Use Sparingly)

Sometimes you need to commit work-in-progress code that doesn't pass linting:

# Skip pre-commit hooks (use only when necessary)
git commit --no-verify -m "WIP: Incomplete feature"

Warning: Always fix linting errors before pushing. CI will catch them anyway, and bypassing hooks regularly indicates your linting rules are too strict or your workflow needs adjustment.

For teams that find pre-commit checks too restrictive (blocking commits during exploratory work), run checks in CI instead and fail pull requests - see our Git Workflow guide for trade-offs.


CI/CD Integration

Linting must run in CI/CD pipelines to enforce quality gates. Even with pre-commit hooks, developers can bypass them (git commit --no-verify), so CI enforcement is essential. Configure pipelines to fail when linting errors are detected.

GitHub Actions Example

# .github/workflows/quality.yml
name: Code Quality

on: [pull_request]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run ESLint
run: npm run lint

- name: Check Prettier formatting
run: npm run format:check

- name: Type check
run: npm run type-check # npx tsc --noEmit

GitLab CI Example

# .gitlab-ci.yml
quality:
stage: test
image: node:18
script:
- npm ci
- npm run lint
- npm run format:check
- npm run type-check
cache:
paths:
- node_modules/
allow_failure: false # Fail pipeline on errors

Caching for Faster CI

Cache node_modules and ESLint cache to speed up repeated runs:

# GitHub Actions with caching
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm' # Caches node_modules automatically

- name: Run ESLint with cache
run: npx eslint 'src/**/*.ts' --cache --cache-location .eslintcache

- name: Cache ESLint results
uses: actions/cache@v3
with:
path: .eslintcache
key: eslint-${{ hashFiles('**/*.ts', '**/*.tsx') }}

Most CI systems support caching dependencies, dramatically reducing build times from 2-3 minutes to 30-60 seconds on repeated runs.

See our CI Testing Best Practices for comprehensive CI configuration patterns.


Auto-fix vs Manual Fixes

Some linting violations can be automatically fixed (--fix flag), while others require manual intervention. Understanding which is which helps you work efficiently.

Auto-fixable Issues

These can be fixed automatically and should be handled by pre-commit hooks:

  • Code formatting - All Prettier rules (indentation, quotes, semicolons)
  • Import sorting - Alphabetical ordering of imports
  • Unused imports - Remove unused import statements
  • Prefer const - Change let to const when variable isn't reassigned
  • Quote style - Consistent single or double quotes
  • Trailing commas - Add/remove trailing commas for consistency
# Auto-fix everything that can be fixed
npx eslint --fix 'src/**/*.ts'
npx prettier --write 'src/**/*.ts'

Manual Fixes Required

These require developer judgment and cannot be auto-fixed:

  • Using any type - Need to determine proper type
  • Logic errors - Unreachable code, incorrect comparisons
  • Missing return types - Need to specify return type
  • Floating promises - Need to add await or error handling
  • Complex refactorings - Breaking up long functions, extracting methods
  • Architectural violations - Fixing import cycles, layer violations
# Show remaining errors after auto-fix
npx eslint 'src/**/*.ts'

Workflow Example

# 1. Auto-fix what you can
npx eslint --fix 'src/**/*.ts'
npx prettier --write 'src/**/*.ts'

# 2. Check remaining issues
npx eslint 'src/**/*.ts'
# Output:
# src/services/payment.ts
# 12:5 error Missing return type on function @typescript-eslint/explicit-function-return-type
# 23:3 error Promise returned in function with void return type @typescript-eslint/no-misused-promises

# 3. Manually fix remaining issues
# - Add return type to function at line 12
# - Make function async or change return type at line 23

# 4. Verify all issues resolved
npx eslint 'src/**/*.ts'
# No errors!

Reviewing Auto-fixed Changes

While auto-fix is convenient, always review changes before committing. Occasionally, auto-fix makes unintended changes that reduce readability.

# After auto-fixing, review changes
git diff

# Example: Line wrapping might reduce readability
# Before (readable):
const payment = createPayment(user, amount, currency);

# After auto-fix (less readable but meets line length limit):
const payment = createPayment(
user,
amount,
currency
);

If auto-fix consistently produces poor formatting in specific cases, adjust your Prettier config (e.g., increase printWidth) or disable the rule for that file with a comment.


Custom ESLint Rules

For team-specific or domain-specific standards, create custom ESLint rules. This enforces architectural patterns, prevents common mistakes specific to your codebase, and codifies team conventions.

When to Create Custom Rules

Create custom rules for patterns that:

  • Recur frequently in code reviews (automate the feedback)
  • Cause production bugs (prevent them automatically)
  • Violate architectural constraints (enforce your architecture)
  • Are specific to your domain (e.g., payment processing patterns)

Don't create custom rules for:

  • One-off issues easily caught in code review
  • Patterns that existing rules already cover
  • Overly specific edge cases that rarely occur

Custom Rule Example

// eslint-rules/no-direct-state-mutation.ts
import { ESLintUtils } from '@typescript-eslint/utils';

export const rule = ESLintUtils.RuleCreator(
(name) => `https://your-docs.com/rules/${name}`
)({
name: 'no-direct-state-mutation',
meta: {
type: 'problem',
docs: {
description: 'Disallow direct state mutations outside setState',
recommended: 'error',
},
messages: {
directMutation: 'Direct state mutation detected. Use setState instead.',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
AssignmentExpression(node) {
// Detect patterns like: this.state.value = ...
if (
node.left.type === 'MemberExpression' &&
node.left.object.type === 'MemberExpression' &&
node.left.object.property.type === 'Identifier' &&
node.left.object.property.name === 'state'
) {
context.report({
node,
messageId: 'directMutation',
});
}
},
};
},
});

Using Custom Rules

// .eslintrc.json
{
"plugins": ["./eslint-rules"],
"rules": {
"custom/no-direct-state-mutation": "error"
}
}

Examples of Custom Rules

  • Architectural: Enforce layering (services can't import from controllers)
  • Domain-specific: Payment amounts must be strings, not numbers (to avoid precision issues)
  • Security: Sensitive data must not be logged
  • Testing: Test file names must match source file names
  • API conventions: All API calls must use the centralized API client

Custom rules should be:

  • Documented: Clear explanation of why the rule exists
  • Team-approved: Reviewed and agreed upon by the team
  • Maintainable: Simple enough that future developers can understand and modify

Only create custom rules for patterns that genuinely improve code quality and prevent real problems - avoid over-engineering with too many custom rules.


Tool Comparison

Understanding what each tool does helps you configure them effectively:

ToolPurposeAuto-fixType-awareSpeedIntegration
TypeScript compilerType checkingNoYes (built-in)MediumAlways on
ESLintCode quality, patternsSome rulesWith configFast-MediumPre-commit, CI
PrettierCode formattingYes (all)NoVery FastPre-commit, CI, IDE
Husky + lint-stagedPre-commit hooksN/AN/AFast (staged only)Pre-commit

For most projects:

  1. TypeScript strict mode - Catches type errors ("strict": true in tsconfig.json)
  2. ESLint with @typescript-eslint - Catches code quality issues
  3. Prettier - Handles formatting
  4. Husky + lint-staged - Pre-commit checks
  5. CI enforcement - Quality gates in pipeline

For very large codebases:

  • Disable type-aware ESLint rules in pre-commit hooks (too slow)
  • Run full type-aware lint only in CI
  • Use ESLint cache (--cache flag) for faster repeated runs
  • Consider incremental type checking in CI

Minimal vs Comprehensive Setup

Minimal (good for small teams starting out):

{
"devDependencies": {
"eslint": "^8.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"prettier": "^3.0.0"
}
}

Comprehensive (recommended for production applications):

{
"devDependencies": {
"eslint": "^8.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"prettier": "^3.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^8.0.0",
"lint-staged": "^15.0.0"
}
}

Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways

  1. TypeScript + ESLint + Prettier - Use all three together for comprehensive quality
  2. Pre-commit hooks - Catch issues before they enter the repository
  3. CI enforcement - Quality gates prevent low-quality code from merging
  4. Type-aware rules - Enable in CI for deep analysis, disable in pre-commit for speed
  5. Auto-fix liberally - Let tools fix formatting automatically
  6. Manual fixes carefully - Developer judgment required for logic issues
  7. Custom rules sparingly - Only for recurring team-specific patterns
  8. Editor integration - Format-on-save provides instant feedback
  9. Cache for performance - Use ESLint cache and CI caching for speed
  10. Progressive adoption - Start minimal, add tools as team matures

Next Steps: Configure these tools in your project, integrate them into CI/CD pipelines, and review the TypeScript Testing guide for testing strategies.