Stay Updated!

Get the latest posts and insights delivered directly to your inbox

Skip to content

My Opinionated ESLint Setup for Vue Projects

Published: at 

Over the last 7+ years as a Vue developer, I’ve developed a highly opinionated style for writing Vue components. Some of these rules might not be useful for you, but I thought it was worth sharing so you can pick what fits your project. The goal is to enforce code structure that’s readable for both developers and AI agents.

These rules aren’t arbitrary—they encode patterns I’ve written about extensively:

ESLint rules are how I enforce these patterns automatically—so the codebase stays consistent even as the team grows.

💡 Note

Why linting matters more in the AI era: As AI agents write more of our code, strict linting becomes essential. It’s a form of back pressure—automated feedback mechanisms that tell an agent when it’s made a mistake, allowing it to self-correct without your intervention. You have a limited budget of feedback (your time and attention). If you spend that budget telling the agent “you missed an import” or “that type is wrong,” you can’t spend it on architectural decisions or complex logic. Type checkers, linters, and test suites act as back pressure: they push back against bad code so you don’t have to. Your ESLint config is now part of your prompt—it’s the automated quality gate that lets agents iterate until they pass.

Table of Contents#

Open Table of Contents

Why Two Linters? Oxlint + ESLint#

I run two linters: Oxlint first, then ESLint. Why? Speed and coverage.

Oxlint: The Speed Demon#

Oxlint is written in Rust. It runs 50-100x faster than ESLint on large codebases. My pre-commit hook completes in milliseconds instead of seconds.

# In package.json
"lint:oxlint": "oxlint . --fix --ignore-path .gitignore",
"lint:eslint": "eslint . --fix --cache",
"lint": "run-s lint:*"  # Runs oxlint first, then eslint

The tradeoff: Oxlint supports fewer rules. It handles:

But Oxlint lacks:

The Setup#

Oxlint runs first for fast feedback. ESLint runs second for comprehensive checks. The eslint-plugin-oxlint package tells ESLint to skip rules that Oxlint already handles.

// eslint.config.ts
import pluginOxlint from 'eslint-plugin-oxlint'

export default defineConfigWithVueTs(
  // ... other configs
  ...pluginOxlint.buildFromOxlintConfigFile('./.oxlintrc.json'),
)
// .oxlintrc.json
{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "categories": {
    "correctness": "error",
    "suspicious": "warn"
  },
  "rules": {
    "typescript/no-explicit-any": "error",
    "eslint/no-console": ["error", { "allow": ["warn", "error"] }]
  }
}

Must-Have Rules#

These rules catch real bugs and enforce maintainable code. Enable them on every Vue project.


Cyclomatic Complexity#

Complex functions are hard to test and understand. This rule limits branching logic per function.

// eslint.config.ts
{
  rules: {
    'complexity': ['warn', { max: 10 }]
  }
}
Bad
function processOrder(order: Order) {
  if (order.status === 'pending') {
    if (order.items.length > 0) {
      if (order.payment) {
        if (order.payment.verified) {
          if (order.shipping) {
            // 5 levels deep, complexity keeps growing...
          }
        }
      }
    }
  }
}
Good
function processOrder(order: Order) {
  if (!isValidOrder(order)) return

  processPayment(order.payment)
  scheduleShipping(order.shipping)
}

function isValidOrder(order: Order): boolean {
  return order.status === 'pending'
    && order.items.length > 0
    && order.payment?.verified === true
}

Threshold guidance:

ESLint: complexity


No Nested Ternaries#

Nested ternaries are hard to read. Use early returns or separate variables instead.

// eslint.config.ts
{
  rules: {
    'no-nested-ternary': 'error'
  }
}
Bad
const label = isLoading ? 'Loading...' : hasError ? 'Failed' : 'Success'
Good
function getLabel() {
  if (isLoading) return 'Loading...'
  if (hasError) return 'Failed'
  return 'Success'
}

const label = getLabel()

ESLint: no-nested-ternary


No Type Assertions#

Type assertions (as Type) bypass TypeScript’s type checker. They hide bugs. Use type guards or proper typing instead.

// eslint.config.ts
{
  rules: {
    '@typescript-eslint/consistent-type-assertions': ['error', {
      assertionStyle: 'never'
    }]
  }
}

💡 Note

as const assertions are always allowed, even with assertionStyle: 'never'. Const assertions don’t bypass type checking—they make types more specific.

Bad
const user = response.data as User  // What if it's not a User?

const element = document.querySelector('.btn') as HTMLButtonElement
element.click()  // Runtime error if element is null
Good
// Use type guards
function isUser(data: unknown): data is User {
  return typeof data === 'object'
    && data !== null
    && 'id' in data
    && 'name' in data
}

if (isUser(response.data)) {
  const user = response.data  // TypeScript knows it's User
}

// Handle nulls properly
const element = document.querySelector('.btn')
if (element instanceof HTMLButtonElement) {
  element.click()
}

TypeScript ESLint: consistent-type-assertions


No Enums#

TypeScript enums have quirks. They generate JavaScript code, have numeric reverse mappings, and behave differently from union types. Use literal unions or const objects instead.

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'TSEnumDeclaration',
      message: 'Use literal unions or `as const` objects instead of enums.'
    }]
  }
}
Bad
enum Status {
  Pending,
  Active,
  Done
}

const status: Status = Status.Pending
Good
// Literal union - simplest
type Status = 'pending' | 'active' | 'done'

// Or const object when you need values
const Status = {
  Pending: 'pending',
  Active: 'active',
  Done: 'done'
} as const

type Status = typeof Status[keyof typeof Status]

ESLint: no-restricted-syntax


No else/else-if#

else and else-if blocks increase nesting. Early returns are easier to read and reduce cognitive load.

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error',
      {
        selector: 'IfStatement > IfStatement.alternate',
        message: 'Avoid `else if`. Prefer early returns or ternary operators.'
      },
      {
        selector: 'IfStatement > :not(IfStatement).alternate',
        message: 'Avoid `else`. Prefer early returns or ternary operators.'
      }
    ]
  }
}
Bad
function getDiscount(user: User) {
  if (user.isPremium) {
    return 0.2
  } else if (user.isMember) {
    return 0.1
  } else {
    return 0
  }
}
Good
function getDiscount(user: User) {
  if (user.isPremium) return 0.2
  if (user.isMember) return 0.1
  return 0
}

ESLint: no-restricted-syntax


No Native try/catch#

Native try/catch blocks are verbose and error-prone. Use a utility function that returns a result tuple instead.

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'TryStatement',
      message: 'Use tryCatch() from @/lib/tryCatch instead of try/catch. Returns Result<T> tuple: [error, null] | [null, data].'
    }]
  }
}
Bad
async function fetchUser(id: string) {
  try {
    const response = await api.get(`/users/${id}`)
    return response.data
  } catch (error) {
    console.error(error)
    return null
  }
}
Good
async function fetchUser(id: string) {
  const [error, response] = await tryCatch(api.get(`/users/${id}`))

  if (error) {
    console.error(error)
    return null
  }

  return response.data
}

The tryCatch utility returns [error, null] or [null, data], similar to Go’s error handling.

ESLint: no-restricted-syntax


No Direct DOM Manipulation#

Vue manages the DOM. Calling document.querySelector bypasses Vue’s reactivity and template refs. Use useTemplateRef() instead. If you’re on Vue 3.5+, the built-in rule already enforces this.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/prefer-use-template-ref': 'error'
  }
}
Bad
<script setup lang="ts">
function focusInput() {
  const input = document.getElementById('my-input')
  input?.focus()
}
</script>

<template>
  <input id="my-input" />
</template>
Good
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const inputRef = useTemplateRef<HTMLInputElement>('input')

function focusInput() {
  inputRef.value?.focus()
}
</script>

<template>
  <input ref="input" />
</template>

ESLint: no-restricted-syntax


Feature Boundary Enforcement#

Features should not import from other features. This keeps code modular and prevents circular dependencies. If you’re using a feature-based architecture, this rule is essential—see How to Structure Vue Projects How to Structure Vue Projects Discover best practices for structuring Vue projects of any size, from simple apps to complex enterprise solutions. vuearchitecture for more on this approach.

// eslint.config.ts
{
  plugins: { 'import-x': pluginImportX },
  rules: {
    'import-x/no-restricted-paths': ['error', {
      zones: [
        // === CROSS-FEATURE ISOLATION ===
        // Features cannot import from other features
        { target: './src/features/workout', from: './src/features', except: ['./workout'] },
        { target: './src/features/exercises', from: './src/features', except: ['./exercises'] },
        { target: './src/features/settings', from: './src/features', except: ['./settings'] },
        { target: './src/features/timers', from: './src/features', except: ['./timers'] },
        { target: './src/features/templates', from: './src/features', except: ['./templates'] },
        { target: './src/features/benchmarks', from: './src/features', except: ['./benchmarks'] },

        // === UNIDIRECTIONAL FLOW ===
        // Shared code cannot import from features or views
        {
          target: ['./src/components', './src/composables', './src/lib', './src/db', './src/types', './src/stores'],
          from: ['./src/features', './src/views']
        },

        // Features cannot import from views (views are top-level orchestrators)
        { target: './src/features', from: './src/views' }
      ]
    }]
  }
}

Unidirectional Flow: The architecture enforces a strict dependency hierarchy. Views orchestrate features, features use shared code, but never the reverse.

views → features → shared (components, composables, lib, db, types, stores)
Bad
// src/features/workout/composables/useWorkout.ts
import { useExerciseData } from '@/features/exercises/composables/useExerciseData'
// Cross-feature import!
Good
// src/features/workout/composables/useWorkout.ts
import { ExerciseRepository } from '@/db/repositories/ExerciseRepository'
// Use shared database layer instead

eslint-plugin-import-x: no-restricted-paths


Vue Component Naming#

Consistent naming makes components easy to find and identify.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/multi-word-component-names': ['error', {
      ignores: ['App', 'Layout']
    }],
    'vue/component-definition-name-casing': ['error', 'PascalCase'],
    'vue/component-name-in-template-casing': ['error', 'PascalCase', {
      registeredComponentsOnly: false
    }],
    'vue/match-component-file-name': ['error', {
      extensions: ['vue'],
      shouldMatchCase: true
    }],
    'vue/prop-name-casing': ['error', 'camelCase'],
    'vue/attribute-hyphenation': ['error', 'always'],
    'vue/custom-event-name-casing': ['error', 'kebab-case']
  }
}
Bad
<!-- File: button.vue -->
<template>
  <base-button>Click</base-button>
</template>
Good
<!-- File: SubmitButton.vue -->
<template>
  <BaseButton>Click</BaseButton>
</template>

eslint-plugin-vue: component rules


Dead Code Detection in Vue#

Find unused props, refs, and emits before they become tech debt.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/no-unused-properties': ['error', {
      groups: ['props', 'data', 'computed', 'methods']
    }],
    'vue/no-unused-refs': 'error',
    'vue/no-unused-emit-declarations': 'error'
  }
}
Bad
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
  title: string
  subtitle: string  // Never used!
}>()

const emit = defineEmits<{
  (e: 'click'): void
  (e: 'hover'): void  // Never emitted!
}>()

const buttonRef = ref<HTMLButtonElement>()  // Never used!
</script>

<template>
  <h1>{{ title }}</h1>
  <button @click="emit('click')">Click</button>
</template>
Good
<script setup lang="ts">
const props = defineProps<{
  title: string
}>()

const emit = defineEmits<{
  (e: 'click'): void
}>()
</script>

<template>
  <h1>{{ title }}</h1>
  <button @click="emit('click')">Click</button>
</template>

eslint-plugin-vue: no-unused-properties


No Hardcoded i18n Strings#

Hardcoded strings break internationalization. The @intlify/vue-i18n plugin catches them.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  plugins: { '@intlify/vue-i18n': pluginVueI18n },
  rules: {
    '@intlify/vue-i18n/no-raw-text': ['error', {
      ignorePattern: '^[-#:()&+×/°′″%]+',
      ignoreText: ['kg', 'lbs', 'cm', 'ft/in', '', '', '', '', '', '·', '.', 'Close'],
      attributes: {
        '/.+/': ['title', 'aria-label', 'aria-placeholder', 'placeholder', 'alt']
      }
    }]
  }
}

The attributes option catches hardcoded strings in accessibility attributes too.

Bad
<template>
  <button>Save Changes</button>
  <p>No items found</p>
</template>
Good
<template>
  <button>{{ t('common.save') }}</button>
  <p>{{ t('items.empty') }}</p>
</template>

eslint-plugin-vue-i18n


No Disabling i18n Rules#

Prevent developers from bypassing i18n checks with eslint-disable comments.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  plugins: {
    '@eslint-community/eslint-comments': pluginEslintComments
  },
  rules: {
    '@eslint-community/eslint-comments/no-restricted-disable': [
      'error',
      '@intlify/vue-i18n/*'
    ]
  }
}
Bad
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<button>Save Changes</button>
Good
<button>{{ t('common.save') }}</button>

@eslint-community/eslint-plugin-eslint-comments


No Hardcoded Route Strings#

Use named routes instead of hardcoded path strings for maintainability.

// eslint.config.ts
{
  rules: {
    'no-restricted-syntax': ['error',
      {
        selector: 'CallExpression[callee.property.name="push"][callee.object.name="router"] > Literal:first-child',
        message: 'Use named routes with RouteNames instead of hardcoded path strings.'
      },
      {
        selector: 'CallExpression[callee.property.name="push"][callee.object.name="router"] > TemplateLiteral:first-child',
        message: 'Use named routes with RouteNames instead of template literals.'
      }
    ]
  }
}
Bad
router.push('/workout/123')
router.push(`/workout/${id}`)
Good
router.push({ name: RouteNames.WorkoutDetail, params: { id } })

ESLint: no-restricted-syntax


Enforce Integration Test Helpers#

Ban direct render() or mount() calls in tests. Use a centralized test helper instead. For more on testing strategies in Vue, see Vue 3 Testing Pyramid: A Practical Guide with Vitest Browser Mode Vue 3 Testing Pyramid: A Practical Guide with Vitest Browser Mode Learn a practical testing strategy for Vue 3 applications using composable unit tests, Vitest browser mode integration tests, and visual regression testing. vuetestingvitest +2 .

// eslint.config.ts
{
  files: ['src/**/__tests__/**/*.{ts,spec.ts}'],
  ignores: ['src/__tests__/helpers/**'],
  rules: {
    'no-restricted-imports': ['error', {
      paths: [
        {
          name: 'vitest-browser-vue',
          importNames: ['render'],
          message: 'Use createTestApp() from @/__tests__/helpers/createTestApp instead.'
        },
        {
          name: '@vue/test-utils',
          importNames: ['mount', 'shallowMount'],
          message: 'Use createTestApp() instead of mounting components directly.'
        }
      ]
    }]
  }
}
Bad
import { render } from 'vitest-browser-vue'
import { mount } from '@vue/test-utils'

const { getByText } = render(MyComponent)
const wrapper = mount(MyComponent)
Good
import { createTestApp } from '@/__tests__/helpers/createTestApp'

const { page } = await createTestApp({ route: '/workout' })

This ensures all tests use consistent setup with routing, i18n, and database.

ESLint: no-restricted-imports


Enforce pnpm Catalogs#

When using pnpm workspaces, enforce that dependencies use catalog references.

// eslint.config.ts
import { configs as pnpmConfigs } from 'eslint-plugin-pnpm'

export default defineConfigWithVueTs(
  // ... other configs
  ...pnpmConfigs.recommended,
)

This ensures dependencies are managed centrally in pnpm-workspace.yaml.

eslint-plugin-pnpm


Nice-to-Have Rules#

These rules improve code quality but are less critical. Enable them after the must-haves are in place.


Vue 3.5+ API Enforcement#

Use the latest Vue 3.5 APIs for cleaner code.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/define-props-destructuring': 'error',
    'vue/prefer-use-template-ref': 'error'
  }
}
Before Vue 3.5
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{ count: number }>()
const buttonRef = ref<HTMLButtonElement>()

console.log(props.count)  // Using props. prefix
</script>

<template>
  <button ref="buttonRef">Click</button>
</template>
Vue 3.5+
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const { count } = defineProps<{ count: number }>()
const buttonRef = useTemplateRef<HTMLButtonElement>('button')

console.log(count)  // Direct destructured access
</script>

<template>
  <button ref="button">Click</button>
</template>

eslint-plugin-vue: define-props-destructuring


Explicit Component APIs#

Require defineExpose and defineSlots to make component interfaces explicit.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/require-expose': 'warn',
    'vue/require-explicit-slots': 'warn'
  }
}
Bad
<script setup lang="ts">
function focus() { /* ... */ }
</script>

<template>
  <slot />
</template>
Good
<script setup lang="ts">
defineSlots<{
  default(): unknown
}>()

function focus() { /* ... */ }

defineExpose({ focus })
</script>

<template>
  <slot />
</template>

eslint-plugin-vue: require-expose


Template Depth Limit#

Deep template nesting is hard to read. Extract nested sections into components. This one matters a lot—it helps you avoid ending up with components that are 2000 lines long.

// eslint.config.ts
{
  files: ['src/**/*.vue'],
  rules: {
    'vue/max-template-depth': ['error', { maxDepth: 8 }],
    'vue/max-props': ['error', { maxProps: 6 }]
  }
}
Bad
<template>
  <div>
    <div>
      <div>
        <div>
          <div>
            <div>
              <div>
                <div>
                  <span>Too deep!</span>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
Good
<template>
  <Card>
    <CardHeader>
      <CardTitle>Title</CardTitle>
    </CardHeader>
    <CardContent>
      <span>Content</span>
    </CardContent>
  </Card>
</template>

eslint-plugin-vue: max-template-depth


Better Assertions in Tests#

Use specific matchers for clearer test failures.

// eslint.config.ts
{
  files: ['src/**/__tests__/*'],
  rules: {
    'vitest/prefer-to-be': 'error',
    'vitest/prefer-to-have-length': 'error',
    'vitest/prefer-to-contain': 'error',
    'vitest/prefer-mock-promise-shorthand': 'error'
  }
}
Bad
expect(value === null).toBe(true)
expect(arr.length).toBe(3)
expect(arr.includes('foo')).toBe(true)
Good
expect(value).toBeNull()
expect(arr).toHaveLength(3)
expect(arr).toContain('foo')

// Also prefer mock shorthands
vi.fn().mockResolvedValue('data')  // Instead of mockReturnValue(Promise.resolve('data'))

eslint-plugin-vitest


Test Structure Rules#

Keep tests organized and readable.

// eslint.config.ts
{
  files: ['src/**/__tests__/*'],
  rules: {
    'vitest/consistent-test-it': ['error', { fn: 'it' }],
    'vitest/prefer-hooks-on-top': 'error',
    'vitest/prefer-hooks-in-order': 'error',
    'vitest/no-duplicate-hooks': 'error',
    'vitest/require-top-level-describe': 'error',
    'vitest/max-nested-describe': ['error', { max: 2 }],
    'vitest/no-conditional-in-test': 'warn'
  }
}
Bad
test('works', () => {})  // Inconsistent: test vs it
it('also works', () => {})

describe('feature', () => {
  it('test 1', () => {})

  beforeEach(() => {})  // Hook after test!

  describe('nested', () => {
    describe('too deep', () => {
      describe('way too deep', () => {})  // 3 levels!
    })
  })
})
Good
describe('feature', () => {
  beforeEach(() => {})  // Hooks first, in order

  it('does something', () => {})
  it('does another thing', () => {})

  describe('edge cases', () => {
    it('handles null', () => {})
  })
})

// no-conditional-in-test prevents flaky tests
// Bad: if (data.length > 0) { expect(data[0]).toBeDefined() }
// Good: expect(data).toHaveLength(3); expect(data[0]).toBeDefined()

eslint-plugin-vitest


Prefer Vitest Locators in Tests#

Use Vitest Browser locators instead of raw DOM queries.

// eslint.config.ts
{
  files: ['src/**/__tests__/**/*.{ts,spec.ts}'],
  rules: {
    'no-restricted-syntax': ['warn', {
      selector: 'CallExpression[callee.property.name=/^querySelector(All)?$/]',
      message: 'Prefer page.getByRole(), page.getByText(), or page.getByTestId() over querySelector. Vitest locators are more resilient to DOM changes.'
    }]
  }
}
Bad
const button = container.querySelector('.submit-btn')
await button?.click()
Good
const button = page.getByRole('button', { name: 'Submit' })
await button.click()

Vitest Browser Mode


Unicorn Rules#

The eslint-plugin-unicorn package catches common mistakes and enforces modern JavaScript patterns.

// eslint.config.ts
pluginUnicorn.configs.recommended,

{
  name: 'app/unicorn-overrides',
  rules: {
    // === Enable non-recommended rules that add value ===
    'unicorn/better-regex': 'warn',              // Simplify regexes: /[0-9]/ → /\d/
    'unicorn/custom-error-definition': 'error',  // Correct Error subclassing
    'unicorn/no-unused-properties': 'warn',      // Dead code detection
    'unicorn/consistent-destructuring': 'warn',  // Use destructured vars consistently

    // === Disable rules that conflict with project conventions ===
    'unicorn/no-null': 'off',                    // We use null for database values
    'unicorn/filename-case': 'off',              // Vue uses PascalCase, tests use camelCase
    'unicorn/prevent-abbreviations': 'off',      // props, e, Db are fine
    'unicorn/no-array-callback-reference': 'off', // arr.filter(isValid) is fine
    'unicorn/no-await-expression-member': 'off', // (await fetch()).json() is fine
    'unicorn/no-array-reduce': 'off',            // reduce is useful for aggregations
    'unicorn/no-useless-undefined': 'off'        // mockResolvedValue(undefined) for TS
  }
}

Examples:

// unicorn/better-regex
// Bad:  /[0-9]/
// Good: /\d/

// unicorn/consistent-destructuring
// Bad:
const { foo } = object
console.log(object.bar)  // Uses object.bar instead of destructuring

// Good:
const { foo, bar } = object
console.log(bar)

eslint-plugin-unicorn


Custom Local Rules#

Sometimes you need rules that don’t exist. Write them yourself.

Composable Must Use Vue#

A file named use*.ts should import from Vue. If it doesn’t, it’s a utility, not a composable. For more on writing proper composables, see Vue Composables Style Guide: Lessons from VueUse’s Codebase Vue Composables Style Guide: Lessons from VueUse's Codebase A practical guide for writing production-quality Vue 3 composables, distilled from studying VueUse's patterns for SSR safety, cleanup, and TypeScript. vuetypescript .

// eslint-local-rules/composable-must-use-vue.ts
const VALID_VUE_SOURCES = new Set(['vue', '@vueuse/core', 'vue-router', 'vue-i18n'])
const VALID_PATH_PATTERNS = [/^@\/stores\//]  // Global state composables count too

function isComposableFilename(filename: string): boolean {
  return /^use[A-Z]/.test(path.basename(filename, '.ts'))
}

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      notAComposable: 'File "{{filename}}" does not import from Vue. Rename it or add Vue imports.'
    }
  },
  create(context) {
    if (!isComposableFilename(context.filename)) return {}

    let hasVueImport = false

    return {
      ImportDeclaration(node) {
        if (VALID_VUE_SOURCES.has(node.source.value)) {
          hasVueImport = true
        }
      },
      'Program:exit'(node) {
        if (!hasVueImport) {
          context.report({ node, messageId: 'notAComposable' })
        }
      }
    }
  }
}
Bad
// src/composables/useFormatter.ts
export function useFormatter() {
  return {
    formatDate: (d: Date) => d.toISOString()  // No Vue imports!
  }
}
Good
// src/lib/formatter.ts (renamed)
export function formatDate(d: Date) {
  return d.toISOString()
}

// OR add Vue reactivity:
// src/composables/useFormatter.ts
import { computed, ref } from 'vue'

export function useFormatter() {
  const locale = ref('en-US')
  const formatter = computed(() => new Intl.DateTimeFormat(locale.value))
  return { formatter, locale }
}

No Hardcoded Tailwind Colors#

Hardcoded Tailwind colors (bg-blue-500) make theming impossible. Use semantic colors (bg-primary).

// eslint-local-rules/no-hardcoded-colors.ts
// Status colors (red, amber, yellow, green, emerald) are ALLOWED for semantic states
const HARDCODED_COLORS = ['slate', 'gray', 'zinc', 'blue', 'purple', 'pink', 'orange', 'indigo', 'violet']
const COLOR_UTILITIES = ['bg', 'text', 'border', 'ring', 'fill', 'stroke']

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      noHardcodedColor: 'Avoid "{{color}}". Use semantic classes like bg-primary, text-foreground.'
    }
  },
  create(context) {
    return {
      Literal(node) {
        if (typeof node.value !== 'string') return

        const matches = findHardcodedColors(node.value)
        for (const color of matches) {
          context.report({ node, messageId: 'noHardcodedColor', data: { color } })
        }
      }
    }
  }
}
Bad
<template>
  <button class="bg-blue-500 text-white">Click</button>
</template>
Good
<template>
  <button class="bg-primary text-primary-foreground">Click</button>
</template>

💡 Note

Status colors (red, amber, yellow, green, emerald) are intentionally allowed for error/warning/success states. Only use these for semantic status indication, not general styling.


No let in describe Blocks#

Mutable variables in test describe blocks create hidden state. Use setup functions instead.

// eslint-local-rules/no-let-in-describe.ts
const rule: Rule.RuleModule = {
  meta: {
    messages: {
      noLetInDescribe: 'Avoid `let` in describe blocks. Use setup functions instead.'
    }
  },
  create(context) {
    let describeDepth = 0

    return {
      CallExpression(node) {
        if (isDescribeCall(node)) describeDepth++
      },
      'CallExpression:exit'(node) {
        if (isDescribeCall(node)) describeDepth--
      },
      VariableDeclaration(node) {
        if (describeDepth > 0 && node.kind === 'let') {
          context.report({ node, messageId: 'noLetInDescribe' })
        }
      }
    }
  }
}
Bad
describe('Login', () => {
  let user: User

  beforeEach(() => {
    user = createUser()  // Hidden mutation!
  })

  it('works', () => {
    expect(user.name).toBe('test')
  })
})
Good
describe('Login', () => {
  function setup() {
    return { user: createUser() }
  }

  it('works', () => {
    const { user } = setup()
    expect(user.name).toBe('test')
  })
})

Extract Complex Conditions#

Complex boolean expressions should have names. Extract them into variables.

// eslint-local-rules/extract-condition-variable.ts
const OPERATOR_THRESHOLD = 2  // Conditions with 2+ logical operators need extraction

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      extractCondition: 'Complex condition should be extracted into a named const.'
    }
  },
  create(context) {
    return {
      IfStatement(node) {
        // Skip patterns that TypeScript needs inline for narrowing
        if (isEarlyExitGuard(node.consequent)) return  // if (!x) return
        if (hasOptionalChaining(node.test)) return      // if (user?.name)
        if (hasTruthyNarrowingPattern(node.test)) return // if (arr && arr[0])

        if (countOperators(node.test) >= OPERATOR_THRESHOLD) {
          context.report({ node: node.test, messageId: 'extractCondition' })
        }
      }
    }
  }
}

Smart Exceptions: The rule skips several patterns that TypeScript needs inline for type narrowing:

Bad
if (user.isActive && user.role === 'admin' && !user.isBanned) {
  showAdminPanel()
}
Good
const canAccessAdminPanel = user.isActive && user.role === 'admin' && !user.isBanned

if (canAccessAdminPanel) {
  showAdminPanel()
}

Repository tryCatch Wrapper#

Database calls can fail. Enforce wrapping them in tryCatch().

// eslint-local-rules/repository-trycatch.ts
// Matches pattern: get*Repository().method()
const REPO_PATTERN = /^get\w+Repository$/

const rule: Rule.RuleModule = {
  meta: {
    messages: {
      missingTryCatch: 'Repository calls must be wrapped with tryCatch().'
    }
  },
  create(context) {
    return {
      AwaitExpression(node) {
        if (!isRepositoryMethodCall(node.argument)) return
        if (isWrappedInTryCatch(context, node)) return

        context.report({ node, messageId: 'missingTryCatch' })
      }
    }
  }
}
Bad
const workouts = await getWorkoutRepository().findAll()  // Might throw!
Good
const [error, workouts] = await tryCatch(getWorkoutRepository().findAll())

if (error) {
  showError('Failed to load workouts')
  return
}

💡 Note

This rule matches the get*Repository() pattern. Ensure your repository factory functions follow this naming convention.


The Full Config#

Complete eslint.config.ts example
import pluginEslintComments from '@eslint-community/eslint-plugin-eslint-comments'
import pluginVueI18n from '@intlify/eslint-plugin-vue-i18n'
import pluginVitest from '@vitest/eslint-plugin'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginImportX from 'eslint-plugin-import-x'
import pluginOxlint from 'eslint-plugin-oxlint'
import { configs as pnpmConfigs } from 'eslint-plugin-pnpm'
import pluginUnicorn from 'eslint-plugin-unicorn'
import pluginVue from 'eslint-plugin-vue'
import localRules from './eslint-local-rules'

export default defineConfigWithVueTs(
  { ignores: ['**/dist/**', '**/coverage/**', '**/node_modules/**'] },

  pluginVue.configs['flat/essential'],
  vueTsConfigs.recommended,
  pluginUnicorn.configs.recommended,

  // Vue component rules
  {
    files: ['src/**/*.vue'],
    rules: {
      'vue/multi-word-component-names': ['error', { ignores: ['App', 'Layout'] }],
      'vue/component-name-in-template-casing': ['error', 'PascalCase'],
      'vue/prop-name-casing': ['error', 'camelCase'],
      'vue/custom-event-name-casing': ['error', 'kebab-case'],
      'vue/no-unused-properties': ['error', { groups: ['props', 'data', 'computed', 'methods'] }],
      'vue/no-unused-refs': 'error',
      'vue/define-props-destructuring': 'error',
      'vue/prefer-use-template-ref': 'error',
      'vue/max-template-depth': ['error', { maxDepth: 8 }],
    },
  },

  // TypeScript style guide
  {
    files: ['src/**/*.{ts,vue}'],
    rules: {
      'complexity': ['warn', { max: 10 }],
      'no-nested-ternary': 'error',
      '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }],
      'no-restricted-syntax': ['error',
        { selector: 'TSEnumDeclaration', message: 'Use literal unions instead of enums.' },
        { selector: 'IfStatement > :not(IfStatement).alternate', message: 'Avoid else. Use early returns.' },
        { selector: 'TryStatement', message: 'Use tryCatch() instead of try/catch.' },
      ],
    },
  },

  // Feature boundaries
  {
    files: ['src/**/*.{ts,vue}'],
    plugins: { 'import-x': pluginImportX },
    rules: {
      'import-x/no-restricted-paths': ['error', {
        zones: [
          { target: './src/features/workout', from: './src/features', except: ['./workout'] },
          // ... other features
          { target: './src/features', from: './src/views' },  // Unidirectional flow
        ]
      }],
    },
  },

  // i18n rules
  {
    files: ['src/**/*.vue'],
    plugins: { '@intlify/vue-i18n': pluginVueI18n },
    rules: {
      '@intlify/vue-i18n/no-raw-text': ['error', { /* config */ }],
    },
  },

  // Prevent disabling i18n rules
  {
    files: ['src/**/*.vue'],
    plugins: { '@eslint-community/eslint-comments': pluginEslintComments },
    rules: {
      '@eslint-community/eslint-comments/no-restricted-disable': ['error', '@intlify/vue-i18n/*'],
    },
  },

  // Vitest rules
  {
    files: ['src/**/__tests__/*'],
    ...pluginVitest.configs.recommended,
    rules: {
      'vitest/consistent-test-it': ['error', { fn: 'it' }],
      'vitest/prefer-hooks-on-top': 'error',
      'vitest/prefer-hooks-in-order': 'error',
      'vitest/no-duplicate-hooks': 'error',
      'vitest/max-nested-describe': ['error', { max: 2 }],
      'vitest/no-conditional-in-test': 'warn',
    },
  },

  // Enforce test helpers
  {
    files: ['src/**/__tests__/**/*.{ts,spec.ts}'],
    rules: {
      'no-restricted-imports': ['error', {
        paths: [
          { name: 'vitest-browser-vue', importNames: ['render'], message: 'Use createTestApp()' },
          { name: '@vue/test-utils', importNames: ['mount'], message: 'Use createTestApp()' },
        ]
      }],
    },
  },

  // Local rules
  {
    files: ['src/**/*.{ts,vue}'],
    plugins: { local: localRules },
    rules: {
      'local/no-hardcoded-colors': 'error',
      'local/composable-must-use-vue': 'error',
      'local/repository-trycatch': 'error',
      'local/extract-condition-variable': 'error',
      'local/no-let-in-describe': 'error',
    },
  },

  // Disable rules handled by Oxlint
  ...pluginOxlint.buildFromOxlintConfigFile('./.oxlintrc.json'),

  // pnpm catalog enforcement
  ...pnpmConfigs.recommended,

  skipFormatting,
)

Summary#

CategoryRulePurpose
Must HavecomplexityLimit function complexity
Must Haveno-nested-ternaryReadable conditionals
Must Haveconsistent-type-assertionsNo unsafe as casts
Must Haveno-restricted-syntax (enums)Use unions over enums
Must Haveno-restricted-syntax (else)Prefer early returns
Must Haveno-restricted-syntax (routes)Use named routes
Must Haveimport-x/no-restricted-pathsFeature isolation
Must Havevue/no-unused-*Dead code detection
Must Have@intlify/vue-i18n/no-raw-texti18n compliance
Must Haveno-restricted-disableNo bypassing i18n
Must Haveno-restricted-importsEnforce test helpers
Nice to Havevue/define-props-destructuringVue 3.5 patterns
Nice to Havevue/max-template-depthTemplate readability
Nice to Havevitest/*Test consistency
Nice to Haveunicorn/*Modern JavaScript
Nice to Havepnpm/recommendedCatalog enforcement
Customcomposable-must-use-vueComposable validation
Customno-hardcoded-colorsTheming support
Customno-let-in-describeClean tests
Customextract-condition-variableReadable conditions
Customrepository-trycatchError handling

Start with the must-haves. Add nice-to-haves when you’re ready. Write custom rules when nothing else fits.

The combination of Oxlint for speed and ESLint for coverage gives you fast feedback during development and comprehensive checks in CI.

Press Esc or click outside to close

Stay Updated!

Subscribe to my newsletter for more TypeScript, Vue, and web dev insights directly in your inbox.

  • Background information about the articles
  • Weekly Summary of all the interesting blog posts that I read
  • Small tips and trick
Subscribe Now