Code reviews catch bugs, but they shouldnât catch formatting issues, lint violations, or broken commit messages. Thatâs mechanical work that belongs to machines. Husky intercepts Git hooks to run automated checks before code ever reaches the remote repository. Pair it with lint-staged for scoped linting and Commitlint for structured commit messages, and you get a pipeline that enforces quality standards silently, on every commit, without relying on developer discipline.
Husky: Git hooks that actually run
Git supports hooks natively, shell scripts in .git/hooks/ that trigger on events like pre-commit or commit-msg. The problem is that .git/ is not tracked by version control. Every developer on the team has to set up hooks manually, and nobody does. Husky solves this by storing hook configurations in the repository and installing them automatically.
{
"scripts": {
"prepare": "husky"
}
}
npx lint-staged
The prepare script runs after npm install or pnpm install, ensuring hooks are active for every developer who clones the repository. No setup documentation, no onboarding step, no âit works on my machine.â The hooks live in .husky/ and are version-controlled like any other file.
lint-staged: lint only what changed
Running ESLint and Prettier on the entire codebase for every commit is slow and noisy. lint-staged scopes the checks to files in the Git staging area, the ones that are actually being committed.
export default {
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
'*.{css,scss}': ['prettier --write'],
'*.{json,md,mdx}': ['prettier --write'],
};
Each glob pattern maps to an array of commands. lint-staged passes the staged file paths as arguments, so ESLint only checks the files you touched. If a fix is applied (auto-fixable lint rule or Prettier formatting), lint-staged re-stages the modified file automatically.
This is critical for large codebases. A monorepo with 500 TypeScript files doesnât need to lint all of them because you changed one component. lint-staged keeps the pre-commit hook under a second for most commits.
Commitlint: structured commit messages
Commit messages are documentation. A well-structured Git history tells you why a change was made, not just what files were modified. Commitlint enforces the Conventional Commits specification, rejecting messages that donât follow the format.
npx --no -- commitlint --edit $1
export default {
extends: ['@commitlint/config-conventional'],
};
The Conventional Commits format follows a strict structure.
type(scope): description
feat(auth): add OAuth2 login flow
fix(cart): prevent duplicate items on rapid clicks
docs(readme): update deployment instructions
refactor(api): extract validation into middleware
chore(deps): update eslint to v9
Valid types include feat, fix, docs, style, refactor, perf, test, build, ci, chore, and revert. The scope is optional but useful for filtering history. Commitlint rejects anything that doesnât match, so âfix stuffâ or âwipâ never make it into the log.
The payoff compounds over time. git log --oneline becomes a readable changelog. Automated release tools like semantic-release can generate version numbers and changelogs from commit types. feat bumps minor, fix bumps patch, BREAKING CHANGE in the footer bumps major.
Combining all three
The full setup chains Husky, lint-staged, and Commitlint into two Git hooks that cover both code quality and commit hygiene.
npx lint-staged
npx --no -- commitlint --edit $1
export default {
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
'*.{css,scss}': ['prettier --write'],
'*.{json,md,mdx}': ['prettier --write'],
};
export default {
extends: ['@commitlint/config-conventional'],
};
The workflow becomes invisible. A developer writes code, stages files, and commits. lint-staged auto-fixes formatting and catches lint errors before the commit is created. Commitlint validates the message format. If either fails, the commit is rejected with a clear error. If both pass, the commit goes through without friction.
Adding TypeScript checks to the pipeline
Linting catches style issues, but it doesnât catch type errors. Adding a TypeScript check to the pre-commit hook ensures that broken types never reach the repository.
npx lint-staged
npx tsc --noEmit
Unlike lint-staged, tsc needs to run on the full project because type checking is global. A change in one file can break types in another. This adds a few seconds to the commit, but catching a type error locally is infinitely faster than discovering it in CI twenty minutes later.
For large projects where tsc is too slow for every commit, move it to a pre-push hook instead.
npx tsc --noEmit
This keeps commits fast while still catching type errors before code reaches the remote.
Handling edge cases
Sometimes a developer needs to bypass hooks. An emergency hotfix, a work-in-progress commit on a feature branch, or a commit that intentionally breaks a lint rule. Git supports this natively.
git commit --no-verify -m "wip: temporary broken state"
The --no-verify flag skips all hooks. This should be rare and deliberate. If a team uses it frequently, the hooks are probably too strict or too slow, both fixable problems.
For lint-staged specifically, you can ignore files by adding them to .lintstagedrc with an empty command array, or by using ESLintâs inline disable comments for exceptional cases. The goal is guardrails, not a prison.
Conclusion
Husky, lint-staged, and Commitlint form a lightweight quality gate that runs on every commit without slowing anyone down. Formatting is consistent, lint rules are enforced, commit messages follow a convention, and type errors get caught early. The setup takes ten minutes and pays for itself on the first pull request that doesnât need a âfix formattingâ comment.