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

JobCommon toolsCatches
Type checkingtsc --noEmitType mismatches, missing properties, null misuse
LintingESLint + typescript-eslint, Biome, OxlintFloating promises, unsafe any, unused code, suspicious patterns
FormattingPrettier, BiomeInconsistent spacing, quotes, line breaks
TestingVitest, Jest, Node's built-in node:testWrong behaviour

ESLint with typescript-eslint

$ terminal
npm install --save-dev eslint @eslint/js typescript-eslint

ESLint uses a flat config file, eslint.config.js:

JS 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 — any values leaking into typed code.
  • no-explicit-any — discourages writing any at all.
  • switch-exhaustiveness-check — a switch on a union that misses a case.
TS floating.ts
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.

TypeScript 7 note: type-aware lint rules call into the TypeScript compiler's JavaScript API. TypeScript 7.0's native compiler doesn't ship that API yet (it's planned for a 7.x release), and at the time of writing typescript-eslint doesn't support TypeScript 7. Projects that lint keep TypeScript 6 installed for their tools and add the native compiler under a different package name for fast builds:
"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

$ terminal
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:

$ terminal
npm install --save-dev vitest
TS src/price.ts
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;
}
TS src/price.test.ts
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);
  });
});
$ terminal
npx vitest run
Output
 ✓ 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:

TS src/types.test-d.ts
import { expectTypeOf, test } from "vitest";
import { applyDiscount } from "./price";

test("signature", () => {
  expectTypeOf(applyDiscount).parameters.toEqualTypeOf<[number, number]>();
  expectTypeOf(applyDiscount).returns.toBeNumber();
});

Putting it together

JSON package.json (excerpt)
{
  "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.

Tip: the editor is your fastest feedback loop. Make sure VS Code (or your editor) uses the project's TypeScript version — in VS Code, run "TypeScript: Select TypeScript Version" and choose "Use Workspace Version" — so what you see while typing matches what CI reports.