The saying that "TypeScript is just typed JavaScript" is only 10% of the truth.
The other 90% is: TypeScript in modern Vue is not about adding types --- it's about removing entire classes of runtime bugs, getting god-tier autocomplete, and making refactoring 10× safer.
Let's start from the #1 most common TypeScript pain point.
When the ESLint + TypeScript screamed:
'useTodosQuery' is typed as 'any' Property 'data' does not exist on type 'any'
This is actually the #1 most common TypeScript pain point in Vue 2024--2025.
useTodosQuery is generated by @tanstack/vue-query's defineQuery or comes from untyped template.
The return type is incredibly complex:
Ref<Readonly<Ref<...deeply nested generics...> | undefined> | undefined>So the dev did the classic escape hatch:
const { data: todos = [], isLoading } = useTodosQuery() as anyOr the slightly better but still ugly:
as any as ReturnType<typeof useTodosQuery>If you're using @tanstack/vue-query ≥ v5 with the new createQuery style:
// queries/todos.ts
import { createQuery } from '@tanstack/vue-query'
export const useTodosQuery = createQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// TypeScript now knows the exact shape of data!
}) Now in your component:
const { data: todos = [], isLoading, error } = useTodosQuery()
// todos is automatically Todo[] | undefined
// No casting, no any, perfect autocomplete
todos.value?.[0].title // fully typedThis is the golden path. Zero any, perfect types.
import { useQuery } from '@tanstack/vue-query'
const useTodosQuery = () => useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// Correct way – no 'any' anywhere
type TodosQueryReturn = ReturnType<typeof useTodosQuery>
// Or even better, extract just the data type
type TodosData = ReturnType<typeof useTodosQuery>['data']
const { data: todos = [] } = useTodosQuery() as unknown as {
data: Ref<TodosData>
}But the cleanest is:
const query = useTodosQuery()
const todos = computed(() => query.data.value ?? [])
// Now todos.value is perfectly typed as Todo[]This pattern is called double assertion or type assertion chaining:
something as any as TargetTypeIt means: "I know more than TypeScript, shut up and treat this as TargetType".
It's safe only when you're 100% sure, because it bypasses all checks.
Use only when:
- Dealing with legacy libraries
- Very complex conditional types that TS can't infer
Never use it for Vue Query in 2024+ -- it's always solvable properly.
// tsconfig.json – the one that just works
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"jsx": "preserve",
"jsxImportSource": "vue",
"allowJs": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client", "unplugin-icons/types/vue"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}Never write this again:
import { defineComponent } from 'vue'
export default defineComponent({ ... })Just do:
<script setup lang="ts">
const props = defineProps<{
title: string
count?: number
}>()
const emit = defineEmits<{
(e: 'update:title', value: string): void
(e: 'close'): void
}>()
</script>This gives you 100% type safety with zero boilerplate.
// stores/todos.ts
import { acceptHMRUpdate } from 'pinia'
export const useTodosStore = defineStore('todos', {
state: () => ({
items: [] as Todo[],
loading: false,
}),
getters: {
completed: (state) => state.items.filter(t => t.completed),
},
actions: {
async load() {
this.loading = true
this.items = await fetchTodos()
this.loading = false
}
}
})
// Full typing everywhere
const store = useTodosStore()
store.items[0].title // fully typed // composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
const update = (e: MouseEvent) => {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
// This is the key – explicit return type!
return { x: readonly(x), y: readonly(y) } as const
}
// Usage – perfect autocomplete
const { x, y } = useMouse()
x.value // number const props = withDefaults(defineProps<{
items: string[]
limit?: number
}>(), {
limit: 10
}) Now props.limit is number, not number | undefined
// NEVER do this
const route = useRoute()
const id = route.params.id as string // dangerous!
// Do this instead
const route = useRoute<'/users/[id]'>() // with typed routes
const id = computed(() => route.params.id as string) // safe because route is typed Or use vue-router-typed:
const id = useRouteParam('id') // returns Ref<string>// Props
defineProps<{
msg: string
labels?: string[]
}>()
// With defaults
withDefaults(defineProps<{
size?: 'sm' | 'md' | 'lg'
}>(), {
size: 'md'
})
// Emits
const emit = defineEmits<{
(e: 'click', id: number): void
(e: 'update:modelValue', value: string): void
}>()
// Expose to template/ref
defineExpose({
focus,
validate
})
// Model (v-model)
const model = defineModel<string>('modelValue', { required: true })
// Slots
defineSlots<{
default?: (props: { item: Todo }) => any
header?: () => any
}>() - Week 1: Turn on
"strict": true→ fix all the red squiggles (mostly any[]) - Week 2: Convert all
<script setup>tolang="ts" - Week 3: Add
defineProps,defineEmits,defineModeleverywhere - Week 4: Migrate Pinia stores to TS
- Week 5: Add typed composables and queries
- Ongoing: Never commit
as anyagain