PocketArC LogoPocketArC

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.

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

ToolWhen it runsWhat it catches
TypeScriptCompileType errors at the lowest level.
BiomeEvery saveFormatting, 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-eslintPre-commit and CIA 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.

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:

"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.

FlagWhat it catches
noImplicitReturnsFunctions where not all code paths return a value.
noImplicitOverrideClass 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.
noFallthroughCasesInSwitchswitch cases without an explicit break or return. Catches the classic fallthrough bug.
exactOptionalPropertyTypesOptional 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.
noUncheckedIndexedAccessIndex 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.
noPropertyAccessFromIndexSignatureIf 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.
noUnusedLocalsUnused local variables are errors. Forces you to clean up as you go.
noUnusedParametersUnused function parameters are errors. Prefix with _ to intentionally ignore.
allowUnusedLabels: falseUnused loop labels are errors.
allowUnreachableCode: falseCode after a return, throw, or infinite loop is an error.

Module system

FlagWhat it catches
verbatimModuleSyntaxPreserves 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).
isolatedModulesEvery 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"Modern resolution that matches how bundlers like Next.js, Vite, and webpack resolve imports.
allowImportingTsExtensionsAllows 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.

FlagWhat it catches
forceConsistentCasingInFileNamesImports 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: trueEnables 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: trueEmits .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: trueDon'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:

DomainWhat 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 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":

RuleWhat it catches
nursery/noFloatingPromisesAsync 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/noMisusedPromisesPassing 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/useAwaitThenableawait 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/noUnnecessaryConditionsConditions 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/useExhaustiveSwitchCasesSwitch 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:

RuleConfiguration
style.useComponentExportOnlyModules"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"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"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"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. 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:

"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:

RuleWhat it catchesIn recommended?
suspicious/noConsoleconsole.log/.info/.debug in production code. The allow option whitelists error and warn for intentional logging.Yes
suspicious/noDebuggerLeftover debugger statements.Yes
suspicious/noAlertalert(), confirm(), prompt(). Block the page and have no place in a production app.No, enable explicitly
security/noGlobalEvaleval() and the Function constructor. Arbitrary code execution from a string has no place in application code.Yes
security/noDangerouslySetInnerHtmlReact's dangerouslySetInnerHTML. The name is a warning; the linter turns it into a build failure unless explicitly suppressed.Yes
security/noBlankTarget<a target="_blank"> without rel="noopener". Tabnabbing vector.Yes
style/noProcessEnvDirect 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:

"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 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:

"nursery": {
    "useExplicitType": "error"
}

Disabled rules (and why)

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

RuleWhy it's off
complexity.useLiteralKeysConflicts with noPropertyAccessFromIndexSignature in the tsconfig, which requires bracket notation for index signature access. Can't have both on.
correctness.noUndeclaredVariablesTypeScript 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:

SettingValueWhy
indentStylespaceSpaces render consistently everywhere: terminals, GitHub diffs, paste buffers. No surprises.
indentWidth4Some teams prefer 2; pick one and enforce it.
lineWidth12080 columns is a holdover from terminals that I don't care for. Change if you do.

These should always be on:

SettingValueWhy
lineEndinglfUnix line endings across all platforms. Prevents the CRLF/LF churn that happens when Windows and macOS/Linux developers commit to the same repo.
formatWithErrorstrueFormat the file even if there are lint errors. You don't want to lose formatting while debugging.
useEditorconfigtrueRespect .editorconfig if present, so Biome integrates with existing editor configurations.
javascript.formatter.quotePropertiespreserveKeeps quoted property keys as-is. Without this, Biome would strip quotes from obj["key"], which conflicts with noPropertyAccessFromIndexSignature in the tsconfig.
css.parser.tailwindDirectivestrueThe 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.

RuleWhat it catches
@typescript-eslint/no-unsafe-argumentPassing any into a typed parameter
@typescript-eslint/no-unsafe-assignmentAssigning any to a typed variable
@typescript-eslint/no-unsafe-callCalling a value typed as any
@typescript-eslint/no-unsafe-member-accessAccessing members on an any value
@typescript-eslint/no-unsafe-returnReturning 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 catches every reference to a function, method, or property tagged with @deprecated JSDoc. Biome has suspicious/noDeprecatedImports, 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 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:

"@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 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

{
    "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

{
    "$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

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 or X/Twitter.