eslint-plugin-opinionated-vue-ts
v2.20.0
Published
Comprehensive, opinionated ESLint plugin and flat config for Vue and TypeScript, featuring custom architecture rules and optimized integrations (Prettier, Vitest, Unicorn, Oxlint).
Maintainers
Readme
eslint-plugin-opinionated-vue-ts
Opinionated ESLint rules and a full flat config for Vue + TypeScript projects.
Credits
This project is inspired by and borrows ideas from Alexander Op’s ESLint setup for Vue projects. Please see his work here:
- https://github.com/alexanderop
- https://alexop.dev/posts/opinionated-eslint-setup-vue-projects/
Install
pnpm add -D eslint-plugin-opinionated-vue-tsInstall required peer dependencies for your stack:
- eslint
- @eslint/js
- typescript-eslint
- eslint-plugin-vue
- eslint-plugin-prettier
- @vue/eslint-config-typescript
- @vue/eslint-config-prettier
- eslint-plugin-oxlint
- eslint-plugin-unicorn
- @vitest/eslint-plugin
- globals
Usage (Flat Config)
// eslint.config.js
import fullConfig from 'eslint-plugin-opinionated-vue-ts/configs/full'
export default fullConfigUsage (Plugin Only)
// eslint.config.js
import opinionated from 'eslint-plugin-opinionated-vue-ts'
export default [
{
plugins: { local: opinionated },
rules: {
'local/composable-must-use-vue': 'error',
'local/extract-condition-variable': 'error',
'local/no-let-in-describe': 'error',
'local/enforce-type-naming': 'error',
'local/imports-on-top': 'error',
'local/max-lines-per-file': ['error', { max: 150 }],
},
},
]What This Config Includes
This package bundles a full flat config that layers:
- ESLint recommended
- TypeScript ESLint recommended
- Vue recommended (flat)
- Unicorn recommended
- Vitest recommended
- Oxlint recommended
- Prettier integration
- Custom local rules (documented below)
- Optional: Feature Boundaries & Architecture (manual enablement)
Per request, the default ESLint rules and Unicorn rules are not documented here. Only explicitly configured rules are documented, including Vue, TypeScript ESLint, Vitest, and local custom rules.
Rule Reference (Explicitly Configured)
Local Custom Rules
local/composable-must-use-vue
Files named useXxx.ts must import from Vue or Vue-related libraries (vue, @vueuse/core, vue-router, vue-i18n). If not, they should be renamed to a utility.
// ❌ Bad: no Vue imports
export function useDateFormatter() {
return (value: string) => value
}
// ✅ Good: uses Vue
import { computed } from 'vue'
export function useDateFormatter(value: string) {
return computed(() => value.trim())
}local/extract-condition-variable
If an if condition uses 3+ logical operators, extract it to a named variable.
// ❌ Bad
if (user.isActive && !user.isBanned && user.role === 'admin') {
// ...
}
// ✅ Good
const canAccessAdmin = user.isActive && !user.isBanned && user.role === 'admin'
if (canAccessAdmin) {
// ...
}local/no-let-in-describe
Disallow let declarations inside describe blocks to avoid shared mutable test state.
// ❌ Bad
describe('thing', () => {
let user
beforeEach(() => {
user = createUser()
})
})
// ✅ Good
describe('thing', () => {
function setup() {
return { user: createUser() }
}
it('works', () => {
const { user } = setup()
})
})local/enforce-type-naming
Interfaces must start with I and types must end with Type.
// ❌ Bad
interface User {}
type User = { id: string }
// ✅ Good
interface IUser {}
type UserType = { id: string }local/imports-on-top
Ensures all import declarations stay at the top of the file.
What it does:
- Allows
importstatements only before any other top-level statement. - Reports an error when an
importappears after code (variables, functions, expressions, etc.). - Applies to
*.js,*.ts, and*.vuefiles through the full config local rules block.
// ❌ Bad
const user = getCurrentUser()
import { getAccountNavItems } from '~/composables/admin/layout/accountNav'
// ✅ Good
import { getAccountNavItems } from '~/composables/admin/layout/accountNav'
const user = getCurrentUser()local/max-lines-per-file
Limits JavaScript, TypeScript, and Vue files to a maximum number of lines (default 150).
What it does:
- Counts the total number of lines in each
*.js,*.ts, and*.vuefile. - Reports an ESLint error when a file has more lines than the configured
max. - Uses
150as the default limit when nomaxoption is provided.
// eslint.config.js
export default [
{
rules: {
'local/max-lines-per-file': ['error', { max: 150 }],
},
},
]TypeScript ESLint Rules
no-unused-vars
Disallows unused JavaScript variables (configured to ignore names starting with _).
// ❌ Bad
const foo = 1
// ✅ Good
const _foo = 1no-undef
Disallows usage of undeclared variables.
// ❌ Bad
console.log(notDeclared)
// ✅ Good
const declared = 'ok'
console.log(declared)@typescript-eslint/no-unused-vars
Disallows unused variables (configured to ignore names starting with _).
// ❌ Bad
const foo = 1
// ✅ Good
const _foo = 1@typescript-eslint/no-explicit-any
Disallows any to preserve type safety.
// ❌ Bad
const value: any = getValue()
// ✅ Good
const value: unknown = getValue()@typescript-eslint/consistent-type-assertions
Disallows type assertions (configured with assertionStyle: "never").
// ❌ Bad
const value = foo as string
// ✅ Good
function isString(input: unknown): input is string {
return typeof input === 'string'
}complexity
Warns when function cyclomatic complexity exceeds 10.
// ❌ Bad (too many branches)
function decide(x: number) {
if (x === 1) return 'a'
else if (x === 2) return 'b'
else if (x === 3) return 'c'
else if (x === 4) return 'd'
else if (x === 5) return 'e'
else if (x === 6) return 'f'
else if (x === 7) return 'g'
else if (x === 8) return 'h'
else if (x === 9) return 'i'
else if (x === 10) return 'j'
return 'k'
}
// ✅ Good
const decisionMap: Record<number, string> = {
1: 'a',
2: 'b',
}no-nested-ternary
Disallows nested ternary expressions.
// ❌ Bad
const label = isAdmin ? 'admin' : isEditor ? 'editor' : 'user'
// ✅ Good
let label = 'user'
if (isAdmin) label = 'admin'
else if (isEditor) label = 'editor'no-restricted-syntax
Configured here to disallow enum declarations and else blocks after if.
// ❌ Bad (enum)
enum Role {
Admin = 'admin',
}
// ✅ Good
type RoleType = 'admin' | 'user'
// ❌ Bad (else)
if (!user) {
return
} else {
processUser(user)
}
// ✅ Good
if (!user) {
return
}
processUser(user)Vitest Rules
vitest/consistent-test-it
Enforces it instead of test.
// ❌ Bad
test('works', () => {})
// ✅ Good
it('works', () => {})vitest/prefer-hooks-on-top
Requires hooks (beforeEach, afterEach, etc.) to appear before tests in a describe block.
// ❌ Bad
it('works', () => {})
beforeEach(() => {})
// ✅ Good
beforeEach(() => {})
it('works', () => {})vitest/prefer-hooks-in-order
Requires hooks to follow the conventional order (beforeAll, beforeEach, afterEach, afterAll).
// ❌ Bad
afterEach(() => {})
beforeEach(() => {})
// ✅ Good
beforeEach(() => {})
afterEach(() => {})vitest/no-duplicate-hooks
Disallows duplicate hooks of the same type within a scope.
// ❌ Bad
beforeEach(() => {})
beforeEach(() => {})
// ✅ Good
beforeEach(() => {})vitest/max-nested-describe
Limits nested describe depth to 2.
// ❌ Bad
describe('a', () => {
describe('b', () => {
describe('c', () => {})
})
})
// ✅ Good
describe('a', () => {
describe('b', () => {})
})vitest/no-conditional-in-test
Disallows conditionals inside tests to keep them deterministic.
// ❌ Bad
it('works', () => {
if (something) {
expect(true).toBe(true)
}
})
// ✅ Good
it('works', () => {
expect(something).toBe(true)
})no-restricted-imports (tests only)
In **/__tests__/**/*.{ts,spec.ts}, disallows direct render import from vitest-browser-vue and direct mount import from @vue/test-utils.
// ❌ Bad
import { render } from 'vitest-browser-vue'
import { mount } from '@vue/test-utils'
// ✅ Good
import { createTestApp } from '@/test/helpers/createTestApp'Vue Rules
Each rule below is configured explicitly in this package. Examples show the expected direction.
vue/multi-word-component-names
Enforces multi-word component names (with App and Layout allowed).
<!-- ❌ Bad -->
<script setup lang="ts">
defineOptions({ name: 'Button' })
</script>
<!-- ✅ Good -->
<script setup lang="ts">
defineOptions({ name: 'BaseButton' })
</script>vue/attribute-hyphenation
Enforces camelCase props in templates (configured as never).
<!-- ❌ Bad -->
<MyComp user-name="Dan" />
<!-- ✅ Good -->
<MyComp userName="Dan" />vue/v-on-event-hyphenation
Enforces camelCase event names in templates (configured as never).
<!-- ❌ Bad -->
<MyComp @user-click="onClick" />
<!-- ✅ Good -->
<MyComp @userClick="onClick" />vue/no-v-html
Disallows v-html to prevent XSS.
<!-- ❌ Bad -->
<div v-html="rawHtml" />
<!-- ✅ Good -->
<div>{{ rawHtml }}</div>vue/block-lang
Requires lang="ts" on <script> blocks.
<!-- ❌ Bad -->
<script setup>
</script>
<!-- ✅ Good -->
<script setup lang="ts">
</script>vue/block-order
Enforces block order: template, script[setup], style[scoped].
<!-- ❌ Bad -->
<script setup lang="ts"></script>
<template></template>
<!-- ✅ Good -->
<template></template>
<script setup lang="ts"></script>vue/component-api-style
Enforces <script setup> only.
<!-- ❌ Bad -->
<script>
export default {}
</script>
<!-- ✅ Good -->
<script setup lang="ts">
</script>vue/define-emits-declaration
Requires type-based defineEmits declaration.
// ❌ Bad
const emit = defineEmits(['save'])
// ✅ Good
const emit = defineEmits<{
(e: 'save'): void
}>()vue/define-macros-order
Enforces macro ordering (defineOptions → defineModel → defineProps → defineEmits → defineSlots).
// ❌ Bad
const emit = defineEmits<{}>()
const props = defineProps<{ id: string }>()
// ✅ Good
const props = defineProps<{ id: string }>()
const emit = defineEmits<{}>()vue/define-props-declaration
Requires type-based defineProps declaration.
// ❌ Bad
const props = defineProps(['id'])
// ✅ Good
const props = defineProps<{ id: string }>()vue/html-button-has-type
Requires <button> elements to include a type attribute.
<!-- ❌ Bad -->
<button>Save</button>
<!-- ✅ Good -->
<button type="button">Save</button>vue/require-default-prop (disabled)
Defaults are not required for optional props.
// ✅ Allowed
const props = defineProps<{ optional?: string }>()vue/no-multiple-objects-in-class
Disallows multiple object syntax in :class bindings.
<!-- ❌ Bad -->
<div :class="[{ active }, { disabled }]" />
<!-- ✅ Good -->
<div :class="{ active, disabled }" />vue/no-root-v-if
Disallows v-if on the root element.
<!-- ❌ Bad -->
<template>
<div v-if="ok"></div>
</template>
<!-- ✅ Good -->
<template v-if="ok">
<div></div>
</template>vue/no-template-target-blank
Disallows target="_blank" without rel="noopener noreferrer".
<!-- ❌ Bad -->
<a href="..." target="_blank">Link</a>
<!-- ✅ Good -->
<a href="..." target="_blank" rel="noopener noreferrer">Link</a>vue/no-undef-properties
Disallows accessing undefined properties in templates.
<!-- ❌ Bad -->
<div>{{ missingProp }}</div>
<!-- ✅ Good -->
<div>{{ definedProp }}</div>vue/no-use-v-else-with-v-for
Disallows v-else/v-else-if on the same element as v-for.
<!-- ❌ Bad -->
<li v-for="item in items" v-else></li>
<!-- ✅ Good -->
<li v-for="item in items"></li>
<li v-else></li>vue/no-useless-mustaches
Disallows unnecessary mustaches in templates.
<!-- ❌ Bad -->
<div>{{ "text" }}</div>
<!-- ✅ Good -->
<div>text</div>vue/no-useless-v-bind
Disallows v-bind with string literal values (use static attributes instead).
<!-- ❌ Bad -->
<div v-bind:foo="'bar'" />
<!-- ✅ Good -->
<div foo="bar" />vue/no-v-text
Disallows v-text directive.
<!-- ❌ Bad -->
<div v-text="msg"></div>
<!-- ✅ Good -->
<div>{{ msg }}</div>vue/padding-line-between-blocks
Enforces blank lines between <template>, <script>, and <style> blocks.
<!-- ✅ Good -->
<template></template>
<script setup lang="ts"></script>
<style scoped></style>vue/prefer-define-options
Prefers defineOptions() over default export for component options.
// ❌ Bad
export default { name: 'MyComp' }
// ✅ Good
defineOptions({ name: 'MyComp' })vue/prefer-separate-static-class
Requires static class attribute to be separate from :class.
<!-- ❌ Bad -->
<div :class="['root', classes]"></div>
<!-- ✅ Good -->
<div class="root" :class="classes"></div>vue/prefer-true-attribute-shorthand
Requires shorthand for boolean attributes.
<!-- ❌ Bad -->
<button disabled="disabled"></button>
<!-- ✅ Good -->
<button disabled></button>vue/require-macro-variable-name
Requires macro variable names to match their macro (defineProps → props).
// ❌ Bad
const p = defineProps<{ id: string }>()
// ✅ Good
const props = defineProps<{ id: string }>()vue/require-typed-ref
Requires typed refs in <script setup>.
// ❌ Bad
const input = ref(null)
// ✅ Good
const input = ref<HTMLInputElement | null>(null)vue/v-for-delimiter-style
Enforces delimiter style in v-for expressions.
<!-- ❌ Bad -->
<div v-for="item of items"></div>
<!-- ✅ Good -->
<div v-for="item in items"></div>vue/valid-define-options
Ensures defineOptions has a valid argument.
// ❌ Bad
defineOptions()
// ✅ Good
defineOptions({ name: 'MyComp' })vue/component-name-in-template-casing
Enforces PascalCase for components in templates.
<!-- ❌ Bad -->
<my-button />
<!-- ✅ Good -->
<MyButton />vue/prop-name-casing
Enforces camelCase for prop names.
// ❌ Bad
const props = defineProps<{ 'user-name': string }>()
// ✅ Good
const props = defineProps<{ userName: string }>()vue/custom-event-name-casing
Enforces kebab-case for custom events.
// ❌ Bad
emit('userClick')
// ✅ Good
emit('user-click')vue/no-unused-properties
Disallows unused component properties (props, data, computed, methods).
// ❌ Bad
const props = defineProps<{ id: string; name: string }>()
// name never used
// ✅ Good
const props = defineProps<{ id: string }>()vue/no-unused-refs
Disallows unused refs.
// ❌ Bad
const count = ref(0)
// ✅ Good
const count = ref(0)
console.log(count.value)vue/define-props-destructuring
Requires destructuring of defineProps.
// ❌ Bad
const props = defineProps<{ id: string }>()
// ✅ Good
const { id } = defineProps<{ id: string }>()vue/prefer-use-template-ref
Prefers useTemplateRef() over string refs.
// ❌ Bad
const input = ref(null)
// ✅ Good
const input = useTemplateRef<HTMLInputElement>('input')vue/max-template-depth
Limits template depth to 7.
<!-- ❌ Bad -->
<div>
<div>
<div>
<div>
<div>
<div>
<div>
<div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>Feature Boundaries & Architecture
This package previously included a rule to enforce unidirectional flow between features and views. This rule is now disabled by default as it requires a specific project architecture (Feature Folders).
If your project follows this architecture, you can manually enable this rule in your eslint.config.js:
// eslint.config.js
import fullConfig from 'eslint-plugin-opinionated-vue-ts/configs/full'
export default [
...fullConfig,
{
files: ["**/*.{ts,vue}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["**/views/**"],
message: "❌ UNIDIRECTIONAL FLOW: Features cannot import from views. Views orchestrate features.",
},
],
},
],
},
},
]Architectural Principle: Unidirectional Flow
In a feature-based architecture, Features should be self-contained and reusable logic/UI units. Views (or Pages) are responsible for orchestrating these features. To maintain this hierarchy and prevent circular dependencies or tight coupling:
- Views can import Features.
- Features SHOULD NOT import from Views.
This ensures that features remain independent of the specific pages they are used in.
Releasing a New Version
To create a new version and update the changelog, follow these steps:
1. Ensure everything is committed
Make sure your working directory is clean.
2. Run tests and build (optional but recommended)
pnpm build3. Bump the version
Use the pnpm version command. This will automatically update package.json, generate/update CHANGELOG.md, and create a git commit and tag.
- Patch (bug fixes):
pnpm version patch - Minor (new features):
pnpm version minor - Major (breaking changes):
pnpm version major
Note: For breaking changes, ensure your commit messages use the ! suffix (e.g., feat!: ...) or include BREAKING CHANGE: in the footer to ensure the changelog reflects them correctly.
4. Push changes and tags
git push origin main --tags5. Publish to NPM
pnpm publishThe prepublishOnly script will automatically run pnpm build before publishing.
