TypeScript with Vue
Vue 3 was rewritten in TypeScript, and the Composition API with <script setup> is where the types actually work. Options API typing is possible but fights you; that alone is a reason to prefer Composition in a typed codebase.
Typing props
Prefer the type-only declaration — the compiler generates the runtime declaration from it:
<script setup lang="ts">
interface Props {
id: number;
label?: string;
items: string[];
}
const props = withDefaults(defineProps<Props>(), { label: 'Untitled' });
</script>
defineProps<Props>() gives compile-time checking and editor completion at the call site. The runtime object form (defineProps({ id: { type: Number, required: true } })) gives runtime validation in development but weaker inference. You cannot use both for the same component.
withDefaults supplies defaults for optional props. Since 3.5 you can also use reactive props destructure with plain default syntax:
<script setup lang="ts">
const { label = 'Untitled' } = defineProps<Props>();
</script>
The compiler rewrites label into a property access, so it stays reactive — which is the opposite of the usual “don’t destructure reactive things” rule and worth flagging, because it is a genuine exception.
Typing emits
<script setup lang="ts">
const emit = defineEmits<{
select: [id: number];
close: [];
}>();
emit('select', 42);
</script>
The tuple syntax names the payload. Emitting an event not in the type, or with the wrong payload, is a compile error — which is the main thing untyped Vue codebases get wrong at component boundaries.
Typing v-model
<script setup lang="ts">
const model = defineModel<string>(); // required: defineModel<string>({ required: true })
const count = defineModel<number>('count', { default: 0 });
</script>
defineModel (3.4+) replaced the modelValue prop plus update:modelValue emit pattern. It returns a writable ref that stays in sync with the parent. Anything still writing the prop/emit pair by hand is pre-3.4 code.
Generic components
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ items: T[]; selected: T | null }>();
const emit = defineEmits<{ select: [item: T] }>();
</script>
The generic attribute makes the component generic over its props, so a <DataTable :items="users" /> yields select payloads typed as User. This is what makes typed list, table and select components possible without casting.
Typing provide / inject
Untyped injection returns unknown and everyone casts. Use an InjectionKey:
// keys.ts
import type { InjectionKey } from 'vue';
export const authKey = Symbol() as InjectionKey<{ user: Ref<User | null> }>;
// provider
provide(authKey, { user });
// consumer — typed, no cast
const auth = inject(authKey); // { user: Ref<User|null> } | undefined
inject returns T | undefined unless you pass a default or assert. Handling the undefined case is the honest option; a throw in a small useAuth() wrapper is the common pattern. See 09_provide_inject.md.
Typing refs and template refs
const count = ref(0); // Ref<number>, inferred
const user = ref<User | null>(null); // annotate when the initial value is null
const input = useTemplateRef<HTMLInputElement>('input'); // 3.5+
useTemplateRef replaced the older “declare a ref whose name matches the ref attribute” convention and types the element properly. For a child component instance, ref<InstanceType<typeof Child>>() gives the exposed API — and only what defineExpose exposes.
Tooling
| Tool | Role |
|---|---|
| Vue Language Tools (Volar) | editor support; the Vetur era is over |
vue-tsc |
type-check .vue files in CI — tsc alone does not see templates |
vite-plugin-checker |
run type checking during dev without blocking HMR |
vue-tsc --noEmit in CI is the non-negotiable part. Vite strips types without checking them, so a build passing proves nothing about type correctness.
Enable strict: true. The one Vue-specific gotcha is that template expressions are type-checked too, so a codebase turning strict on for the first time surfaces errors in templates nobody had ever type-checked.
Interview angle
- “How do you type props in Vue 3?” -
defineProps<Props>()with a type-only declaration andwithDefaultsfor defaults. The runtime object form is the alternative, and you cannot mix the two in one component. - “How do you make a component generic over its data?” -
<script setup lang="ts" generic="T">. That is how a table or select component gives correctly typed event payloads instead ofany. - “How do you type
inject?” - with anInjectionKey<T>symbol, so the provider and consumer agree without casting. Rememberinjectcan returnundefined. - “Does
vite buildtype-check?” - no. Vite transpiles and strips types via esbuild. You needvue-tscin CI, and this catches people out when a typed project ships type errors. - “Why is Composition API better for TypeScript?” - types flow naturally through plain function calls and returns. Options API relies on
thisbeing assembled from several option objects, which needsdefineComponentand still infers worse.