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:
- How to Write Clean Vue ComponentsHow to Write Clean Vue ComponentsThere are many ways to write better Vue components. One of my favorite ways is to separate business logic into pure functions.vuearchitecture explains why I separate business logic into pure functions
- How to Structure Vue ProjectsHow to Structure Vue ProjectsDiscover best practices for structuring Vue projects of any size, from simple apps to complex enterprise solutions.vuearchitecture covers my feature-based architecture approach
- Building a Modular Monolith with Nuxt LayersBuilding a Modular Monolith with Nuxt Layers: A Practical GuideLearn how to build scalable applications using Nuxt Layers to enforce clean architecture boundaries without the complexity of microservices.nuxtvuearchitecture+1 applies feature isolation to Nuxt projects
- The Problem with
asin TypeScriptThe Problem with as in TypeScript: Why It's a Shortcut We Should AvoidLearn why as can be a Problem in Typescripttypescript covers why I ban type assertions - Robust Error Handling in TypeScriptRobust Error Handling in TypeScript: A Journey from Naive to Rust-Inspired SolutionsLearn to write robust, predictable TypeScript code using Rust's Result pattern. This post demonstrates practical examples and introduces the ts-results library, implementing Rust's powerful error management approach in TypeScript.typescript introduces the Result pattern behind my
tryCatchrule - Vue 3 Testing PyramidVue 3 Testing Pyramid: A Practical Guide with Vitest Browser ModeLearn a practical testing strategy for Vue 3 applications using composable unit tests, Vitest browser mode integration tests, and visual regression testing.vuetestingvitest+2 explains my integration-first testing strategy
- Frontend Testing GuideFrontend Testing Guide: 10 Essential Rules for Naming TestsLearn how to write clear and maintainable frontend tests with 10 practical naming rules. Includes real-world examples showing good and bad practices for component testing across any framework.testingvitest shares my test naming conventions
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#
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:
- Correctness & suspicious patterns - catches bugs early
- Core ESLint equivalents -
no-console,no-explicit-any - TypeScript basics -
array-type,consistent-type-definitions
But Oxlint lacks:
- Vue-specific rules (
vue/*) - Import boundary rules (
import-x/*) - Vitest testing rules (
vitest/*) - i18n rules (
@intlify/vue-i18n/*) - Custom local rules
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 }]
}
}
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...
}
}
}
}
}
}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 default:
20(lenient) - This project uses:
10(stricter) - Consider
15as a middle ground for legacy codebases
No Nested Ternaries#
Nested ternaries are hard to read. Use early returns or separate variables instead.
// eslint.config.ts
{
rules: {
'no-nested-ternary': 'error'
}
}
const label = isLoading ? 'Loading...' : hasError ? 'Failed' : 'Success'function getLabel() {
if (isLoading) return 'Loading...'
if (hasError) return 'Failed'
return 'Success'
}
const label = getLabel()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.
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// 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()
}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.'
}]
}
}
enum Status {
Pending,
Active,
Done
}
const status: Status = Status.Pending// 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]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.'
}
]
}
}
function getDiscount(user: User) {
if (user.isPremium) {
return 0.2
} else if (user.isMember) {
return 0.1
} else {
return 0
}
}function getDiscount(user: User) {
if (user.isPremium) return 0.2
if (user.isMember) return 0.1
return 0
}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].'
}]
}
}
async function fetchUser(id: string) {
try {
const response = await api.get(`/users/${id}`)
return response.data
} catch (error) {
console.error(error)
return null
}
}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.
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'
}
}
<script setup lang="ts">
function focusInput() {
const input = document.getElementById('my-input')
input?.focus()
}
</script>
<template>
<input id="my-input" />
</template><script setup lang="ts">
import { useTemplateRef } from 'vue'
const inputRef = useTemplateRef<HTMLInputElement>('input')
function focusInput() {
inputRef.value?.focus()
}
</script>
<template>
<input ref="input" />
</template>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 ProjectsHow to Structure Vue ProjectsDiscover 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)
// src/features/workout/composables/useWorkout.ts
import { useExerciseData } from '@/features/exercises/composables/useExerciseData'
// Cross-feature import!// src/features/workout/composables/useWorkout.ts
import { ExerciseRepository } from '@/db/repositories/ExerciseRepository'
// Use shared database layer insteadVue 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']
}
}
<!-- File: button.vue -->
<template>
<base-button>Click</base-button>
</template><!-- File: SubmitButton.vue -->
<template>
<BaseButton>Click</BaseButton>
</template>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'
}
}
<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><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>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.
<template>
<button>Save Changes</button>
<p>No items found</p>
</template><template>
<button>{{ t('common.save') }}</button>
<p>{{ t('items.empty') }}</p>
</template>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/*'
]
}
}
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<button>Save Changes</button><button>{{ t('common.save') }}</button>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.'
}
]
}
}
router.push('/workout/123')
router.push(`/workout/${id}`)router.push({ name: RouteNames.WorkoutDetail, params: { id } })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 ModeVue 3 Testing Pyramid: A Practical Guide with Vitest Browser ModeLearn 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.'
}
]
}]
}
}
import { render } from 'vitest-browser-vue'
import { mount } from '@vue/test-utils'
const { getByText } = render(MyComponent)
const wrapper = mount(MyComponent)import { createTestApp } from '@/__tests__/helpers/createTestApp'
const { page } = await createTestApp({ route: '/workout' })This ensures all tests use consistent setup with routing, i18n, and database.
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.
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'
}
}
<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><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>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'
}
}
<script setup lang="ts">
function focus() { /* ... */ }
</script>
<template>
<slot />
</template><script setup lang="ts">
defineSlots<{
default(): unknown
}>()
function focus() { /* ... */ }
defineExpose({ focus })
</script>
<template>
<slot />
</template>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 }]
}
}
<template>
<div>
<div>
<div>
<div>
<div>
<div>
<div>
<div>
<span>Too deep!</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template><template>
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
</CardHeader>
<CardContent>
<span>Content</span>
</CardContent>
</Card>
</template>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'
}
}
expect(value === null).toBe(true)
expect(arr.length).toBe(3)
expect(arr.includes('foo')).toBe(true)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'))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'
}
}
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!
})
})
})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()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.'
}]
}
}
const button = container.querySelector('.submit-btn')
await button?.click()const button = page.getByRole('button', { name: 'Submit' })
await button.click()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)
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 CodebaseVue Composables Style Guide: Lessons from VueUse's CodebaseA 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' })
}
}
}
}
}
// src/composables/useFormatter.ts
export function useFormatter() {
return {
formatDate: (d: Date) => d.toISOString() // No Vue imports!
}
}// 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 } })
}
}
}
}
}
<template>
<button class="bg-blue-500 text-white">Click</button>
</template><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' })
}
}
}
}
}
describe('Login', () => {
let user: User
beforeEach(() => {
user = createUser() // Hidden mutation!
})
it('works', () => {
expect(user.name).toBe('test')
})
})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:
- Early exit guards:
if (!user) return - Optional chaining:
if (user?.name) - Truthy narrowing:
if (arr && arr[0])
if (user.isActive && user.role === 'admin' && !user.isBanned) {
showAdminPanel()
}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' })
}
}
}
}
const workouts = await getWorkoutRepository().findAll() // Might throw!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#
Summary#
| Category | Rule | Purpose |
|---|---|---|
| Must Have | complexity | Limit function complexity |
| Must Have | no-nested-ternary | Readable conditionals |
| Must Have | consistent-type-assertions | No unsafe as casts |
| Must Have | no-restricted-syntax (enums) | Use unions over enums |
| Must Have | no-restricted-syntax (else) | Prefer early returns |
| Must Have | no-restricted-syntax (routes) | Use named routes |
| Must Have | import-x/no-restricted-paths | Feature isolation |
| Must Have | vue/no-unused-* | Dead code detection |
| Must Have | @intlify/vue-i18n/no-raw-text | i18n compliance |
| Must Have | no-restricted-disable | No bypassing i18n |
| Must Have | no-restricted-imports | Enforce test helpers |
| Nice to Have | vue/define-props-destructuring | Vue 3.5 patterns |
| Nice to Have | vue/max-template-depth | Template readability |
| Nice to Have | vitest/* | Test consistency |
| Nice to Have | unicorn/* | Modern JavaScript |
| Nice to Have | pnpm/recommended | Catalog enforcement |
| Custom | composable-must-use-vue | Composable validation |
| Custom | no-hardcoded-colors | Theming support |
| Custom | no-let-in-describe | Clean tests |
| Custom | extract-condition-variable | Readable conditions |
| Custom | repository-trycatch | Error 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.
