DEV Community

Cover image for # Enforcing Code Quality on Every Commit: Husky + Biome in a Node.js/TypeScript Project
MD Ehsanul Haque Rizvy
MD Ehsanul Haque Rizvy

Posted on

# Enforcing Code Quality on Every Commit: Husky + Biome in a Node.js/TypeScript Project

Overview

This article documents the exact setup used in <project's>_api* — a Node.js REST API built with Express 5, Prisma, TypeScript, and Bun — to enforce linting and formatting on every Git commit using Husky and Biome.

What this covers:

  • How Git hooks work and why raw hooks are insufficient for team projects
  • How Husky solves the version-control problem for hooks
  • How Biome replaces ESLint + Prettier in a single tool
  • The complete configuration for a TypeScript/Prisma project
  • Extending the hook with tests once test files exist

How Git Hooks Work

Git hooks are shell scripts that Git executes automatically at specific lifecycle points. The relevant hooks for enforcing code quality are:

Hook Trigger
pre-commit Before the commit is recorded
commit-msg After the commit message is written
pre-push Before pushing to a remote

The pre-commit hook fires before Git writes anything. If the script exits with code 1, Git aborts the commit entirely and nothing is recorded. If it exits with code 0, Git proceeds normally.

git commit
    ↓
pre-commit script runs
    ↓
Exit 0 → commit recorded
Exit 1 → commit aborted, working tree unchanged
Enter fullscreen mode Exit fullscreen mode

The Problem With Raw Git Hooks

Raw hooks live in .git/hooks/. This directory is explicitly excluded from version control — it does not exist in the remote repository and is never cloned. This means:

  1. Every developer must manually create and configure hooks after cloning
  2. Hook scripts cannot be peer-reviewed through PRs
  3. Hook updates must be communicated and applied manually by every team member
  4. CI pipelines cannot validate that hooks are properly configured

This makes raw hooks unreliable in any multi-developer context.


How Husky Solves This

Husky uses the Git configuration key core.hooksPath to redirect Git's hook resolution to a custom directory — .husky/ — which lives in the project root and is tracked by version control.

# What Husky sets under the hood
git config core.hooksPath .husky
Enter fullscreen mode Exit fullscreen mode

This means the .husky/ directory is committed to the repository, versioned, code-reviewed, and cloned alongside the rest of the project.

Husky also adds a prepare lifecycle script to package.json:

{
  "scripts": {
    "prepare": "husky"
  }
}
Enter fullscreen mode Exit fullscreen mode

The prepare script runs automatically when any developer runs bun install (or npm install). This means hooks are configured silently on every fresh clone — no manual steps required.

Husky characteristics:

  • 2kb gzipped, zero runtime dependencies
  • Supports all 13 client-side Git hooks
  • Works with any package manager: npm, pnpm, yarn, bun
  • Compatible with monorepos and nested projects

Why Biome Instead of ESLint + Prettier

The traditional approach uses two separate tools:

  • ESLint for lint rules (detecting code problems)
  • Prettier for formatting (enforcing consistent style)

This means two config files, two separate passes, two tools to keep in sync, and occasional conflicts when ESLint formatting rules clash with Prettier's output.

Biome is a single Rust-based tool that handles both. One config file (biome.json), one command, one pass. It is significantly faster than the JavaScript-based alternatives and produces no conflicts between lint and format rules because they are handled by the same engine.

For a TypeScript project using Bun, Biome is a natural fit — it is invoked via bunx --bun @biomejs/biome which runs the native binary directly without spawning a Node.js process.


Project Setup

Installation

# Install Husky
bun add --dev husky

# Initialise — creates .husky/ and adds prepare script to package.json
bunx husky init
Enter fullscreen mode Exit fullscreen mode

After initialisation, the project structure gains:

project-root/
├── .husky/
│   └── pre-commit        ← created by husky init (edit this)
├── biome.json
├── package.json          ← "prepare": "husky" added automatically
└── bun.lock
Enter fullscreen mode Exit fullscreen mode

The Pre-Commit Hook

Edit .husky/pre-commit:

#!/bin/sh
bunx --bun @biomejs/biome check --write .
Enter fullscreen mode Exit fullscreen mode

Flags explained:

Flag Behaviour
check Run both linter and formatter in a single pass
--write Auto-apply safe fixes (formatting, trivial lint fixes)
. Apply to the entire project (respects files.includes in biome.json)

If Biome encounters lint errors it cannot auto-fix — a real code problem — it exits with code 1 and the commit is aborted. If all checks pass (or only auto-fixable issues were found), it exits with code 0 and the commit proceeds.


Biome Configuration

The biome.json for JoseOcando_api:

{
  "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  },
  "files": {
    "includes": ["**", "!!**/dist"]
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "tab"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "double"
    }
  },
  "assist": {
    "actions": {
      "source": {
        "organizeImports": "off"
      }
    }
  },
  "linter": {
    "rules": {
      "suspicious": {
        "useIterableCallbackReturn": "off",
        "noExplicitAny": "off"
      },
      "style": {
        "useImportType": "off"
      },
      "correctness": {
        "noUnusedFunctionParameters": "off",
        "noUnusedImports": "off"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Configuration decisions:

  • vcs.useIgnoreFile: true — Biome reads .gitignore and skips ignored files (e.g. node_modules, src/generated/)
  • files.includes: ["!!**/dist"] — explicitly excludes the compiled output directory
  • indentStyle: "tab" — tabs for indentation (consistent with the TypeScript config)
  • quoteStyle: "double" — double quotes for JavaScript/TypeScript strings
  • noExplicitAny: "off" — Prisma service functions use payload: any during schema iteration; this rule is disabled until the schema stabilises
  • noUnusedImports: "off" — disabled to avoid noise on files that are still being actively developed
  • organizeImports: "off" — import ordering is handled manually to avoid conflicts with Prisma-generated imports

Rules should be tightened over time. Starting with fewer rules enforced is preferable to fighting the linter constantly during early development.


What the Hook Produces

On a passing commit

❯ git commit -m "feat: add experience endpoint"
» Running pre-commit hook...
✔ Biome: no issues found (12 files checked)
[main 4f2a1c9] feat: add experience endpoint
 3 files changed, 47 insertions(+)
Enter fullscreen mode Exit fullscreen mode

On a failing commit

❯ git commit -m "feat: add experience endpoint"
» Running pre-commit hook...
src/app/module/Experiance/exp.validation.ts:1:8 lint/correctness/noUnusedImports
  Imported variable 'z' is defined but never used.

✖ Found 1 error.
husky - pre-commit script failed (code 1)
Enter fullscreen mode Exit fullscreen mode

The commit is aborted. The working tree is unchanged. The developer fixes the error and commits again.


The Default Hook Pitfall

When bunx husky init runs, it generates a default .husky/pre-commit that contains:

npm test
Enter fullscreen mode Exit fullscreen mode

(or bun test if Bun is detected)

If your project has no test files yet, this will immediately fail:

bun test v1.3.5
No tests found!
Tests need ".test", "_test_", ".spec" or "_spec_" in the filename
husky - pre-commit script failed (code 1)
Enter fullscreen mode Exit fullscreen mode

This is correct behaviour — bun test exited with code 1, so Husky blocked the commit. But the cause is a missing test suite, not a code problem.

Resolution: Replace the default hook content with a script that runs something immediately useful. Biome is the correct choice here since it is already configured and provides real value from the first commit.

Do not add bun test back to the hook until actual test files exist. An empty test suite failing the hook on every commit defeats the purpose of having a hook.


Extending the Hook With Tests

Once test files exist, extend .husky/pre-commit:

#!/bin/sh
bunx --bun @biomejs/biome check --write .
bun test
Enter fullscreen mode Exit fullscreen mode

For an Express/Prisma project, start with tests for utilities that have no database dependencies. These run instantly and require no environment setup.

Recommended starting points in JoseOcando_api:

File Why it's a good first test
src/utils/jwtUtils.ts Pure functions, no DB, no HTTP
src/shared/catchAsync.ts Wraps a handler, fully synchronous
src/shared/sendResponse.ts Builds a response object, no side effects

Example — src/utils/jwtUtils.test.ts:

import { describe, expect, it } from "bun:test"
import { jwtUtils } from "./jwtUtils"

const SECRET = "test_secret_key_32_chars_minimum"
const PAYLOAD = { userId: 1, email: "user@example.com", role: "VISITOR" }

describe("jwtUtils.generateToken", () => {
  it("returns a string with three dot-separated segments", () => {
    const token = jwtUtils.generateToken(PAYLOAD, SECRET, "1h")
    expect(typeof token).toBe("string")
    expect(token.split(".").length).toBe(3)
  })
})

describe("jwtUtils.verifyToken", () => {
  it("returns the original payload on a valid token", () => {
    const token = jwtUtils.generateToken(PAYLOAD, SECRET, "1h")
    const result = jwtUtils.verifyToken(token, SECRET) as typeof PAYLOAD
    expect(result.userId).toBe(PAYLOAD.userId)
    expect(result.email).toBe(PAYLOAD.email)
  })

  it("throws JsonWebTokenError on a malformed token", () => {
    expect(() =>
      jwtUtils.verifyToken("not.a.valid.jwt", SECRET)
    ).toThrow()
  })

  it("throws TokenExpiredError on an expired token", async () => {
    const token = jwtUtils.generateToken(PAYLOAD, SECRET, "0s")
    await new Promise(r => setTimeout(r, 10)) // ensure expiry
    expect(() =>
      jwtUtils.verifyToken(token, SECRET)
    ).toThrow()
  })
})
Enter fullscreen mode Exit fullscreen mode

Bypassing the Hook

For work-in-progress commits on feature branches:

# Environment variable method
HUSKY=0 git commit -m "wip: draft implementation"

# Git native flag
git commit --no-verify -m "wip: draft implementation"
Enter fullscreen mode Exit fullscreen mode

Both methods skip all hooks entirely. HUSKY=0 is Husky-specific and more explicit about intent. --no-verify is a Git primitive that bypasses all hooks regardless of whether Husky is involved.

Policy: Never bypass hooks on main or any branch that feeds into a deployment pipeline. Bypassing should be limited to local WIP branches that will be cleaned up (rebased or squash-merged) before merging.


Summary

Component Role
Husky Manages Git hook lifecycle; makes hooks version-controlled
core.hooksPath Git config key Husky sets to redirect hook resolution
.husky/pre-commit Shell script executed before every commit
Biome Performs lint + format check; exits 1 on unfixable errors
prepare script Runs husky on bun install; installs hooks on every clone

The complete hook for a project with tests:

#!/bin/sh
bunx --bun @biomejs/biome check --write .
bun test
Enter fullscreen mode Exit fullscreen mode

For a project without tests yet:

#!/bin/sh
bunx --bun @biomejs/biome check --write .
Enter fullscreen mode Exit fullscreen mode

Add bun test to the hook only when test files exist. A hook that always fails provides no value and trains developers to bypass it.


Stack: Express 5 · Prisma 7 · TypeScript 5.9 · Bun 1.3 · Biome 2.4 · Husky 9

Top comments (0)