frontend / vue / 17_typescript_with_vue.md

TypeScript with Vue

5 interview angles 4 min read source

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 and withDefaults for 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 of any.
  • “How do you type inject?” - with an InjectionKey<T> symbol, so the provider and consumer agree without casting. Remember inject can return undefined.
  • “Does vite build type-check?” - no. Vite transpiles and strips types via esbuild. You need vue-tsc in 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 this being assembled from several option objects, which needs defineComponent and still infers worse.