TypeScript Patterns for Vue Applications

TypeScript and Vue 3 are a powerful combination. The Composition API's design makes it naturally type-friendly. Let's explore patterns that maximize type safety while keeping code clean.
Typing Component Props#
Basic Props#
interface Props {
title: string;
count: number;
isActive?: boolean;
}
const props = defineProps<Props>();
Props with Defaults#
interface Props {
title: string;
count?: number;
items?: string[];
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [] // Factory for non-primitives
});
Complex Prop Types#
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
interface Props {
user: User;
permissions: Set<string>;
onUpdate: (user: User) => void;
}
Typing Emits#
interface Emits {
(e: 'update', value: string): void;
(e: 'delete', id: number): void;
(e: 'submit', payload: { data: FormData; meta: object }): void;
}
const emit = defineEmits<Emits>();
// Usage
emit('update', 'new value');
emit('delete', 123);
Typing Refs#
// Primitive refs
const count = ref<number>(0);
const name = ref<string | null>(null);
// Object refs
interface FormState {
email: string;
password: string;
rememberMe: boolean;
}
const form = ref<FormState>({
email: '',
password: '',
rememberMe: false
});
// Template refs
const inputEl = ref<HTMLInputElement | null>(null);
const childComponent = ref<InstanceType<typeof MyComponent> | null>(null);
Typing Computed Properties#
const count = ref(0);
// Type is inferred
const doubled = computed(() => count.value * 2);
// Explicit typing when needed
const items = ref<string[]>([]);
const sortedItems = computed<string[]>(() => {
return [...items.value].sort();
});
// Writable computed
const fullName = computed<string>({
get() {
return `${firstName.value} ${lastName.value}`;
},
set(value: string) {
const [first, last] = value.split(' ');
firstName.value = first;
lastName.value = last ?? '';
}
});
Generic Composables#
Create reusable, type-safe composables:
// Generic list composable
function useList<T>(initialItems: T[] = []) {
const items = ref<T[]>(initialItems) as Ref<T[]>;
function add(item: T) {
items.value.push(item);
}
function remove(predicate: (item: T) => boolean) {
items.value = items.value.filter(item => !predicate(item));
}
function find(predicate: (item: T) => boolean): T | undefined {
return items.value.find(predicate);
}
return {
items: readonly(items),
add,
remove,
find
};
}
// Usage
const { items, add, remove } = useList<User>();
add({ id: 1, name: 'John' });
Typing Provide/Inject#
// Define injection key with type
import type { InjectionKey } from 'vue';
interface ThemeContext {
theme: Ref<'light' | 'dark'>;
toggle: () => void;
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme');
// Provider
const theme = ref<'light' | 'dark'>('light');
provide(ThemeKey, {
theme,
toggle: () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
}
});
// Consumer
const themeContext = inject(ThemeKey);
if (themeContext) {
themeContext.toggle();
}
// With default value
const themeContext = inject(ThemeKey, {
theme: ref('light'),
toggle: () => {}
});
Typing Async Operations#
interface AsyncState<T> {
data: T | null;
error: Error | null;
loading: boolean;
}
function useAsync<T>(asyncFn: () => Promise<T>) {
const state = reactive<AsyncState<T>>({
data: null,
error: null,
loading: false
});
async function execute() {
state.loading = true;
state.error = null;
try {
state.data = await asyncFn();
} catch (e) {
state.error = e instanceof Error ? e : new Error(String(e));
} finally {
state.loading = false;
}
}
return {
...toRefs(state),
execute
};
}
Discriminated Unions for State#
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
const state = ref<RequestState<User>>({ status: 'idle' });
// TypeScript narrows types in conditionals
if (state.value.status === 'success') {
console.log(state.value.data.name); // data is available
}
Conclusion#
TypeScript in Vue isn't about adding types everywhere—it's about leveraging inference while providing explicit types where they add value. Start with strict mode enabled, let TypeScript guide you, and add explicit types when inference falls short.



