Vue & TypeScript Coding Standards
Essential patterns, naming conventions, and structural rules enforced across the codebase. TypeScript strict mode, Composition API, and Lodash required — no exceptions.
Non-negotiable requirements
Core Requirements
Five non-negotiable rules. Every file in the codebase must comply — ESLint enforces most of them automatically.
TypeScript
Strict mode, no exceptions. Explicit types on all function signatures and interfaces for all objects.
Template Literals
Backticks for ALL strings — even constants with no interpolation. Never mix quote styles.
Arrow Functions
Preferred for all declarations. Use shorthand return for single expressions; braces for multi-line.
Destructure Props
Always destructure with inline defaults. Never use withDefaults.
Lodash Required
Use Lodash for all data manipulation. Do not use native .filter().map() chains.
CVA in templates: never wrap CVA calls in a computed(). Use the CVA function directly in the template binding: :class="buttonClasses({ intent })"
Template Literals — Always
Backticks are required for every string — including constants with no interpolation. Mixing quote styles anywhere in a file will fail the linter.
const message = `User ${name} logged in`
const token = `session_token`
const url = `https://api.example.com`
const key = `AUTH_KEY`
const mixed = 'string' + "other" + `${var}`
const single = 'just a string'
const double = "also wrong"
Rule: if you are reaching for ' or ", stop and use a backtick instead. ESLint will flag any non-template string literals.
Arrow Functions — Preferred
Use arrow functions for all declarations. Shorthand return for single-expression functions; braces with explicit return for multi-line logic. Always include early returns.
const getUser = (id: string) =>
users.find(u => u.id === id)
const isAdmin = (user: User) =>
user.role === `admin`
const formatName = (u: User) =>
`${u.firstName} ${u.lastName}`
function getUser(id: string) {
return users.find(u => u.id === id)
}
async function fetchData() {
const res = await api.get(`/data`)
return res
}
const processUser = (user?: User) => {
if (!user) return null
return {
id: user.id,
name: `${user.firstName} ${user.lastName}`
}
}
const handleSubmit = async () => {
if (!isValid.value) return
const result = await api.save(form)
emit(`saved`, result)
}
Conditionals and Vue Props
Conditionals — always use braces
if (user.isActive) {
enableFeatures()
}
if (!token) {
return redirect(`/login`)
}
if (count > 0) {
showResults()
} else {
showEmptyState()
}
if (user.isActive) enableFeatures()
if (!token) return redirect(`/login`)
Vue Props — always destructure
export interface Props {
user: User
loading?: boolean
onSave?: (u: User) => void
}
const {
user,
loading = false,
onSave = () => {}
} = defineProps<Props>()
const props = withDefaults(
defineProps<Props>(),
{ loading: false }
)
The Props interface must be exported so it can be consumed by parent components and tests.
Lodash — Required for Data Manipulation
Lodash is required for all collection and object operations. Do not use native array methods when a Lodash equivalent exists.
const result = chain(users)
.filter(u => u.isActive)
.sortBy(`lastName`)
.map(u => ({
id: u.id,
name: `${u.firstName} ${u.lastName}`
}))
.value()
const name = get(user, `profile.name`, `Unknown`)
const role = get(config, `auth.role`, `viewer`)
const unique = uniqBy(items, `id`)
const grouped = groupBy(events, `type`)
const flat = flatMap(pages, `items`)
const stripped = omit(payload, [`password`])
const partial = pick(user, [`id`, `name`])
// Do not use native when Lodash exists
const result = users
.filter(u => u.isActive)
.sort((a, b) =>
a.lastName.localeCompare(b.lastName))
.map(u => ({
id: u.id,
name: `${u.firstName} ${u.lastName}`
}))
// Throws if profile is undefined
const name = user.profile.name
// Use get() instead
const name = get(user, `profile.name`, `Unknown`)
Always call .value() to terminate a chain() — forgetting it returns a wrapper object, not the array.
i18n and Class Variance Authority
i18n — always destructure as $t
const { t: $t } = useI18n()
// Usage
const label = $t(`common.save`)
const error = $t(`errors.required`, { field })
const { t } = useI18n()
// This naming is disallowed
t(`common.save`)
The $t naming mirrors Vue's global property, making template and script usage visually consistent.
CVA — use directly in template binding
// script
const buttonClasses = cva(`btn`, {
variants: {
intent: {
primary: `bg-blue-500 text-white`,
ghost: `bg-transparent border`
},
size: {
sm: `text-sm px-2`,
md: `text-base px-4`
}
}
})
<!-- template: bind directly -->
<button :class="buttonClasses({ intent, size })">
{{ label }}
</button>
// This is wrong — do not do this
const classes = computed(() =>
buttonClasses({ intent, size })
)
Vue Component Block Order
Block order is enforced by ESLint via the vue/block-order rule. Script first, template second, style third. Any other order will fail the linter.
<script lang="ts" setup>
// 1. Script block MUST come first
import { storeToRefs } from 'pinia'
export interface Props {
user: User
}
const { user } = defineProps<Props>()
</script>
<template>
<!-- 2. Template block MUST come second -->
<div>{{ user.name }}</div>
</template>
<style scoped>
/* 3. Style block MUST come third (if present) */
/* Use Tailwind — avoid custom CSS */
</style>
Script block — internal order (6 sections)
<script lang="ts" setup>
// 1. Imports
import { storeToRefs } from 'pinia'
// 2. Props interface (MUST be exported)
export interface Props { user: User }
const { user, loading = false } = defineProps<Props>()
// 3. Emits
const emit = defineEmits<{ save: [user: User] }>()
// 4. Reactive state
const isValid = ref(false)
// 5. Computed
const displayName = computed(() =>
`${user.firstName} ${user.lastName}`
)
// 6. Methods
const handleSave = () => {
if (!isValid.value) return
emit(`save`, user)
}
</script>
TypeScript Patterns
Strict mode is required. Explicit return types on all function signatures. Use interfaces for objects, type aliases for unions, and utility types where applicable.
// Explicit return type on async functions
const getUser = (id: string): Promise<User | null> =>
api.getUser(id)
// Interface for all object shapes
interface User {
id: string
name: string
email: string
role: Role
}
// Type alias for unions
type Status = `active` | `inactive` | `pending`
type Role = `admin` | `editor` | `viewer`
// Omit — derive from existing interface
type CreateUser = Omit<User, `id`>
// Partial — all fields optional
type UpdateUser = Partial<User>
// Pick — narrow to specific fields
type UserSummary = Pick<User, `id` | `name`>
// Record — typed key-value map
type RoleMap = Record<Role, string[]>
// Required — strip optionals
type StrictConfig = Required<Config>
No any. If you find yourself reaching for any, use unknown and narrow with a type guard, or define the correct interface instead.
Auto-Imported Symbols
These packages are auto-imported by the build config. Manually importing them in .vue files is an ESLint error.
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cva } from 'class-variance-authority'
import { toast } from 'vue-sonner'
import { storeToRefs } from 'pinia'
// Vue reactivity — available globally
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (val) => { console.log(val) })
// i18n
const { t: $t } = useI18n()
// CVA
const btnClass = cva(`btn`, { variants: {} })
// Toast
toast.success(`Saved!`)
toast.error(`Something went wrong`)
What IS manually imported (non-auto-imported)
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import type { User } from '@/types/user'
When in doubt, try using it without an import first. If the linter doesn't complain, it is auto-imported. If it does, add the import.
Critical DON'Ts
These patterns are either ESLint errors or architectural violations. A PR containing any of them will not be merged.
Code style
- No custom CSS — use Tailwind utility classes exclusively
- No mixed quote styles — backticks only, everywhere
- No function declarations — use arrow functions only
- No braceless conditionals — always wrap if/else bodies in
{}
Vue patterns
- No
withDefaults— destructure props with inline defaults - No computed CVA — bind CVA calls directly in the template
- No manual auto-imports — do not import
ref,computed,cva,useI18n,toastin.vuefiles
// ❌ Custom CSS
<style scoped>
.my-button { color: red; }
</style>
// ❌ Mixed quotes
const x = 'hello' + "world" + `!`
// ❌ Function declaration
function doThing() { return true }
// ❌ Braceless if
if (ok) doThing()
// ❌ withDefaults
const props = withDefaults(defineProps<P>(), {})
// ❌ Computed CVA
const cls = computed(() => cva(...)({ variant }))
// ❌ Manual auto-import
import { ref } from 'vue'
File Organization & Naming Conventions
Naming conventions are ESLint-enforced. Wrong casing in the wrong location causes a lint error.
Directory structure
src/
├── components/ # PascalCase.vue
├── composables/ # camelCase.ts
├── utils/ # camelCase.ts
└── types/ # camelCase.ts
layer-name/ # team-specific layer
├── components/ # PascalCase.vue
├── pages/ # kebab-case.vue
├── composables/ # camelCase.ts
└── queries/ # camelCase.ts
Naming conventions by location
| Location | Convention | Example |
|---|---|---|
**/components/** | PascalCase.vue | UserProfile.vue |
**/pages/** | kebab-case.vue | user-settings.vue |
| Dynamic routes | [param].vue | [id].vue |
composables/ | camelCase.ts | useAuth.ts |
utils/ | camelCase.ts | formatDate.ts |
queries/ | camelCase.ts | userQueries.ts |
**/assets/** | kebab-case | user-icon.svg |
Test files (*.test.ts, *.spec.ts, *.stories.ts) are exempt from the camelCase rule for composables/utils.
At-a-Glance Reference
Always do
- Backticks for every string
- Arrow functions for every function declaration
- Braces around every
if/elsebody - Destructure props with inline defaults
- Export the Props interface
- Use Lodash
chain()+.value()for collections - Use Lodash
get()for deep property access - Rename
tto$ton i18n destructure - Bind CVA directly in template:
:class="fn({ variant })" - Script → Template → Style block order
Never do
- Single or double quotes for strings
functiondeclarations (use arrow functions)- Braceless conditionals
withDefaults()for props- Wrap CVA in
computed() - Import
ref,computed,cva,useI18n,toast - Native
.filter().map()chains (use Lodash) - Custom CSS (use Tailwind)
anytype (useunknown+ type guard)- PascalCase page files or kebab-case component files
Full examples in src/stories/coding-standards.mdx