# Hardcore TypeScript + Biome + ESLint: Maximum-strictness static analysis

AI writes a lot of TypeScript now. It writes `any` when narrowing types is hard, uses `arr[0]` without checking that an index exists, calls deprecated APIs it was trained on years ago, and catches errors as `any` instead of `unknown`. If there's a lazy way out of a problem, AI will usually take it by default.

TypeScript catches some of this. Biome catches a ton of stuff incredibly fast, plus a surprising amount of type-aware analysis via its nursery `types` domain (it's a huge upgrade over eslint during dev, use it!). typescript-eslint catches deep checks Biome doesn't have yet, mostly the `any`-propagation rules.

This page documents the combined configuration I use across my TypeScript projects: every strict TypeScript flag enabled, Biome at maximum strictness, and typescript-eslint on top. Most of it is framework-agnostic and works for any TypeScript codebase. The React and Next.js specifics are called out where they apply. For LLM consumption, a machine-readable version is available at [/typescript.md](/typescript.md).

The philosophy is split by cost: each tool runs where it's cheapest to run and catches what only it can catch.

| Tool              | When it runs      | What it catches                                                                                                                                                                                                        |
|-------------------|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| TypeScript        | Compile           | Type errors at the lowest level.                                                                                                                                                                                       |
| Biome             | Every save        | Formatting, import order, suspicious patterns, banned types, and (via its `types` domain) floating promises, unnecessary conditions, and exhaustive switches. It is _literally_ fast enough to run on every keystroke. |
| typescript-eslint | Pre-commit and CI | A handful of rules Biome hasn't implemented yet: unsafe `any` propagation, deprecated API calls at call sites, non-boolean conditions. Slower than Biome, which is why it runs late. |

This split mirrors how PHP is usually linted: PHPCS or Pint runs on save for fast linting, PHPStan runs in CI for slow type-aware checks. Biome plus typescript-eslint gives you both.

## The setup

This takes three config files, and all can be rolled into one lint command.

```bash
npm install --save-dev \
  typescript \
  @biomejs/biome \
  eslint \
  typescript-eslint \
  eslint-plugin-security
```

This page assumes TypeScript 6 or newer. If you're stuck on 5.x, set `strict: true` and the other defaults explicitly; everything else on this page applies identically.

The `package.json` lint script runs the three tools in order:

```json
"scripts": {
    "lint": "biome check --write src/ && tsc --noEmit && eslint src/"
}
```

Biome runs first with `--write` to auto-fix what it can (formatting, import organization, safe lint fixes). TypeScript runs second to type-check with `--noEmit`. ESLint runs last, because it's the slowest and most likely to fail. You want fast feedback from Biome and tsc first.

In the editor, only Biome and TypeScript should run on save. ESLint is a pre-commit and CI check, not an editor check.

## TypeScript: every strict flag

TypeScript 6 flipped `strict` to `true` by default, along with `module: esnext` and a floating current-year `target`, so a fresh TypeScript 6 install is already stricter than stock TypeScript 5.x. The config below still sets `strict: true` explicitly for clarity and for backwards compatibility with 5.x.

What follows is every flag this config adds on top of that baseline.

### Extra strictness beyond `strict`

These flags go further than `strict: true` and are the real difference between "strict" TypeScript and "hardcore" TypeScript.

| Flag                                 | What it catches                                                                                                                                                                                                                                           |
|--------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`noImplicitReturns`](https://www.typescriptlang.org/tsconfig/#noImplicitReturns)                            | Functions where not all code paths return a value.                                                                                                                                                                                                        |
| [`noImplicitOverride`](https://www.typescriptlang.org/tsconfig/#noImplicitOverride)                         | Class methods that override a parent method must use the `override` keyword. If the parent method is renamed or removed, you get an error instead of silently creating an unrelated method.                                                               |
| [`noFallthroughCasesInSwitch`](https://www.typescriptlang.org/tsconfig/#noFallthroughCasesInSwitch)         | `switch` cases without an explicit `break` or `return`. Catches the classic fallthrough bug.                                                                                                                                                              |
| [`exactOptionalPropertyTypes`](https://www.typescriptlang.org/tsconfig/#exactOptionalPropertyTypes)         | Optional properties (`foo?: string`) can be missing, but cannot be explicitly set to `undefined`. Enforces the distinction between "not set" and "set to undefined", which is a real semantic difference in some APIs.                                    |
| [`noUncheckedIndexedAccess`](https://www.typescriptlang.org/tsconfig/#noUncheckedIndexedAccess)             | Index access on arrays and records returns `T \| undefined` instead of `T`. Forces you to handle the case where the index doesn't exist. LLMs access `arr[0]` without bounds checks all the time because most training data is written without this flag. |
| [`noPropertyAccessFromIndexSignature`](https://www.typescriptlang.org/tsconfig/#noPropertyAccessFromIndexSignature) | If a type has an index signature (`[key: string]: Foo`), you must use bracket notation (`obj['key']`) instead of dot notation (`obj.key`). Forces you to be explicit about when you're accessing a signature vs a known property.                         |
| [`noUnusedLocals`](https://www.typescriptlang.org/tsconfig/#noUnusedLocals)                                 | Unused local variables are errors. Forces you to clean up as you go.                                                                                                                                                                                      |
| [`noUnusedParameters`](https://www.typescriptlang.org/tsconfig/#noUnusedParameters)                         | Unused function parameters are errors. Prefix with `_` to intentionally ignore.                                                                                                                                                                           |
| [`allowUnusedLabels: false`](https://www.typescriptlang.org/tsconfig/#allowUnusedLabels)                    | Unused loop labels are errors.                                                                                                                                                                                                                            |
| [`allowUnreachableCode: false`](https://www.typescriptlang.org/tsconfig/#allowUnreachableCode)              | Code after a `return`, `throw`, or infinite loop is an error.                                                                                                                                                                                             |

### Module system

| Flag                          | What it catches                                                                                                                                                                                                                               |
|-------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax)               | Preserves import and export syntax exactly as written, forcing explicit `import type` for type-only imports. This is needed for tools that can't do cross-file type analysis (like the TypeScript compiler running per-file in a bundler). |
| [`isolatedModules`](https://www.typescriptlang.org/tsconfig/#isolatedModules)                         | Every file must be safely transpilable on its own, with no cross-file type info needed. The main reason to enable this is performance: it lets you replace `tsc` for the transpilation step with Rust and Go tools (esbuild, swc, Next.js's compiler) which are 20 to 100 times faster because they skip type checking and process files in parallel. The trade-off is that a few patterns (`const enum`, re-exporting types without `export type`) stop working, and this flag catches them at compile time. |
| [`moduleResolution: "bundler"`](https://www.typescriptlang.org/tsconfig/#moduleResolution)            | Modern resolution that matches how bundlers like Next.js, Vite, and webpack resolve imports.                                                                                                                                                  |
| [`allowImportingTsExtensions`](https://www.typescriptlang.org/tsconfig/#allowImportingTsExtensions)   | Allows `import './foo.ts'` with the extension. Useful with `isolatedModules`, since the bundler handles the extension stripping.                                                                                                              |

### Other notable flags

Two of these (`composite` and `declaration`) are no-ops when `noEmit: true` is set, which it is for a Next.js application. They're kept on because this config is meant to work as a reference for any TypeScript project, including libraries and packages that publish types to npm. They cost nothing in an app, and they're already there when you need them for a library.

| Flag                               | What it catches                                                                                                                                                                                                                                      |
|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`forceConsistentCasingInFileNames`](https://www.typescriptlang.org/tsconfig/#forceConsistentCasingInFileNames) | Imports must match the actual case of the filename on disk. Catches bugs that only appear on case-sensitive filesystems (i.e., Linux CI when you develop on macOS).                                                                                  |
| [`composite: true`](https://www.typescriptlang.org/tsconfig/#composite)                                        | Enables project references (`tsc --build`) for multi-project and monorepo setups. Suppressed by `noEmit: true` in an application, but essential if you split the codebase into multiple tsconfig projects or extract a shared package.              |
| [`declaration: true`](https://www.typescriptlang.org/tsconfig/#declaration)                                     | Emits `.d.ts` files so library consumers get types. Suppressed by `noEmit: true` in an application, required the moment you reuse this config for a library or published package.                                                                   |
| [`noEmit: true`](https://www.typescriptlang.org/tsconfig/#noEmit)                                              | Don't emit anything (JavaScript or `.d.ts`). The bundler (Next.js) handles compilation; `tsc` is only used for type checking. Flip this off if you use this config for a library and want `tsc` to emit real output.                                |

## Biome: linter, formatter, and import organizer

Biome replaces Prettier, import sorters, and most of ESLint with a single tool. It's written in Rust and handles linting (finding bugs, enforcing code quality rules), formatting (what Prettier does), import organization (sorting and grouping imports), and type-aware analysis. All four are enabled in this config.

### Linter domains

Biome's linter uses a `domains` system. Each domain is a collection of rules targeting a specific framework or use case. You enable domains with one of three values:

- `"recommended"` activates the curated recommended subset
- `"all"` enables every rule in the domain, including nursery (experimental) rules
- `"none"` disables the domain entirely

This config enables four domains at maximum strictness:

| Domain           | What it enables                                                                                                                                                                                                                                                                                                                           |
|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `next: "all"`    | Next.js-specific rules (activates when `next` is detected in dependencies).                                                                                                                                                                                                                                                                |
| `react: "all"`   | React best practices and hooks rules (activates when `react` v16+ is detected).                                                                                                                                                                                                                                                            |
| `project: "all"` | Project-level analysis rules (rules that scan across files, not just within a single file).                                                                                                                                                                                                                                                |
| `types: "all"`   | Type-aware nursery rules that require Biome's type inference engine. This is where Biome does its heaviest work: `noFloatingPromises`, `noMisusedPromises`, `useAwaitThenable`, `noUnnecessaryConditions`, and `useExhaustiveSwitchCases`. See [Type-aware checks in Biome](#type-aware-checks-in-biome) below for what each one catches. |

Setting everything to `"all"` means the config picks up new rules automatically when Biome adds them, including nursery rules. The trade-off is that your CI might break when you upgrade Biome, but you catch new issues the moment they're implemented.

### Type-aware checks in Biome

The `types: "all"` setting activates Biome's type inference engine, enabling rules that need to understand types across the project. These are nursery rules (experimental), but they're stable enough to rely on and they cover most of the type-aware checks you'd need ESLint for. All of them are enabled automatically by `types: "all"`:

| Rule                               | What it catches                                                                                                                                                                                                                                                                                                                                  |
|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`nursery/noFloatingPromises`](https://biomejs.dev/linter/rules/no-floating-promises/)             | Async calls without `await`, `return`, `void`, `.then()`, or `.catch()`. A floating promise is a silent bug that usually surfaces as a mysterious error somewhere else in the app. LLMs constantly generate code like `db.save(user)` without awaiting it, especially in error handlers and cleanup paths.                                       |
| [`nursery/noMisusedPromises`](https://biomejs.dev/linter/rules/no-misused-promises/)               | Passing an async function where a synchronous one is expected, or using a promise in a condition (where it's always truthy because it's an object). Both are usually "forgot to `await`" bugs.                                                                                                                                                   |
| [`nursery/useAwaitThenable`](https://biomejs.dev/linter/rules/use-await-thenable/)                 | `await` on a non-promise value. It silently does nothing, but the worse problem is that it makes the code *look* like you're working with a promise when you're not, which obscures the actual control flow. Usually a typo or a mistaken assumption about a function's return type.                                                            |
| [`nursery/noUnnecessaryConditions`](https://biomejs.dev/linter/rules/no-unnecessary-conditions/)   | Conditions that are provably always true or always false: `if (user)` when `user` is a non-nullable type, optional chaining on a non-nullable value, dead branches in if-else chains.                                                                                                                                                            |
| [`nursery/useExhaustiveSwitchCases`](https://biomejs.dev/linter/rules/use-exhaustive-switch-cases/) | Switch statements over union types that don't cover every variant. Adding a new case to a union turns every existing switch into a build error until it's handled. Adding a `default:` arm to silently handle unknown cases is exactly how new variants get silently dropped.                                                                   |

### Rule overrides

Beyond the domain-wide settings, a few rules are explicitly tuned:

| Rule | Configuration |
|------|---------------|
| [`style.useComponentExportOnlyModules`](https://biomejs.dev/linter/rules/use-component-export-only-modules/) | `"error"` with `allowConstantExport: true`. Fast Refresh only works when a file only exports components; mixing in other exports causes a full page reload instead of a hot component update during development. `allowConstantExport: true` exempts `const` exports, which covers common patterns like Next.js `metadata` exports and shadcn/ui's `buttonVariants`. |
| [`style.noUnusedTemplateLiteral`](https://biomejs.dev/linter/rules/no-unused-template-literal/) | `"error"` with `fix: "safe"`. Catches template literals that don't use template literal features: `` const foo = `bar` `` should just be `const foo = "bar"`. The safe fix converts them automatically. |
| [`style.noDefaultExport`](https://biomejs.dev/linter/rules/no-default-export/) | `"error"`. Default exports are harder to rename and discover than named exports. Next.js page components require default exports, so those files need a `// biome-ignore` comment, but that's a small price for catching accidental default exports everywhere else. |
| [`style.useNamingConvention`](https://biomejs.dev/linter/rules/use-naming-convention/) | `"warn"` with a custom configuration allowing both `snake_case` and `camelCase` for type properties, object literal properties, class properties, and class getters. This is one rule you'll want to tweak to match your own conventions. The mixed allowlist here reflects my reality: backend APIs return `snake_case` JSON, frontend code uses `camelCase`, and the boundary layer needs both. |

### Complexity limits

Biome has a built-in rule for this: [`complexity/noExcessiveCognitiveComplexity`](https://biomejs.dev/linter/rules/no-excessive-cognitive-complexity/). It uses the SonarSource cognitive complexity algorithm, which weights nested conditionals and control flow changes more heavily than raw branch counts.

This is the rule that matters most for AI-generated code. Without a complexity ceiling, LLMs keep piling branches, callbacks, and nested conditions onto one function until it's unreadable. Set a limit and they're forced to decompose.

The rule is not in any domain and not in `recommended`, so `domains: "all"` does not enable it. Turn it on explicitly:

```json
"complexity": {
    "noExcessiveCognitiveComplexity": {
        "level": "error",
        "options": { "maxAllowedComplexity": 15 }
    }
}
```

The default threshold is 15, which matches SonarQube's default and is a reasonable ceiling for "function a human can hold in their head." Drop it to 10 if you want a tighter limit; both values are defensible.

One thing Biome doesn't do: the rule is per-function only. There's no aggregate class-level complexity check. In practice this rarely matters because modern TypeScript codebases lean heavily on functions and modules rather than classes, and if every function stays under 15, the files tend to stay manageable too.

### Disallowed calls

JavaScript has a handful of functions and patterns that shouldn't appear in production code. These are worth banning explicitly:

| Rule | What it catches | In `recommended`? |
|------|-----------------|-------------------|
| [`suspicious/noConsole`](https://biomejs.dev/linter/rules/no-console/) | `console.log`/`.info`/`.debug` in production code. The `allow` option whitelists `error` and `warn` for intentional logging. | Yes |
| [`suspicious/noDebugger`](https://biomejs.dev/linter/rules/no-debugger/) | Leftover `debugger` statements. | Yes |
| [`suspicious/noAlert`](https://biomejs.dev/linter/rules/no-alert/) | `alert()`, `confirm()`, `prompt()`. Block the page and have no place in a production app. | No, enable explicitly |
| [`security/noGlobalEval`](https://biomejs.dev/linter/rules/no-global-eval/) | `eval()` and the `Function` constructor. Arbitrary code execution from a string has no place in application code. | Yes |
| [`security/noDangerouslySetInnerHtml`](https://biomejs.dev/linter/rules/no-dangerously-set-inner-html/) | React's `dangerouslySetInnerHTML`. The name is a warning; the linter turns it into a build failure unless explicitly suppressed. | Yes |
| [`security/noBlankTarget`](https://biomejs.dev/linter/rules/no-blank-target/) | `<a target="_blank">` without `rel="noopener"`. Tabnabbing vector. | Yes |
| [`style/noProcessEnv`](https://biomejs.dev/linter/rules/no-process-env/) | Direct `process.env.FOO` access. Forces env vars through a single config module so there's one place to validate shapes and defaults. | No, enable explicitly |

The two non-recommended rules need to be turned on by name:

```json
"suspicious": {
    "noConsole": {
        "level": "error",
        "options": { "allow": ["error", "warn"] }
    },
    "noAlert": "error"
},
"style": {
    "noProcessEnv": "error"
}
```

`noProcessEnv` needs thought in any project that embeds env vars at build time. Next.js is the canonical case: it exposes config via `process.env.NEXT_PUBLIC_*` and server components read `process.env.*` directly by convention, so a naive ban would flag legitimate code everywhere. Vite projects hit the same pattern with `import.meta.env.VITE_*`, and Node services read straight from `process.env` on boot.

The pattern I favor is a single `src/config/env.ts` module: read every environment variable once at the top of that file, validate the shape with zod, and export typed constants for the rest of the codebase to import. That file gets a `// biome-ignore-all lint/style/noProcessEnv: centralized env access` comment at the top, and every other file is forbidden from touching `process.env` directly.

From then on, any `process.env.FOO` that shows up anywhere else is a build error.

### Explicit return types

[`nursery/useExplicitType`](https://biomejs.dev/linter/rules/use-explicit-type/) forces explicit return type annotations on every function, arrow, method, and getter. This is primarily for performance. Without them, TypeScript re-infers the return type every time a function is called, and on large codebases that adds up to noticeably slower type checks.

Like `noExcessiveCognitiveComplexity`, this isn't part of any domain. Enable it explicitly:

```json
"nursery": {
    "useExplicitType": "error"
}
```

### Disabled rules (and why)

Not every rule from the recommended set fits every project. These are intentionally turned off.

| Rule | Why it's off |
|------|--------------|
| [`complexity.useLiteralKeys`](https://biomejs.dev/linter/rules/use-literal-keys/) | Conflicts with [`noPropertyAccessFromIndexSignature`](https://www.typescriptlang.org/tsconfig/#noPropertyAccessFromIndexSignature) in the tsconfig, which requires bracket notation for index signature access. Can't have both on. |
| [`correctness.noUndeclaredVariables`](https://biomejs.dev/linter/rules/no-undeclared-variables/) | TypeScript already catches undeclared variables reliably via the type checker, so this rule is redundant and noisy in a TypeScript project. |

### Formatter settings

These three are my personal preferences. Tweak to taste:

| Setting | Value | Why |
|---------|-------|-----|
| `indentStyle` | `space` | Spaces render consistently everywhere: terminals, GitHub diffs, paste buffers. No surprises. |
| `indentWidth` | `4` | Some teams prefer 2; pick one and enforce it. |
| `lineWidth` | `120` | 80 columns is a holdover from terminals that I don't care for. Change if you do. |

These should always be on:

| Setting | Value | Why |
|---------|-------|-----|
| `lineEnding` | `lf` | Unix line endings across all platforms. Prevents the CRLF/LF churn that happens when Windows and macOS/Linux developers commit to the same repo. |
| `formatWithErrors` | `true` | Format the file even if there are lint errors. You don't want to lose formatting while debugging. |
| `useEditorconfig` | `true` | Respect `.editorconfig` if present, so Biome integrates with existing editor configurations. |
| `javascript.formatter.quoteProperties` | `preserve` | Keeps quoted property keys as-is. Without this, Biome would strip quotes from `obj["key"]`, which conflicts with [`noPropertyAccessFromIndexSignature`](https://www.typescriptlang.org/tsconfig/#noPropertyAccessFromIndexSignature) in the tsconfig. |
| `css.parser.tailwindDirectives` | `true` | The CSS parser understands Tailwind's `@apply`, `@screen`, and other custom directives so they don't trigger parse errors. |

### Import organization

`assist.actions.source.organizeImports: "on"` enables automatic import sorting and grouping. When you run `biome check --write`, imports get sorted into groups (external, then internal, then relative) with consistent ordering within each group. This matters for AI-generated code because LLMs often place imports in the order they're added, which produces noisy diffs and inconsistent files across the project.

## ESLint: the remaining rules

Biome covers most of what typescript-eslint offers, but not all of it yet. The gaps are small, specific, and high-value enough that running a second linter in pre-commit and CI is worth the trouble. The rules below are the only reason typescript-eslint is in this config; everything else it offers is already handled by Biome and is explicitly disabled in the ESLint config to avoid double-reporting.

### The `any` propagation family

These five rules are the single biggest reason to run typescript-eslint at all. Once a value has type `any`, it spreads: calling it returns `any`, accessing properties on it returns `any`, passing it around erases types everywhere it touches. Biome has no equivalent for any of these.

| Rule | What it catches |
|------|-----------------|
| [`@typescript-eslint/no-unsafe-argument`](https://typescript-eslint.io/rules/no-unsafe-argument/) | Passing `any` into a typed parameter |
| [`@typescript-eslint/no-unsafe-assignment`](https://typescript-eslint.io/rules/no-unsafe-assignment/) | Assigning `any` to a typed variable |
| [`@typescript-eslint/no-unsafe-call`](https://typescript-eslint.io/rules/no-unsafe-call/) | Calling a value typed as `any` |
| [`@typescript-eslint/no-unsafe-member-access`](https://typescript-eslint.io/rules/no-unsafe-member-access/) | Accessing members on an `any` value |
| [`@typescript-eslint/no-unsafe-return`](https://typescript-eslint.io/rules/no-unsafe-return/) | Returning `any` from a function with a typed return |

These rules close the escape hatch LLMs reach for whenever narrowing a type is hard. Without them, one `as any` or one untyped JSON response spreads silently through the codebase.

### Deprecation at call sites

[`@typescript-eslint/no-deprecated`](https://typescript-eslint.io/rules/no-deprecated/) catches every reference to a function, method, or property tagged with `@deprecated` JSDoc. Biome has [`suspicious/noDeprecatedImports`](https://biomejs.dev/linter/rules/no-deprecated-imports/), but that only catches the import statement. A codebase that imported a function before it was deprecated and still calls it in a hundred places gets zero warnings from Biome. typescript-eslint flags every call site, so deprecating an API actually fails the build everywhere it's used.

### Non-boolean conditions

[`@typescript-eslint/strict-boolean-expressions`](https://typescript-eslint.io/rules/strict-boolean-expressions/) bans `if (str)` when `str` is `string | null`, forcing you to write `if (str !== null && str !== '')` or `if (str != null)` depending on intent. Only real booleans are allowed in boolean contexts.

The common LLM bug this catches is checking a nullable type with `if (x)` when the writer meant "not null." JavaScript's loose truthiness makes these easy to write and impossible to spot by eye. The options let you loosen the rule for unambiguous cases:

```json
"@typescript-eslint/strict-boolean-expressions": ["error", {
    "allowString": true,
    "allowNumber": true,
    "allowNullableObject": false,
    "allowNullableBoolean": false,
    "allowNullableString": false,
    "allowNullableNumber": false,
    "allowAny": false
}]
```

### Weak crypto and weak randomness

[`eslint-plugin-security`](https://github.com/eslint-community/eslint-plugin-security) catches things neither Biome nor typescript-eslint does: `Math.random()` in security-sensitive contexts, unsafe regular expressions that can backtrack catastrophically (ReDoS), unsanitized file paths, and `crypto.createHash('md5' | 'sha1')`. The plugin's recommended preset is a good default.

Even if your project doesn't do cryptography directly, these rules catch bad practices that LLMs generate unprompted. Using `Math.random()` to generate IDs or session tokens is a classic example.

## The full config

### `tsconfig.json`

```json
{
    "compilerOptions": {
        // With noEmit, target only affects which syntax TS accepts, not the output.
        // esnext floats to the latest and matches the lib setting.
        "target": "esnext",
        // Controls which APIs TypeScript recognizes at compile time.
        // Without "dom", using window/document/fetch is a type error.
        // Without "esnext", newer JS APIs like Array.at() are unknown.
        "lib": ["dom", "esnext"],
        "skipLibCheck": true,
        "strict": true,
        "noEmit": true,
        "allowImportingTsExtensions": true,
        "types": ["node"],
        "module": "esnext",
        "moduleResolution": "bundler",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "react-jsx",
        "incremental": true,
        // Next.js plugin. Remove if not using Next.js.
        "plugins": [
            {
                "name": "next"
            }
        ],
        "paths": {
            "@/*": ["./src/*"]
        },
        "forceConsistentCasingInFileNames": true,
        "declaration": true,
        "composite": true,
        "allowUnusedLabels": false,
        "allowUnreachableCode": false,
        "exactOptionalPropertyTypes": true,
        "noFallthroughCasesInSwitch": true,
        "noImplicitOverride": true,
        "noImplicitReturns": true,
        "noPropertyAccessFromIndexSignature": true,
        "noUncheckedIndexedAccess": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "verbatimModuleSyntax": true,
        "removeComments": true,
        "pretty": true
    },
    "include": [
        "**/*.ts",
        "**/*.tsx",
        // Next.js generated types. Remove if not using Next.js.
        "next-env.d.ts",
        ".next/types/**/*.ts",
        ".next/dev/types/**/*.ts"
    ],
    // ".next" in exclude is Next.js-specific. Remove if not using Next.js.
    "exclude": ["node_modules", ".next"]
}
```

### `biome.json`

```json
{
    "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
    "vcs": {
        "enabled": true,
        "clientKind": "git",
        "useIgnoreFile": true
    },
    "files": {
        "ignoreUnknown": true,
        "includes": ["**"]
    },
    "formatter": {
        "enabled": true,
        "useEditorconfig": true,
        "formatWithErrors": true,
        "indentStyle": "space",
        "indentWidth": 4,
        "lineEnding": "lf",
        "lineWidth": 120
    },
    "assist": { "actions": { "source": { "organizeImports": "on" } } },
    "linter": {
        "enabled": true,
        "domains": {
            "next": "all",
            "react": "all",
            "project": "all",
            "types": "all"
        },
        "rules": {
            "complexity": {
                "noExcessiveCognitiveComplexity": {
                    "level": "error",
                    "options": { "maxAllowedComplexity": 15 }
                },
                "useLiteralKeys": {
                    "level": "off"
                }
            },
            "suspicious": {
                "noConsole": {
                    "level": "error",
                    "options": { "allow": ["error", "warn"] }
                },
                "noAlert": "error"
            },
            "correctness": {
                "noUndeclaredVariables": "off"
            },
            "style": {
                "noProcessEnv": "error",
                "useComponentExportOnlyModules": {
                    "level": "error",
                    "options": {
                        "allowConstantExport": true
                    }
                },
                "noUnusedTemplateLiteral": {
                    "level": "error",
                    "fix": "safe"
                },
                "noDefaultExport": "error",
                "useNamingConvention": {
                    "level": "warn",
                    "options": {
                        "strictCase": false,
                        "conventions": [
                            {
                                "selector": { "kind": "typeProperty" },
                                "formats": ["snake_case", "camelCase"]
                            },
                            {
                                "selector": { "kind": "objectLiteralProperty" },
                                "formats": ["snake_case", "camelCase"]
                            },
                            {
                                "selector": { "kind": "classProperty" },
                                "formats": ["snake_case", "camelCase"]
                            },
                            {
                                "selector": { "kind": "classGetter" },
                                "formats": ["snake_case", "camelCase"]
                            }
                        ]
                    }
                }
            },
            "nursery": {
                "useExplicitType": "error"
            }
        }
    },
    "javascript": {
        "formatter": {
            "quoteProperties": "preserve"
        }
    },
    "css": {
        "parser": {
            "tailwindDirectives": true
        }
    }
}
```

### `eslint.config.js`

```js
import tseslint from 'typescript-eslint';
import security from 'eslint-plugin-security';

export default tseslint.config(
    ...tseslint.configs.strictTypeChecked,
    security.configs.recommended,
    {
        languageOptions: {
            parserOptions: {
                projectService: true,
                tsconfigRootDir: import.meta.dirname,
            },
        },
        rules: {
            // Extra rules not in strict-type-checked.
            '@typescript-eslint/strict-boolean-expressions': ['error', {
                allowString: true,
                allowNumber: true,
                allowNullableObject: false,
                allowNullableBoolean: false,
                allowNullableString: false,
                allowNullableNumber: false,
                allowAny: false,
            }],

            // Already handled by Biome. Disabled to avoid double-reporting.
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/no-unnecessary-condition': 'off',
            '@typescript-eslint/no-floating-promises': 'off',
            '@typescript-eslint/no-misused-promises': 'off',
            '@typescript-eslint/await-thenable': 'off',
            '@typescript-eslint/no-for-in-array': 'off',
        },
    },
);
```

`projectService: true` is the modern way to hook typescript-eslint into TypeScript's language service. It picks up your `tsconfig.json` automatically without the per-file `parserOptions.project` setup that used to be necessary.

## What still isn't caught

One real gap: declared return types don't have to be tight. A function can declare `string | number` as its return type but only ever return `string`, and nothing in this stack will flag the overly broad annotation. `explicit-function-return-type` forces you to write a return type but doesn't check that it matches what you actually return. A `satisfies` assertion at the return site is the closest workaround, but nothing enforces its use.

This matters more for library authors defining public APIs than for application code. On a normal application codebase it rarely comes up.

## Adopting this on an existing codebase

The `no-unsafe-*` family of rules and `strict-boolean-expressions` will fire a lot on a codebase that wasn't written with them in mind. Once one value is `any`, everything it touches is `any`, and a single untyped JSON response can cascade into violations across the whole file. Don't try to fix them all at once.

Adopt the Biome half of this config first and get it clean. Then enable the ESLint half file-by-file or rule-by-rule. The fastest way through is to fix violations at the root (where the `any` or untyped value enters the system) rather than chasing every downstream site.

---

This page is a living reference and will be updated as the configuration evolves. If you spot an error or think something is missing, reach out via [email](mailto:hello@pocketarc.com) or [X/Twitter](https://x.com/pocketarc).
