Typing Vue.js Components with the Composition API in TypeScript
Introduction
As Vue 3 and the Composition API gain adoption, writing well-typed components with TypeScript becomes essential for building maintainable, reliable UIs. Intermediate developers often feel confident with basic TypeScript and Vue setup but stumble when typed props, emits, refs, provides, generics, and reusable composition functions enter the picture. This guide fills that gap with a practical, example-driven approach.
You will learn how to type props and emits correctly, create fully typed setup return values, leverage generics in composition utilities, and integrate TypeScript patterns that scale across an app. We cover common errors and show how to avoid them, plus offer troubleshooting tips for problems like 'argument of type X is not assignable' and incorrect ref types.
By the end of this tutorial you will be able to write Composition API components that provide strong editor feedback, reduce runtime bugs, and make refactors safer. Examples include defineComponent usage, the script setup macro, typed provide/inject, and patterns for reusable composition functions. The techniques here apply to single-file components, utilities, and larger codebases.
Background & Context
Vue 3's Composition API focuses on logical reusability and clearer separation of concerns. TypeScript complements the Composition API by giving you static guarantees about the shapes of props, the return types of composition functions, and the events a component emits. Combining both allows teams to enforce contracts between components and detect mistakes during development rather than at runtime.
Typing Composition API code is different from class or Options API patterns. Instead of relying on class decorators or implicit typings, you define explicit types for props, events, and refs. This requires familiarity with TypeScript features like generics, utility types, and the Ref
Key Takeaways
- How to type props in defineComponent and script setup using PropType and withDefaults
- Typing component emits and ensuring downstream consumers get correct event payload types
- Correctly typing refs, reactive, and computed values using Ref
and Reactive types - Reusable, generic composition functions with proper inference and ReturnType usage
- Strategies to handle third-party untyped libs and JSON data using interfaces or type aliases
- Common TypeScript errors in Vue patterns and how to fix them with practical examples
Prerequisites & Setup
Before following the examples, ensure you have:
- Vue 3 and TypeScript installed in your project
- A Vite or Vue CLI project configured for TypeScript
- Editor support for TypeScript (VS Code recommended)
Recommended tsconfig flags include strict mode options that catch many errors early. For a suggested set of flags and migration tips, see our guide on recommended tsconfig.json strictness flags. Install types for Vue with package manager and enable the script setup macro if you prefer that syntax.
Main Tutorial Sections
1) Typing Basic Props with PropType
Vue props need explicit typing when the type is more complex than a primitive. Use PropType from 'vue' to annotate complex shapes.
Example:
import { defineComponent, PropType } from 'vue'
interface User {
id: string
name: string
age?: number
}
export default defineComponent({
props: {
user: {
type: Object as PropType<User>,
required: true
}
},
setup(props) {
// props.user is correctly typed as User
return {}
}
})If you prefer the script setup macro, use defineProps with the same PropType pattern. Choosing between interfaces and type aliases for incoming JSON or payloads is common; our guide on typing JSON data using interfaces or type aliases helps decide the right form.
2) Using script setup and withDefaults
The script setup macro simplifies syntax but needs type annotations for props to get full benefits.
Example:
<script setup lang='ts'>
import { withDefaults, defineProps } from 'vue'
interface Options { theme?: string }
const props = withDefaults(defineProps<{ id: string; options?: Options }>(), {
options: () => ({ theme: 'light' })
})
// props.id is string, props.options is Options with default applied
</script>WithDefaults helps you provide default values while keeping type inference for props intact. Defaults must match declared types to avoid assignment errors.
3) Typing Emits and Event Payloads
Emits can be typed so parent components get correct payload hints. Define an emits validator or use generic helper types.
Example:
import { defineComponent } from 'vue'
export default defineComponent({
emits: {
'update': (payload: { value: number }) => typeof payload.value === 'number'
},
setup(_, { emit }) {
const updateValue = (n: number) => emit('update', { value: n })
return { updateValue }
}
})For more advanced callback patterns and function types, our article on typing callbacks in TypeScript illustrates common approaches you can reuse within emit handlers.
4) Typing Refs, Reactive, and Computed
Ref and reactive need explicit generic types when inference is insufficient. Use Ref
Example:
import { ref, reactive, computed, Ref } from 'vue'
const count: Ref<number> = ref(0)
const state = reactive({ items: [] as string[] })
const doubled = computed(() => count.value * 2)
// Type safety: state.items is string[] and count.value is numberIf you work with array methods, good typing helps avoid mistakes; review patterns for typing array methods for map/filter/reduce examples that transfer to computed lists.
5) Creating Typed Composition Functions
Reusable composition functions should expose typed inputs and outputs to preserve developer ergonomics.
Example:
import { ref, computed, Ref } from 'vue'
export function useCounter(initial = 0) {
const count: Ref<number> = ref(initial)
const increment = () => { count.value += 1 }
const double = computed(() => count.value * 2)
return { count, increment, double } as const
}
// useCounter() inferred return types allow autocompletion in componentsFor more advanced generics in utilities, use ReturnType and generic parameters to keep inference intact across modules.
6) Typing provide / inject
Provide and inject work well with TypeScript when you centralize keys and types.
Example:
import { provide, inject, InjectionKey, ref } from 'vue'
interface AuthContext { userId: string | null }
const authKey: InjectionKey<AuthContext> = Symbol('auth')
export function provideAuth() {
const ctx = { userId: null }
provide(authKey, ctx)
}
export function useAuth() {
const ctx = inject(authKey)
if (!ctx) throw new Error('Auth not provided')
return ctx
}Using InjectionKey ensures injected values carry type information. Avoid using plain symbols without types when possible.
7) Working with Third-Party Libraries and Untyped Modules
When a dependency lacks types, you have a few options: install community types, write minimal ambient declarations, or wrap the API with a typed adapter.
Example ambient declaration:
declare module 'untyped-lib' {
export function createThing(options: any): any
}However, aim to create a small typed facade that maps the untyped API to a typed interface. This helps keep the rest of your codebase strictly typed. Learn how to organize types and modules in larger apps in our article on organizing your TypeScript code.
8) Typing Async Operations and API Responses
When fetching data, annotate promise results and use typed interfaces for payloads to avoid shape surprises.
Example:
import { ref } from 'vue'
interface ApiUser { id: string; name: string }
async function fetchUser(id: string): Promise<ApiUser> {
const res = await fetch(`/api/users/${id}`)
const data = await res.json() as ApiUser
return data
}
const user = ref<ApiUser | null>(null)
fetchUser('1').then(u => user.value = u)For patterns and pitfalls when typing promises and async/await flows, see our guide on typing asynchronous JavaScript.
9) Leveraging Generics for Scalable Components
Generic components allow you to create highly reusable building blocks. Use TypeScript generics with defineComponent or within composition functions.
Example:
import { defineComponent, PropType } from 'vue'
export default defineComponent({
props: {
items: { type: Array as PropType<T[]>, required: true } as any
}
})The complexity here comes from ensuring the generic T is declared and inferred correctly. For many cases, it's easier to type the composition function around a generic and return typed helpers to the component.
10) Debugging Type Errors and Common Fixes
Typical problems include incorrect union assignments, missing properties, or wrong ref types. A frequent error message is 'argument of type X is not assignable to parameter of type Y'. When you encounter this, walk the types backward, and consult our troubleshooting article on resolving the "argument of type 'X' is not assignable to parameter of type 'Y'" error in TypeScript for concrete fixes.
Common fixes:
- Add or relax generic constraints with extends
- Narrow types with type guards at runtime
- Use as casting sparingly and prefer narrow interfaces
Advanced Techniques
Once you are comfortable with basic typing, use these expert patterns to improve maintainability:
- Use explicit ReturnType
in components to avoid drifting types across refactors - Create typed factories for provides using InjectionKey to ensure strictness across feature modules
- Use mapped types for normalized stores or complex form models
- Build small typed adapters around untyped libs instead of sprinkling any across the codebase
- Consider centralizing shared DTO types for API responses and reference them across frontend and backend if possible
For a deeper exploration of organizing types and maintaining a scalable codebase, consult the article on best practices for writing clean and maintainable TypeScript code. Also consider the tradeoffs between read-only types and runtime immutability libraries in our piece on using readonly vs immutability libraries.
Best Practices & Common Pitfalls
Dos:
- Prefer explicit types for props and public composition function APIs
- Enable strict TypeScript settings early to catch errors early; check recommended tsconfig flags
- Keep types small and focused: split large interfaces into composable pieces
- Use InjectionKey for provide/inject patterns to preserve type info
Don'ts:
- Don’t overuse any to silence compiler errors; instead, refine types or write narrow adapters
- Avoid implicit any in public APIs that are consumed across modules
- Don’t rely on runtime checks alone; combine runtime validators with TypeScript types when validating external data
Troubleshooting tips:
- When emits produce unexpected types, ensure your emits validator matches the payload shape
- If props inference fails in script setup, explicitly annotate the defineProps call
- For common array and object method typing issues, review patterns in typing array methods
Real-World Applications
Typed Composition API components matter most in medium-to-large applications where multiple developers interact with shared UI primitives, forms, and data models. Examples:
- A design system: typed components ensure consumers use props and slots correctly
- Form libraries: typing form state, validation functions, and submission payloads prevents incorrect payloads from reaching the server
- Feature modules: typed provide/inject enables safe cross-feature communication
For patterns when typing Mongoose models and backend DTOs consumed by a Vue frontend, understanding how to type schemas and models can be useful; see our primer on typing Mongoose schemas and models to align backend and frontend types.
Conclusion & Next Steps
Typing Vue 3 components with the Composition API pays dividends in reliability and maintainability. Start by typing props, emits, and refs in your smallest components, then extract typed composition functions and shared DTOs. Next, tighten your tsconfig settings and gradually increase strictness to catch regressions early. Explore the linked articles for deep dives into callbacks, async patterns, and organization strategies.
Recommended next steps:
- Add typing to a small component and write unit tests that exercise typed behaviors
- Extract a reusable composition function and document its types
- Adopt stricter tsconfig flags from the referenced guide and fix resulting type errors iteratively
Enhanced FAQ
Q: How should I decide between interface and type alias for component props or API data?
A: Both work, but interfaces are extensible and better for public, extendable shapes. Type aliases are more flexible for unions and mapped types. If you model JSON payloads returned by an API, see typing JSON data using interfaces or type aliases to choose based on extension needs and patterns.
Q: Can I rely on Vue's inference or should I always specify types explicitly?
A: Vue offers robust inference for many simple cases, but explicit types prevent silent regressions and improve editor experience. For public APIs like props, emits, and composition returns, prefer explicit typing. When in doubt, annotate the public surface and allow locals to infer.
Q: How do I type a generic composition function that works with different models?
A: Use TypeScript generics and constrain them when necessary. Example:
export function useList<T>(initial: T[] = []) {
const items = ref<T[]>(initial)
const add = (t: T) => items.value.push(t)
return { items, add }
}Return types remain inferred where you consume the hook, preserving autocompletion.
Q: What is the correct way to type emits to ensure parent components get proper types?
A: Define the emits object with validators or create a typed emits helper signature. The validator approach is recommended because it pairs runtime guard with type annotation. You can also use generics and helper types to map event names to payload shapes.
Q: Why do I sometimes see 'This expression is not callable' or similar runtime type complaints in Vue + TypeScript?
A: Those errors usually indicate you attempted to treat a value as a function or call something that TypeScript thinks is not callable. Often the root cause is incorrect generics or missing ReturnType. Our article on fixing the "This expression is not callable" error in TypeScript has general strategies to diagnose and fix such issues.
Q: How should I handle untyped third-party Vue plugins or components?
A: Create a small typed wrapper that exposes only the properties and methods you need, or write minimal ambient module declarations as a stopgap. Prefer wrapping and mapping to typed interfaces for long-term maintainability.
Q: Are there special considerations for typing slots and scoped slots in the Composition API?
A: Yes. Typed slots are best expressed through component generic patterns or by defining explicit slot props on the child component's props interface. When building design system components, provide clear slot prop types so consumers get accurate hints.
Q: How do I approach typing async operations and handling API errors gracefully?
A: Always type the expected success payload with an interface or type alias and model possible error shapes. Use discriminated unions for responses that may be success or error. For promise and async typing patterns, consult our guide on typing asynchronous JavaScript.
Q: What are common pitfalls when migrating an existing Vue 2 TypeScript codebase to Vue 3 Composition API types?
A: Common pitfalls include mismatched prop shapes, reliance on global 'this', and implicit any creeping into composition functions. Adopt strict tsconfig flags incrementally, and consider refactoring small modules to composition functions with explicit types. For broader organization guidance, see organizing your TypeScript code.
Q: Where can I find tips for improving TypeScript developer experience while typing Vue components?
A: Use well-scoped interfaces, prefer explicit prop/emits typing, centralize shared DTOs, and use editor plugins that surface type information. Additionally, read our article on best practices for writing clean and maintainable TypeScript code for general strategies that directly improve DX.
Additional Resources
- Typing event handlers and common DOM typing patterns can be useful when components interact with raw events; check typing event handlers in React with TypeScript for complementary patterns that translate to Vue event handlers.
- For debugging complex type relationships, learning patterns from typing object methods (keys, values, entries) in TypeScript can be helpful when normalizing objects in state.
- If you build Redux-like or local state managers, typing actions and reducers in TypeScript provides useful patterns; see typing Redux actions and reducers in TypeScript.
With these patterns and the linked deep dives, you should be well-equipped to type real-world Vue 3 Composition API components and scale typing across your application.
