| name | typescript-vibe-safe-coding-setup |
| description | Install and configure a "vibe-safe" TypeScript toolchain (ESLint flat config + typescript-eslint strict + Prettier + Husky/lint-staged + strict tsconfig + ASCII enforcement + typed errors + structured logger + validated env + coverage ratchet) so AI-generated code is caught by lints/types before it ships. TRIGGER when the user is starting a new TS/Next.js/Node project, asks to "set up linting", "add eslint", "make this project safe for vibe coding", "add guardrails", "harden this codebase", or wants to mirror a known-good lint baseline. SKIP for one-off rule tweaks in an already-configured project. |
TypeScript Vibe-Safe Coding Setup
A reusable, opinionated baseline that turns ESLint into a guardrail strict enough that LLM-generated code can't silently introduce bugs, magic numbers, untyped boundaries, swallowed errors, smart-quotes, raw env access, plain Error() throws, console.log, or unstructured logging.
When to apply
- New Next.js / Node / TS monorepo with no ESLint config, or only
next lint defaults.
- Existing project where the user explicitly wants stricter guardrails ("vibe-safe", "make linting strict", "add lint rules to catch AI mistakes").
Always confirm with the user before overwriting an existing eslint.config.*, tsconfig.json, .prettierrc, or package.json scripts.
Step 1 - Install dependencies
Use pnpm if pnpm-lock.yaml exists, otherwise match the existing lockfile's package manager.
pnpm add -D eslint @eslint/js @eslint/eslintrc typescript-eslint \
prettier \
husky lint-staged \
@types/node typescript
For Next.js projects also add:
pnpm add -D eslint-config-next eslint-plugin-i18next eslint-plugin-jsx-a11y
For the structured logger and env validation referenced below:
pnpm add pino @t3-oss/env-nextjs zod
pnpm add -D pino-pretty
Step 2 - tsconfig.base.json (strict baseline)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowJs": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist", ".next", ".turbo"]
}
Never disable strict or noUncheckedIndexedAccess to make code compile - fix the code instead.
Step 3 - eslint.config.mjs (the guardrail core)
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import i18nextPlugin from 'eslint-plugin-i18next';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({ baseDirectory: __dirname });
const NO_NON_ASCII_SELECTORS = [
{
selector: 'Literal[value=/[^\\x00-\\x7F]/]',
message: 'Non-ASCII in string literal. Use ASCII (em-dash -> -, smart quote -> \' or ").',
},
{
selector: 'TemplateElement[value.raw=/[^\\x00-\\x7F]/]',
message: 'Non-ASCII in template literal.',
},
];
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.strict,
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
ignores: [
'node_modules/**', '.next/**', 'out/**', 'build/**', 'dist/**',
'next-env.d.ts', 'coverage/**',
'eslint.config.mjs', 'postcss.config.mjs',
],
},
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: __dirname },
},
},
{
files: ['**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/naming-convention': [
'error',
{ selector: 'default', format: ['camelCase'], leadingUnderscore: 'allow' },
{ selector: 'variable', format: ['camelCase', 'UPPER_CASE', 'PascalCase'], leadingUnderscore: 'allow' },
{ selector: 'function', format: ['camelCase', 'PascalCase'] },
{ selector: 'parameter', format: ['camelCase'], leadingUnderscore: 'allow' },
{ selector: 'property', format: ['camelCase', 'UPPER_CASE', 'PascalCase'], leadingUnderscore: 'allow' },
{ selector: 'property', modifiers: ['requiresQuotes'], format: null },
{ selector: 'typeLike', format: ['PascalCase'] },
{ selector: 'enumMember', format: ['UPPER_CASE', 'PascalCase'] },
{ selector: 'import', format: null },
],
'max-lines-per-function': ['error', { max: 50, skipBlankLines: true, skipComments: true }],
'max-lines': ['error', { max: 300, skipBlankLines: true, skipComments: true }],
complexity: ['error', { max: 10 }],
'max-depth': ['error', { max: 4 }],
'max-params': ['error', { max: 4 }],
'max-nested-callbacks': ['error', { max: 3 }],
'default-case': 'error',
'@typescript-eslint/switch-exhaustiveness-check': [
'error',
{ considerDefaultExhaustiveForUnions: true },
],
'@typescript-eslint/strict-boolean-expressions': [
'error',
{
allowString: false,
allowNumber: false,
allowNullableObject: true,
allowNullableBoolean: true,
allowNullableString: false,
allowNullableNumber: false,
allowAny: false,
},
],
'no-implicit-coercion': 'error',
'no-param-reassign': 'error',
'@typescript-eslint/no-shadow': 'error',
'guard-for-in': 'error',
'no-magic-numbers': [
'error',
{ ignore: [0, 1, -1], ignoreArrayIndexes: true, ignoreDefaultValues: true, enforceConst: true },
],
'@typescript-eslint/explicit-module-boundary-types': 'error',
'consistent-return': 'error',
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-ignore': true,
'ts-nocheck': true,
'ts-expect-error': 'allow-with-description',
minimumDescriptionLength: 10,
},
],
'@typescript-eslint/consistent-type-assertions': [
'error',
{ assertionStyle: 'as', objectLiteralTypeAssertions: 'never' },
],
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'no-empty': ['error', { allowEmptyCatch: false }],
'no-restricted-syntax': ['error', ...NO_NON_ASCII_SELECTORS],
},
},
{
rules: {
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: ['error', 'always'],
'no-nested-ternary': 'error',
'prefer-template': 'error',
'no-else-return': ['error', { allowElseIf: false }],
'no-unneeded-ternary': 'error',
'object-shorthand': ['error', 'always'],
'prefer-arrow-callback': ['error', { allowNamedFunctions: false }],
'no-lonely-if': 'error',
'no-useless-return': 'error',
curly: ['error', 'all'],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{ name: 'stripe', message: 'Import from @/lib/payments/providers/ instead.', allowTypeImports: true },
{ name: 'resend', message: 'Import from @/lib/email/providers/ instead.' },
{ name: 'nodemailer', message: 'Import from @/lib/email/providers/ instead.' },
{ name: 'posthog-js', message: 'Import from @/lib/analytics/ instead.' },
],
},
],
},
},
{ files: ['lib/payments/providers/**', 'lib/payments/stripe.ts'], rules: { 'no-restricted-imports': 'off' } },
{ files: ['lib/email/providers/**'], rules: { 'no-restricted-imports': 'off' } },
{ files: ['lib/analytics/posthog.ts'], rules: { 'no-restricted-imports': 'off' } },
{
files: ['app/**/*.tsx', 'components/**/*.tsx', 'features/**/*.tsx'],
plugins: { i18next: i18nextPlugin },
rules: {
'i18next/no-literal-string': [
'error',
{
mode: 'jsx-text-only',
ignoreAttribute: [
'className', 'class', 'style', 'href', 'src', 'alt', 'type',
'id', 'name', 'data-testid', 'autoComplete', 'htmlFor', 'role',
'key', 'variant', 'size',
'strokeLinecap', 'strokeLinejoin', 'viewBox', 'fill', 'stroke', 'd',
'lang', 'dir',
],
ignoreCallee: ['cn', 'clsx', 'buttonVariants', 'console.log', 'console.error', 'console.warn'],
ignoreProperty: ['className', 'style'],
},
],
},
},
{ files: ['app/global-error.tsx', 'app/api/**', 'components/ui/**', 'lib/**'], rules: { 'i18next/no-literal-string': 'off' } },
{
files: ['**/*.tsx'],
ignores: ['**/*.test.tsx', 'app/api/**'],
rules: {
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-static-element-interactions': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/heading-has-content': 'error',
'jsx-a11y/html-has-lang': 'error',
'jsx-a11y/no-autofocus': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/role-has-required-aria-props': 'error',
'jsx-a11y/no-noninteractive-element-interactions': 'error',
},
},
{
files: ['**/*.{ts,tsx}'],
ignores: [
'**/*.test.{ts,tsx}', 'lib/db/seed.ts', 'lib/env.ts',
'scripts/**', 'app/**/error.tsx', 'app/global-error.tsx',
],
rules: { 'no-console': 'error' },
},
{
files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
rules: {
'max-lines-per-function': 'off',
'max-lines': 'off',
complexity: 'off',
'max-depth': 'off',
'max-nested-callbacks': 'off',
'max-params': 'off',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-extraneous-class': 'off',
'@typescript-eslint/no-useless-constructor': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/consistent-type-assertions': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'no-magic-numbers': 'off',
'no-param-reassign': 'off',
'no-empty': 'off',
'@typescript-eslint/no-shadow': 'off',
'consistent-return': 'off',
},
},
{
files: ['**/*.config.{ts,mjs,js}', 'scripts/**', 'lib/env.ts', 'lib/db/seed.ts'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'max-lines-per-function': 'off',
'max-lines': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'no-magic-numbers': 'off',
'consistent-return': 'off',
},
},
{ files: ['lib/db/schema/**'], rules: { '@typescript-eslint/naming-convention': 'off', 'no-magic-numbers': 'off' } },
{
files: ['lib/db/migrations/**'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'no-magic-numbers': 'off',
'max-lines-per-function': 'off',
},
},
{
files: ['components/ui/**'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/consistent-type-assertions': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'no-magic-numbers': 'off',
'no-empty': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/no-shadow': 'off',
'jsx-a11y/label-has-associated-control': 'off',
},
},
{
files: [
'app/manifest.ts',
'lib/analytics/posthog.ts',
'lib/db/index.ts',
'lib/payments/checkout.ts',
'lib/seo/json-ld.tsx',
],
rules: { '@typescript-eslint/naming-convention': 'off' },
},
{
files: ['app/api/mcp/**', 'lib/mcp/**'],
rules: {
'@typescript-eslint/consistent-type-assertions': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
{
files: ['**/*.{ts,tsx}'],
ignores: [
'lib/env.ts', 'instrumentation.ts', 'next.config.ts',
'drizzle.config.ts', 'middleware.ts', 'lib/db/seed.ts',
'scripts/**', '**/*.test.{ts,tsx}', 'vitest.config.ts',
],
rules: {
'no-restricted-syntax': [
'error',
...NO_NON_ASCII_SELECTORS,
{
selector: 'MemberExpression[object.object.name="process"][object.property.name="env"]',
message: 'Use `import { env } from "@/lib/env"` instead of process.env.',
},
],
},
},
{
files: ['**/*.{ts,tsx}'],
ignores: ['**/*.test.{ts,tsx}', 'lib/db/seed.ts', 'scripts/**'],
rules: {
'no-restricted-syntax': [
'error',
...NO_NON_ASCII_SELECTORS,
{
selector: "NewExpression[callee.name='Error']",
message: 'Use ClientError, ServerError, or ExternalServiceError. Import from @/lib/errors.',
},
{
selector: "ThrowStatement > NewExpression[callee.name='Error']",
message: 'Use typed errors instead of throw new Error().',
},
],
},
},
{
files: ['app/api/**/route.{ts,tsx}'],
ignores: ['app/api/auth/**', 'app/api/og/**', 'app/api/webhooks/**', 'app/api/mcp/**'],
rules: {
'no-restricted-syntax': [
'error',
...NO_NON_ASCII_SELECTORS,
{
selector: 'MemberExpression[object.object.name="process"][object.property.name="env"]',
message: 'Use `import { env } from "@/lib/env"` instead of process.env.',
},
{
selector: "NewExpression[callee.name='Error']",
message: 'Use typed errors instead of plain Error.',
},
{
selector: 'ExportNamedDeclaration > FunctionDeclaration',
message: 'API handlers must use withApiRoute(). Export: const GET = withApiRoute(..., handler)',
},
],
},
},
);
Drop the Next.js-specific blocks (i18n, jsx-a11y, app/api API-route enforcement) if the project isn't Next.js. Drop the lib/db/* blocks if Drizzle isn't being used.
Step 4 - Prettier
.prettierrc:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
.prettierignore:
node_modules
.next
dist
.turbo
coverage
pnpm-lock.yaml
*.tsbuildinfo
.claude/
Step 5 - ASCII enforcement (scripts/check-ascii.sh)
ESLint's ASCII rule only catches string/template literals in TS/JS. This shell script catches non-ASCII in comments, identifiers, markdown, shell, python, go, sql, toml, yaml, json. Required for full coverage.
#!/usr/bin/env bash
set -euo pipefail
IN_SCOPE_REGEX='\.(ts|tsx|js|jsx|mjs|cjs|md|mdx|json|ya?ml|py|sh|go|rs|sql|toml|html|css|scss)$|(^|/)Dockerfile(\..+)?$|(^|/)Makefile$'
EXCLUDE_REGEX='(^|/)(node_modules|\.next|dist|build|coverage|\.git|\.venv|venv|__pycache__|\.turbo|playwright-report|test-results)(/|$)|(^|/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|uv\.lock|Cargo\.lock|go\.sum)$'
collect_files() {
if [[ $# -gt 0 ]]; then printf '%s\n' "$@"
elif git rev-parse --git-dir >/dev/null 2>&1; then git ls-files
else find . -type f ! -path '*/node_modules/*' ! -path '*/.git/*'
fi
}
filter_in_scope() { grep -E "$IN_SCOPE_REGEX" | grep -Ev "$EXCLUDE_REGEX" || true; }
violations=0
first=0
while IFS= read -r file; do
[[ -z "$file" || ! -f "$file" ]] && continue
if matches=$(perl -ne 'print "$.: $_" if /[^\x00-\x7F]/' "$file") && [[ -n "$matches" ]]; then
if [[ $first -eq 0 ]]; then
echo "Non-ASCII characters found. Replace with ASCII equivalents:"
echo " em-dash -> - arrow -> -> <- smart quote -> ' or \" ellipsis -> ..."
echo
first=1
fi
echo "$file:"; echo "$matches" | sed 's/^/ /'; echo
violations=$((violations + 1))
fi
done < <(collect_files "$@" | filter_in_scope)
if [[ $violations -gt 0 ]]; then
echo "check-ascii: $violations file(s) contain non-ASCII characters" >&2
exit 1
fi
echo "check-ascii: all files are pure ASCII"
chmod +x scripts/check-ascii.sh.
Step 6 - Husky + lint-staged
pnpm exec husky init
.husky/pre-commit:
pnpm lint-staged
.husky/pre-push:
pnpm verify
.lintstagedrc:
{
"*.{ts,tsx}": ["eslint --fix --max-warnings=0 --no-warn-ignored", "prettier --write"],
"*.{json,md,yml,yaml,css}": ["prettier --write"],
"*.{ts,tsx,js,jsx,mjs,cjs,md,mdx,json,yml,yaml,py,sh,go,rs,sql,toml,html,css,scss}": [
"bash scripts/check-ascii.sh"
]
}
The --max-warnings=0 flag is critical: warnings would otherwise let the commit through.
Step 7 - Enabling abstractions (lib/env.ts, lib/errors.ts, lib/logger.ts)
The bans in Step 3 (no process.env, no new Error(), no console.*) only work if the abstractions exist. Scaffold these:
lib/env.ts - validated env, the single allowed reader of process.env:
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
},
client: {},
runtimeEnv: process.env,
});
lib/errors.ts - typed errors with HTTP status + blame attribution:
export type Blame = 'client' | 'server' | 'external';
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly blame: Blame,
public readonly userMessage: string = message,
) {
super(message);
this.name = this.constructor.name;
}
}
export class ClientError extends AppError {
constructor(message: string, status = 400, userMessage?: string) {
super(message, status, 'client', userMessage);
}
}
export class ServerError extends AppError {
constructor(message: string, status = 500, userMessage?: string) {
super(message, status, 'server', userMessage);
}
}
export class ExternalServiceError extends AppError {
constructor(message: string, status = 502, userMessage?: string) {
super(message, status, 'external', userMessage);
}
}
lib/logger.ts - structured JSON logger via pino:
import pino from 'pino';
import { env } from '@/lib/env';
export const logger = pino({
level: env.LOG_LEVEL,
...(process.env.NODE_ENV === 'development' && {
transport: { target: 'pino-pretty', options: { colorize: true } },
}),
});
For Next.js API routes, lib/api/with-api-route.ts:
import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { AppError, ServerError } from '@/lib/errors';
import { logger } from '@/lib/logger';
type Handler<T> = (req: NextRequest, body: T) => Promise<unknown>;
export function withApiRoute<T>(schema: z.ZodType<T>, handler: Handler<T>) {
return async (req: NextRequest) => {
try {
const body = schema.parse(await req.json());
const data = await handler(req, body);
return NextResponse.json({ ok: true, data });
} catch (err) {
if (err instanceof z.ZodError) {
return NextResponse.json({ ok: false, error: err.issues }, { status: 400 });
}
if (err instanceof AppError) {
logger.warn({ err, blame: err.blame }, 'handler error');
return NextResponse.json({ ok: false, error: err.userMessage }, { status: err.statusCode });
}
const wrapped = new ServerError('Internal error');
logger.error({ err }, 'unhandled error');
return NextResponse.json({ ok: false, error: wrapped.userMessage }, { status: 500 });
}
};
}
Step 8 - Coverage ratchet (scripts/coverage-ratchet.ts)
Coverage that can only go up. Reads .coverage-baseline.json, fails if current is below baseline minus tolerance.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
const BASELINE = resolve('.coverage-baseline.json');
const SUMMARY = resolve('coverage/coverage-summary.json');
const TOLERANCE = 0.5;
if (!existsSync(SUMMARY)) {
console.error('No coverage summary. Run `pnpm test:coverage` first.');
process.exit(1);
}
const total = JSON.parse(readFileSync(SUMMARY, 'utf8')).total;
const current = {
statements: total.statements.pct,
branches: total.branches.pct,
functions: total.functions.pct,
lines: total.lines.pct,
};
if (process.argv.includes('--update') || !existsSync(BASELINE)) {
writeFileSync(BASELINE, JSON.stringify(current, null, 2) + '\n');
console.log('Baseline updated:', current);
process.exit(0);
}
const baseline = JSON.parse(readFileSync(BASELINE, 'utf8'));
let failed = false;
for (const k of Object.keys(current) as Array<keyof typeof current>) {
const diff = current[k] - baseline[k];
const ok = diff >= -TOLERANCE;
console.log(`${k}: ${baseline[k]}% -> ${current[k]}% (${diff >= 0 ? '+' : ''}${diff.toFixed(1)}%) ${ok ? 'PASS' : 'FAIL'}`);
if (!ok) failed = true;
}
if (failed) process.exit(1);
Configure Vitest to emit coverage-summary.json in vitest.config.ts:
test: { coverage: { provider: 'v8', reporter: ['text', 'json-summary'] } }
Step 9 - package.json scripts
{
"scripts": {
"lint": "eslint --max-warnings=0",
"lint:ascii": "bash scripts/check-ascii.sh",
"lint:fix": "eslint --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"format": "prettier --write .",
"format:check": "prettier --check .",
"verify": "pnpm lint && pnpm lint:ascii && pnpm typecheck && pnpm test:coverage && tsx scripts/coverage-ratchet.ts && pnpm build && pnpm format:check",
"prepare": "husky"
}
}
Step 10 - Verify
Run pnpm verify and resolve every error before declaring the setup done. If existing code floods with violations, fix them or scope per-file overrides explicitly - never disable a rule globally.
What NOT to do
- Don't downgrade rules to
'warn' to make CI pass. Errors mean errors.
- Don't add
// eslint-disable blanket comments. Use ts-expect-error with a 10-char description, or fix the code.
- Don't bypass
process.env ban with destructuring (const { FOO } = process.env); the rule selector catches process.env.X access patterns - the env module is the only correct path.
- Don't write custom Prettier configs that fight the formatter.
- Don't initialize the coverage baseline at 0% and call it done - run real tests first, then ratchet up.