eslint-plugin-solodev
v1.0.0
Published
10 ESLint rules that replace code review for solo developers. Typed errors, safe Zod parsing, server action contracts, and more.
Maintainers
Readme
eslint-plugin-solodev
10 ESLint rules that replace code review for solo developers.
When there's no one to catch your throw new Error() or your empty catch {}, these rules do it for you. Built from real patterns that caused production bugs in a one-person SaaS.
Install
npm install eslint-plugin-solodev --save-devSetup (flat config)
// eslint.config.mjs
import solodev from 'eslint-plugin-solodev'
export default [
// All 7 framework-agnostic rules
solodev.configs.recommended,
// OR: all 10 rules including Next.js server action rules
solodev.configs.nextjs,
]Or pick individual rules:
import solodev from 'eslint-plugin-solodev'
export default [
{
plugins: { solodev },
rules: {
'solodev/no-plain-error-throw': 'error',
'solodev/no-schema-parse': 'error',
}
}
]Rules
| Rule | Category | Description |
|---|---|---|
| no-plain-error-throw | Error handling | Ban throw new Error() — force typed subclasses |
| no-silent-catch | Error handling | Ban empty catch + log-only catch blocks |
| no-schema-parse | Zod safety | Ban schema.parse() — force safeParse() |
| no-unsafe-type-assertions | Type safety | Ban as unknown as Type double-casts |
| no-unvalidated-formdata | Type safety | Ban formData.get('x') as string |
| no-loose-status-type | Type safety | Ban status: string — force union types |
| one-export-per-file | Code organization | One exported function per file |
| action-must-return | Server actions | Actions must return via response helpers |
| require-use-server | Server actions | .action.ts files must start with "use server" |
| prefer-server-actions | Server actions | Flag fetch('/api/...') — use server actions |
no-plain-error-throw
A plain Error tells you nothing at 3am. Was it a network timeout you should retry? A validation failure you should surface? A bug you should page yourself for? Typed error subclasses encode this directly.
// Bad
throw new Error('Payment failed')
reject(new Error('Timed out'))
// Good
throw new TransientError('Payment gateway timeout', { cause: error })
throw new FatalError('Invalid card number')no-silent-catch
Most linters catch empty catch {}. This rule also catches the more insidious pattern: logging the error and continuing as if nothing happened.
// Bad
catch (e) {}
catch (e) { console.error(e) }
promise.catch(() => {})
promise.catch(e => console.log(e))
// Good
catch (e) { logger.error(e); throw e }
catch (e) { return fallbackValue }no-schema-parse
Zod's .parse() throws a ZodError on failure. In a server action, that's an uncontrolled exception with a useless stack trace. safeParse() returns a discriminated union you can handle gracefully.
// Bad
const data = userSchema.parse(input)
z.array(schema).parse(items)
// Good
const result = userSchema.safeParse(input)
if (!result.success) return failed(result.error)
const data = result.dataSkips test files automatically.
no-unsafe-type-assertions
as unknown as Type is TypeScript's escape hatch. It silently breaks every type guarantee. If you need to convert between types, validate with a schema or write a type guard.
// Bad
const user = data as unknown as User
// Good
const result = userSchema.safeParse(data)no-unvalidated-formdata
FormData.get() returns FormDataEntryValue | null. Casting to string hides a null that will blow up at runtime.
// Bad
const email = formData.get('email') as string
// Good
const email = formData.get('email')
if (!email || typeof email !== 'string') throw new Error('Missing email')no-loose-status-type
status: string accepts any string. A typo like "actve" compiles fine and silently corrupts your data.
// Bad
type Order = { status: string }
type Order = { status: string | null }
// Good
type Order = { status: 'pending' | 'paid' | 'shipped' }one-export-per-file
When you're solo, discoverability IS your architecture. One function per file means the filename tells you everything.
// Bad — two-exports.ts
export function createUser() { ... }
export function deleteUser() { ... }
// Good — create-user.ts
export function createUser() { ... }Options:
'solodev/one-export-per-file': ['error', { max: 2 }]action-must-return
A server action that doesn't return a typed response is invisible to the client. Did it succeed? Fail? The caller will never know.
// Bad — silent success
export async function updateProfile(data: FormData) {
await db.update(data)
}
// Good
export async function updateProfile(data: FormData) {
await db.update(data)
return actionSuccessResponse('Profile updated')
}Options:
'solodev/action-must-return': ['error', {
returnFunctions: ['actionSuccessResponse', 'actionFailureResponse'],
filePattern: '.action.ts'
}]require-use-server
Without the "use server" directive, Next.js silently treats your action file as a regular module. It works in dev, breaks in prod.
// Bad — missing directive
export async function createUser() { ... }
// Good
"use server"
export async function createUser() { ... }Options:
'solodev/require-use-server': ['error', { filePattern: '.server.ts' }]prefer-server-actions
fetch('/api/users') gives you untyped JSON and string URL typos. Server actions give you end-to-end TypeScript safety.
// Bad
const res = await fetch('/api/users')
const data = await res.json() // untyped
// Good
const result = await getUsers() // fully typed input + outputOptions:
'solodev/prefer-server-actions': ['error', {
internalPatterns: ['/api/', '/trpc/']
}]Skips external URLs and test files automatically.
Companion rules
These patterns are useful but don't need a custom plugin — use built-in ESLint config:
Prefer safe JSON parse
// eslint.config.mjs
{
rules: {
'no-restricted-syntax': ['error', {
selector: "CallExpression[callee.object.name='JSON'][callee.property.name='parse']:not([arguments.1])",
message: 'Use safeJsonParse() or pass a reviver to JSON.parse().'
}]
}
}Prefer Promise.allSettled
{
rules: {
'no-restricted-syntax': ['error', {
selector: "CallExpression[callee.object.name='Promise'][callee.property.name='all']",
message: 'Prefer Promise.allSettled() — Promise.all() rejects on first failure and loses other results.'
}]
}
}No direct process.env
Use the n/no-process-env rule from eslint-plugin-n.
Consistent logging (no console)
{
rules: {
'no-console': ['error', { allow: ['warn'] }]
}
}Typographic quotes
Use eslint-plugin-human — auto-fixes straight quotes to curly in JSX.
Philosophy
These rules exist because solo developers don't get code review. Every pattern here caused a real production bug that would have been caught by a second pair of eyes:
- A
throw new Error()that should have been retried - A
catch (e) { console.log(e) }that silently broke a payment flow - A
.parse()that crashed a server action with an unreadable ZodError - An
as unknown asthat passed TypeScript but failed at runtime - A
status: stringthat accepted a typo and corrupted 200 records
If you work with a team, you probably don't need all of these. If you work alone, turn them all on and never look back.
License
MIT
