TypeScript Project Standards
You are setting up or reviewing a TypeScript project to follow modern best practices. Apply these standards:
Technology Stack
| Tool | Purpose | Command |
|---|---|---|
| Biome | Linting, formatting, import organizing (replaces ESLint + Prettier) | npx biome check, npx biome format |
| tsc | Type checking (strictest settings) | npx tsc --noEmit |
| Vitest | Testing (fast, TypeScript-native, V8 coverage) | npx vitest run |
| just | Task runner (replaces npm scripts sprawl) | just fc, just test |
Required Files
tsconfig.json (Maximum Strictness)
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInImports": true,
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["node_modules", "dist", "coverage"]
}
Key strict options explained:
noUncheckedIndexedAccess— Array/object index access returnsT | undefined, forcing null checksexactOptionalPropertyTypes— Distinguishes{ x?: string }(missing) from{ x: undefined }(present but undefined)noPropertyAccessFromIndexSignature— Forces bracket notation for index signatures, making dynamic access explicitverbatimModuleSyntax— Requires explicitimport typefor type-only imports, prevents runtime import side effects
Remove "jsx": "react-jsx" if not using React.
biome.json (Biome v2, Aggressive Rules)
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": "warn",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error",
"noUnusedFunctionParameters": "warn",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error"
},
"performance": {
"noAccumulatingSpread": "warn",
"noBarrelFile": "warn",
"noReExportAll": "warn"
},
"style": {
"noNonNullAssertion": "warn",
"useForOf": "warn",
"useTemplate": "error"
},
"suspicious": {
"noExplicitAny": "error",
"noConfusingVoidType": "error"
}
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}
Aggressive rules explained:
noExplicitAny: "error"— Bansanytype; useunknownand narrow insteadnoUnusedImports: "error"— Auto-removable bybiome check --writenoBarrelFile: "warn"— Discouragesindex.tsre-export barrels that hurt tree-shakingnoAccumulatingSpread: "warn"— Flags O(n²) spread-in-loop patternsnoNonNullAssertion: "warn"— Discourages!postfix; prefer proper null checks
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/**/*.d.ts'],
},
},
});
Justfile
set shell := ["bash", "-cu"]
default:
@just --list
# Format code
fmt:
npx biome format --write .
# Check formatting (no changes)
format-check:
npx biome format .
# Lint code (no changes)
lint:
npx biome lint .
# Fix lint + format + organize imports
lint-fix:
npx biome check --write .
# Type check
type:
npx tsc --noEmit
# Run tests
test:
npx vitest run
# Run tests with coverage
test-cov:
npx vitest run --coverage
# Full check (lint + format + imports, no fixes)
check:
npx biome check .
# FIX + CHECK: Run before every commit
fc: lint-fix check type test
# CI pipeline (check-only, no fixes)
ci: check type test
# Install dependencies
install:
npm install
GitHub Actions CI (.github/workflows/ci.yml)
name: CI
on:
push:
branches: [main, master]
pull_request:
permissions:
contents: write
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ["20", "22"]
steps:
- uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install just
uses: taiki-e/install-action@v2
with:
tool: just
- name: Lint, format, and type check
run: |
just check
just type
- name: Run tests with coverage
run: npx vitest run --coverage
- name: Generate coverage badge
if: matrix.node-version == '22' && github.ref == 'refs/heads/main'
run: node scripts/coverage-badge.mjs
- name: Commit coverage badge
if: matrix.node-version == '22' && github.ref == 'refs/heads/main'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add assets/coverage.svg
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
git push
Coverage Badge Script (scripts/coverage-badge.mjs)
Zero-dependency Node.js script that generates an SVG badge from Vitest coverage output:
#!/usr/bin/env node
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
const summary = JSON.parse(readFileSync('coverage/coverage-summary.json', 'utf8'));
const pct = Math.round(summary.total.lines.pct);
const color = pct >= 90 ? '#4c1' : pct >= 75 ? '#dfb317' : '#e05d44';
const w = pct === 100 ? 116 : 108;
const tx = pct === 100 ? 88 : 84;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20">
<linearGradient id="b" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="a"><rect width="${w}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#a)">
<rect width="61" height="20" fill="#555"/>
<rect x="61" width="${w - 61}" height="20" fill="${color}"/>
<rect width="${w}" height="20" fill="url(#b)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,sans-serif" font-size="11">
<text x="30.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
<text x="30.5" y="14">coverage</text>
<text x="${tx}" y="15" fill="#010101" fill-opacity=".3">${pct}%</text>
<text x="${tx}" y="14">${pct}%</text>
</g>
</svg>`;
mkdirSync('assets', { recursive: true });
writeFileSync('assets/coverage.svg', svg);
console.log(`Coverage badge: ${pct}%`);
README Badges
Add at the top of README.md (replace OWNER/REPO):
[](https://github.com/OWNER/REPO/actions/workflows/ci.yml)

Initial Coverage Badge
Generate locally before first push:
npx vitest run --coverage
node scripts/coverage-badge.mjs
Add coverage/ to .gitignore. The assets/coverage.svg file is committed — CI auto-updates it on pushes to main.
Dev Dependencies
Install with:
npm install -D @biomejs/biome typescript vitest @vitest/coverage-v8
{
"devDependencies": {
"@biomejs/biome": "^2.4.0",
"typescript": "^5.7.0",
"vitest": "^4.0.0",
"@vitest/coverage-v8": "^4.0.0"
}
}
The fc Command
Always run just fc before committing. It runs:
lint-fix— Auto-fix lint, formatting, and import ordering via Biomecheck— Verify everything passes (catches unfixable issues)type— TypeScript type checking with strictest settingstest— Run full test suite
Coverage Policy
- CI generates the badge: On pushes to main, CI runs coverage, generates an SVG via
scripts/coverage-badge.mjs, and commits it toassets/coverage.svg - Local
just testruns fast without coverage overhead just test-covruns coverage locally with terminal report- The
[skip ci]suffix prevents infinite CI loops when the badge is updated - No external services (Codecov, Coveralls) — the badge is a self-contained SVG in the repo
Agent Guidelines
- Always run
just fcbefore committing — non-negotiable - No
anytype — useunknownand narrow with type guards - No non-null assertions — prefer optional chaining, nullish coalescing, or proper checks
- Use
import type— separate type imports from value imports (verbatimModuleSyntaxenforces this) - Write tests — new features need tests; bug fixes need regression tests
- Follow existing patterns — match the project's conventions
- Do not add backward-compatibility shims — clean up old code fully
Project Type Adjustments
Node.js (no bundler)
// tsconfig.json changes:
"module": "Node16",
"moduleResolution": "Node16"
// Remove "jsx" line
Library (published to npm)
// tsconfig.json — keep declaration/declarationMap enabled
// Ensure "outDir" is set and included in package.json "files"
React / Vite
// tsconfig.json: keep as-is (bundler + react-jsx is correct)
// Add to vitest.config.ts if using jsdom:
// test: { environment: "jsdom" }
Electron
// May need separate tsconfigs for main (Node) vs renderer (browser)
// Main: "module": "CommonJS", "moduleResolution": "node"
// Renderer: "module": "ESNext", "moduleResolution": "bundler"
Node.js with native TypeScript execution (Node 22+)
// tsconfig.json: add for --strip-types compatibility:
"erasableSyntaxOnly": true
// Disables enums and namespaces (only erasable syntax allowed)
Common Issues & Troubleshooting
verbatimModuleSyntax import errors
If you see errors about imports that should be type-only:
// Wrong — will error with verbatimModuleSyntax
import { MyType } from './types';
// Correct
import type { MyType } from './types';
// Mixed (values and types from same module)
import { myFunction, type MyType } from './module';
exactOptionalPropertyTypes confusion
This option is strict about undefined vs missing:
interface Config {
debug?: boolean; // Can be missing, but NOT explicitly `undefined`
}
const a: Config = { debug: undefined }; // ERROR
const b: Config = {}; // OK
If too strict for your codebase, remove exactOptionalPropertyTypes from tsconfig.
noUncheckedIndexedAccess returns T | undefined
Array/object index access now returns T | undefined:
const arr = [1, 2, 3];
const x = arr[0]; // number | undefined
// Fix: check before use
if (arr[0] !== undefined) {
console.log(arr[0]); // number
}
// Or use non-null assertion (avoid if possible)
const y = arr[0]!; // number
Biome v2 migration from v1
If upgrading from Biome v1, run the automated migration:
npx @biomejs/biome migrate --write
Key v1 → v2 changes:
organizeImports.enabled→assist.actions.source.organizeImports: "on"include/ignore→includes(use!prefix for exclusions)--fix→--write- Several rules renamed or removed (migration command handles this)
Biome vs tsc unused variable overlap
Both Biome (noUnusedVariables) and tsc (noUnusedLocals/noUnusedParameters) catch unused variables. This is intentional — Biome can auto-fix (prefix with _), tsc catches at type-check time. If double-reporting is annoying, disable one:
// Option A: Disable in tsc, let Biome handle it
"noUnusedLocals": false,
"noUnusedParameters": false
// Option B: Disable in Biome, let tsc handle it
// In biome.json: "noUnusedVariables": "off"
Package manager alternatives
The Justfile uses npx which works universally. For pnpm or bun projects:
npx→pnpm execorbunxnpm install→pnpm installorbun installnpm ci→pnpm install --frozen-lockfileorbun install --frozen-lockfile
Update the CI workflow cache field accordingly ('pnpm' or remove for bun).
Your Task
$ARGUMENTS
If no specific task was given, analyze this project and:
- Check if the required files exist (tsconfig.json, biome.json, vitest.config.ts, Justfile, .github/workflows/ci.yml)
- Report what's missing or needs updating
- Offer to create/update the missing pieces