Skip to content

Instantly share code, notes, and snippets.

@suntong
Created December 11, 2025 07:36
Show Gist options
  • Select an option

  • Save suntong/5b2e50abe707f5657ffeceb07cb5f4e7 to your computer and use it in GitHub Desktop.

Select an option

Save suntong/5b2e50abe707f5657ffeceb07cb5f4e7 to your computer and use it in GitHub Desktop.

TypeScript for Vue Programmers in 2025

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.

1. Starting with 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.

Why This Happens

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 any

Or the slightly better but still ugly:

        as any as ReturnType<typeof useTodosQuery>

The Correct Modern Ways (2025 Best Practices)

Option 1 -- Let Vue Query infer everything (recommended 2024+)

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 typed

This is the golden path. Zero any, perfect types.

Option 2 -- Manual ReturnType (when you can't use createQuery)

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[]

2. What "as any as X" Actually Means

This pattern is called double assertion or type assertion chaining:

       something as any as TargetType

It 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.

3. The Modern Vue + TypeScript Setup (2025 Standard)

// 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"]
}

4. Practical TypeScript Patterns Every Vue Dev Must Know

1. DefineComponent is dead (Vue 3.4+)

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.

2. Pinia + TypeScript Heaven

// 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    

3. Composables with Proper Return Types

// 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    

4. Props with Default Values (the correct way)

const props = withDefaults(defineProps<{
  items: string[]
  limit?: number
}>(), {
  limit: 10
})   

Now props.limit is number, not number | undefined

5. Handling Route Params/Reactives Correctly

 // 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>

5. The Ultimate Cheat Sheet (2025)

// 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
}>()   

Final Advice -- How to Switch from JS to TS in Vue (The Real Path)

  1. Week 1: Turn on "strict": true → fix all the red squiggles (mostly any[])
  2. Week 2: Convert all <script setup> to lang="ts"
  3. Week 3: Add defineProps, defineEmits, defineModel everywhere
  4. Week 4: Migrate Pinia stores to TS
  5. Week 5: Add typed composables and queries
  6. Ongoing: Never commit as any again
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment