Linting, Formatting & Testing
The compiler catches type errors, but a healthy TypeScript project leans on a few more tools: a linter for risky patterns the compiler allows, a formatter so nobody argues about style, and a test runner for behaviour. This lesson sets up the most common choices and shows how they fit together.
The toolchain at a glance
| Job | Common tools | Catches |
|---|---|---|
| Type checking | tsc --noEmit | Type mismatches, missing properties, null misuse |
| Linting | ESLint + typescript-eslint, Biome, Oxlint | Floating promises, unsafe any, unused code, suspicious patterns |
| Formatting | Prettier, Biome | Inconsistent spacing, quotes, line breaks |
| Testing | Vitest, Jest, Node's built-in node:test | Wrong behaviour |
ESLint with typescript-eslint
npm install --save-dev eslint @eslint/js typescript-eslint
ESLint uses a flat config file, eslint.config.js:
import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( eslint.configs.recommended, tseslint.configs.recommendedTypeChecked, { languageOptions: { parserOptions: { projectService: true }, }, }, );
The …TypeChecked preset enables rules that use full type information. They're slower, but they catch bugs nothing else does. A few favourites:
no-floating-promises— a promise that's neither awaited nor handled.no-misused-promises— passing an async function where a sync callback is expected (e.g.if (asyncCheck())).no-unsafe-assignment,no-unsafe-member-access—anyvalues leaking into typed code.no-explicit-any— discourages writinganyat all.switch-exhaustiveness-check— aswitchon a union that misses a case.
async function save(): Promise<void> { /* … */ } button.addEventListener("click", () => { save(); // ✗ no-floating-promises: errors here vanish silently void save(); // ✓ explicitly "fire and forget" });
Run it with npx eslint .. Most editors show ESLint warnings inline, just like type errors.
"devDependencies": {
"typescript": "npm:@typescript/typescript6@^6.0.2",
"@typescript/native": "npm:typescript@^7.0.2"
}
Check the typescript-eslint release notes before upgrading — this is moving quickly.Prettier for formatting
npm install --save-dev prettier npx prettier --write .
Prettier reformats code into one consistent style and supports TypeScript out of the box. Configure your editor to format on save and formatting stops being a topic in code review. Keep formatting rules out of ESLint — let each tool do one job.
Biome is an alternative that combines linting and formatting in a single fast binary. It doesn't yet run the type-aware rules that typescript-eslint offers, so some teams use Biome for formatting and basic linting alongside a smaller typescript-eslint setup.
Testing with Vitest
Vitest runs TypeScript test files directly, with no separate build step:
npm install --save-dev vitest
export function applyDiscount(price: number, percent: number): number { if (percent < 0 || percent > 100) { throw new RangeError("percent must be 0–100"); } return Math.round(price * (100 - percent)) / 100; }
import { describe, expect, it } from "vitest"; import { applyDiscount } from "./price"; describe("applyDiscount", () => { it("takes a percentage off", () => { expect(applyDiscount(80, 25)).toBe(60); }); it("rounds to cents", () => { expect(applyDiscount(9.99, 15)).toBe(8.49); }); it("rejects invalid percentages", () => { expect(() => applyDiscount(10, 120)).toThrow(RangeError); }); });
npx vitest run
✓ src/price.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)Test files are type-checked like any other code (by your editor and tsc), so a test that calls a function with the wrong arguments fails before it even runs. Jest works too, but needs extra setup (ts-jest or a Babel/SWC transform) to understand TypeScript.
Testing types
For libraries, the types are part of the API and can be tested too. Vitest includes expectTypeOf:
import { expectTypeOf, test } from "vitest"; import { applyDiscount } from "./price"; test("signature", () => { expectTypeOf(applyDiscount).parameters.toEqualTypeOf<[number, number]>(); expectTypeOf(applyDiscount).returns.toBeNumber(); });
Putting it together
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run",
"check": "npm run typecheck && npm run lint && npm run test"
}
}
Run npm run check in continuous integration (CI) on every pull request, so nothing that fails types, lint or tests can be merged. Many teams also run a fast subset before each commit with a tool like lint-staged.